1+ import { mkdirSync } from "node:fs" ;
2+ import { dirname } from "node:path" ;
3+ import * as lockfile from "proper-lockfile" ;
14import type { Credentials , CredentialsStore } from "./credentials.js" ;
25import { type OAuthConfig , refreshAccessToken } from "./flow.js" ;
36
@@ -6,32 +9,48 @@ export interface TokenManagerOptions {
69 oauthConfig : OAuthConfig ;
710 /**
811 * Refresh when the access token's remaining lifetime falls below this
9- * many milliseconds. Default 60s — generous enough to ride out clock
10- * skew + a slow refresh round-trip without ever sending an expired
11- * token over the wire.
12+ * many milliseconds. Default 5 minutes — wide enough to absorb clock
13+ * skew, slow refresh round-trips, and a request that starts just under
14+ * the wire so it never travels with an already-expired token .
1215 */
1316 refreshSkewMs ?: number ;
17+ /**
18+ * Max time to wait acquiring the cross-process refresh lock before
19+ * giving up. If another `codebase` process is mid-refresh we wait;
20+ * if the lock is wedged (stale, crashed peer) we bail rather than
21+ * hang the user's request.
22+ */
23+ lockTimeoutMs ?: number ;
1424}
1525
1626/**
1727 * Read-through, refresh-aware accessor for the OAuth access token.
1828 *
19- * Pi-mono's `getApiKey` runs on every API call, so we have to be fast in
20- * the common case (token still valid). Slow path: refresh + persist before
21- * returning the new token. A single in-flight promise (`pending`) prevents
22- * a burst of concurrent calls from firing parallel refreshes at the same
23- * moment — they all await the one refresh that's already running.
29+ * Two coordination layers:
30+ * 1. In-memory single-flight (`pending`) — multiple awaits within ONE
31+ * process collapse into one refresh round-trip.
32+ * 2. Filesystem lockfile on the credentials directory — multiple
33+ * `codebase` processes on the same machine coordinate so only one
34+ * refreshes at a time. The others wait, re-read the rotated token,
35+ * and skip their own refresh. Necessary because refresh tokens are
36+ * often one-time-use: two parallel refreshes burn the shared refresh
37+ * token and one process gets logged out.
38+ *
39+ * Pi-mono's `getApiKey` runs on every API call, so the cached-token fast
40+ * path stays branch-free; the slow path only fires near expiry.
2441 */
2542export class TokenManager {
2643 private readonly store : CredentialsStore ;
2744 private readonly oauthConfig : OAuthConfig ;
2845 private readonly refreshSkewMs : number ;
46+ private readonly lockTimeoutMs : number ;
2947 private pending : Promise < string > | null = null ;
3048
3149 constructor ( options : TokenManagerOptions ) {
3250 this . store = options . store ;
3351 this . oauthConfig = options . oauthConfig ;
34- this . refreshSkewMs = options . refreshSkewMs ?? 60_000 ;
52+ this . refreshSkewMs = options . refreshSkewMs ?? 5 * 60_000 ;
53+ this . lockTimeoutMs = options . lockTimeoutMs ?? 30_000 ;
3554 }
3655
3756 /**
@@ -47,39 +66,75 @@ export class TokenManager {
4766 if ( ! creds . refreshToken ) {
4867 throw new Error ( "access token expired and no refresh token saved — run `codebase auth login`" ) ;
4968 }
50- return this . refresh ( creds . refreshToken ) ;
69+ return this . refresh ( ) ;
5170 }
5271
5372 private needsRefresh ( creds : Credentials ) : boolean {
5473 if ( ! creds . expiresAt ) return false ;
5574 return creds . expiresAt - this . refreshSkewMs <= Date . now ( ) ;
5675 }
5776
58- private refresh ( refreshToken : string ) : Promise < string > {
77+ private refresh ( ) : Promise < string > {
5978 if ( this . pending ) return this . pending ;
6079 this . pending = ( async ( ) => {
6180 try {
62- const next = await refreshAccessToken ( this . oauthConfig , refreshToken ) ;
63- // Preserve fields the refresh response doesn't echo back (source,
64- // email, userId) so they survive every rotation. The refresh
65- // response is authoritative for tokens + expiry; we layer the
66- // stable metadata back on top.
67- const existing = this . store . load ( ) ;
68- this . store . save ( {
69- accessToken : next . accessToken ,
70- refreshToken : next . refreshToken ?? existing ?. refreshToken ?? refreshToken ,
71- expiresAt : next . expiresAt ,
72- scopes : next . scopes ,
73- source : existing ?. source ?? next . source ,
74- userId : existing ?. userId ?? next . userId ,
75- email : existing ?. email ?? next . email ,
76- provider : existing ?. provider ?? next . provider ,
77- } ) ;
78- return next . accessToken ;
81+ return await this . refreshWithLock ( ) ;
7982 } finally {
8083 this . pending = null ;
8184 }
8285 } ) ( ) ;
8386 return this . pending ;
8487 }
88+
89+ /**
90+ * Acquire a cross-process lock, then re-check (another process may have
91+ * already refreshed by the time we get the lock), then refresh + save.
92+ * The double-check is the whole point of taking the lock — it converts
93+ * N parallel refreshes into 1 refresh + N-1 reads of the fresh token.
94+ */
95+ private async refreshWithLock ( ) : Promise < string > {
96+ const lockDir = dirname ( this . store . filePath ) ;
97+ mkdirSync ( lockDir , { recursive : true } ) ;
98+ const release = await lockfile . lock ( lockDir , {
99+ retries : {
100+ retries : Math . max ( 1 , Math . ceil ( this . lockTimeoutMs / 500 ) ) ,
101+ minTimeout : 250 ,
102+ maxTimeout : 1000 ,
103+ factor : 1.5 ,
104+ randomize : true ,
105+ } ,
106+ // 30s — stale-lock window. Survives a normal refresh; releases
107+ // quickly enough that a crashed peer doesn't wedge us for long.
108+ stale : 30_000 ,
109+ } ) ;
110+ try {
111+ const reread = this . store . load ( ) ;
112+ if ( reread && ! this . needsRefresh ( reread ) ) return reread . accessToken ;
113+ if ( ! reread ?. refreshToken ) {
114+ throw new Error ( "access token expired and no refresh token saved — run `codebase auth login`" ) ;
115+ }
116+ const next = await refreshAccessToken ( this . oauthConfig , reread . refreshToken ) ;
117+ // Preserve fields the refresh response doesn't echo back (source,
118+ // email, userId) so they survive every rotation. The refresh
119+ // response is authoritative for tokens + expiry; we layer the
120+ // stable metadata back on top.
121+ this . store . save ( {
122+ accessToken : next . accessToken ,
123+ refreshToken : next . refreshToken ?? reread . refreshToken ,
124+ expiresAt : next . expiresAt ,
125+ scopes : next . scopes ,
126+ source : reread . source ?? next . source ,
127+ userId : reread . userId ?? next . userId ,
128+ email : reread . email ?? next . email ,
129+ provider : reread . provider ?? next . provider ,
130+ } ) ;
131+ return next . accessToken ;
132+ } finally {
133+ await release ( ) . catch ( ( ) => {
134+ // Release can fail if the lock was stale-released by another
135+ // process while we held it. The credentials are already saved
136+ // at that point — there's nothing useful to do here.
137+ } ) ;
138+ }
139+ }
85140}
0 commit comments