diff --git a/apps/ade-cli/src/serviceManager/common.ts b/apps/ade-cli/src/serviceManager/common.ts index 23c231b05..e0b0a5ed1 100644 --- a/apps/ade-cli/src/serviceManager/common.ts +++ b/apps/ade-cli/src/serviceManager/common.ts @@ -261,9 +261,12 @@ export async function terminatePidGracefullyAsync( const deadline = Date.now() + (deps.graceTimeoutMs ?? 1_500); while (Date.now() < deadline) { if (!pidAlive(pid)) return; + // This timer is awaited: it must stay referenced, or a standalone CLI + // (e.g. `ade serve --install-service` repairing a wedged brain) can run + // out of referenced work and exit before the SIGKILL escalation and the + // subsequent `launchctl load` ever happen. await new Promise((resolve) => { - const timer = setTimeout(resolve, 50); - timer.unref?.(); + setTimeout(resolve, 50); }); } try { diff --git a/apps/ade-cli/src/serviceManager/installLaunchd.ts b/apps/ade-cli/src/serviceManager/installLaunchd.ts index 7da721e50..252fa63ff 100644 --- a/apps/ade-cli/src/serviceManager/installLaunchd.ts +++ b/apps/ade-cli/src/serviceManager/installLaunchd.ts @@ -211,9 +211,11 @@ function pidAlive(pid: number): boolean { } async function sleepAsync(ms: number): Promise { + // Awaited lifecycle delays must stay referenced: in a standalone CLI the + // handover polling can be the only pending work, and an unref'd timer lets + // the process exit mid-repair (before SIGKILL escalation / launchctl load). await new Promise((resolve) => { - const timer = setTimeout(resolve, ms); - timer.unref?.(); + setTimeout(resolve, ms); }); } diff --git a/apps/ade-cli/src/services/account/accountAuthService.ts b/apps/ade-cli/src/services/account/accountAuthService.ts index f6863a097..23a662c9b 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.ts @@ -1817,21 +1817,22 @@ export function createAccountAuthService(args: { return refreshed.accessToken; })().finally(() => { clearTimeout(envSharedTimer); + // Single-flight must be released by the SHARED exchange settling, + // never by an individual caller aborting out of the join below — + // otherwise a second caller starts a competing OAuth exchange with + // the same rotating refresh token. + if (envRefreshInFlight === refreshPromise) envRefreshInFlight = null; }); envRefreshInFlight = refreshPromise; } const refreshPromise = envRefreshInFlight; - try { - // Race the caller's own signal; the shared exchange keeps running for - // every other caller when this one aborts. - return await runWithAbortSignal( - () => refreshPromise, - signal ?? undefined, - "The account token request was aborted.", - ); - } finally { - if (envRefreshInFlight === refreshPromise) envRefreshInFlight = null; - } + // Race the caller's own signal; the shared exchange keeps running for + // every other caller when this one aborts. + return await runWithAbortSignal( + () => refreshPromise, + signal ?? undefined, + "The account token request was aborted.", + ); } if (!record?.accessToken || !record.userId) { @@ -1879,7 +1880,7 @@ export function createAccountAuthService(args: { token = await postTokenForm({ fetchImpl, tokenUrl: `${config.issuer}/oauth/token`, - signal, + signal: sharedSignal, body: { grant_type: "refresh_token", refresh_token: refreshRecord.refreshToken!,