diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 6e82271916d..e0d68ec9057 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -315,13 +315,15 @@ vi.mock('./daemon-health', () => ({ vi.mock('./client', () => ({ // Mirror ensureConnected onto the bounded variant so every existing double keeps its - // behaviour — including the ones whose whole point is that connecting throws. + // behaviour — including the ones whose whole point is that connecting throws. The budget is + // forwarded rather than dropped so a test can see what the launcher was willing to wait. DaemonClient: function DaemonClientDouble(...args: unknown[]) { const instance = (daemonClientMock as unknown as (...a: unknown[]) => Record)( ...args ) if (instance && typeof instance === 'object' && !('ensureConnectedWithin' in instance)) { - instance.ensureConnectedWithin = instance.ensureConnected + instance.ensureConnectedWithin = (budgetMs?: number) => + (instance.ensureConnected as (ms?: number) => unknown)(budgetMs) } return instance } @@ -3195,11 +3197,11 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { '/fake/token' ) expect(forkMock).toHaveBeenCalled() - // 1 adoption + 1 initial probe + the retries + 1 patient probe + 1 post-fork adoption. - // The patient probe is the point: retrying a four-second handshake never reaches a - // daemon that consistently needs longer, so the launcher spends what is left of the - // clock on a single tolerant ask before it is willing to replace anything. - expect(daemonClientMock).toHaveBeenCalledTimes(4 + WEDGED_DAEMON_GRACE_RETRIES) + // 1 adoption + 1 patient probe + the cheap retries + 1 post-fork adoption. The patient + // probe comes first and is the point: retrying a four-second handshake never reaches a + // daemon that consistently needs longer, and spending the clock on the cheap asks first + // left the tolerant one unable to fund an answer. + expect(daemonClientMock).toHaveBeenCalledTimes(3 + WEDGED_DAEMON_GRACE_RETRIES) // Why: this replace path used to kill the daemon with no log, so a post-hoc // reader could not tell it apart from an adoption; the verdict must be recorded. expect(warnSpy).toHaveBeenCalledWith( diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 2a496b8effc..42963c84a8f 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -510,7 +510,12 @@ function createOutOfProcessLauncher( const classificationRemainingMs = (): number => Math.max(0, classificationDeadline - Date.now()) try { // Why: acquire the full pair before control-only probes so an expired inherited deadline can't fire in the probe-to-adoption gap. - await adoptionClient.ensureConnectedWithin(classificationRemainingMs()) + // Capped: this only acquires a lease that preserveDaemon() re-establishes anyway, and a + // daemon that accepts the socket but never answers hello would otherwise spend the whole + // classification clock here, leaving nothing for the probes that protect its sessions. + await adoptionClient.ensureConnectedWithin( + Math.min(OCCUPANCY_CONNECT_BUDGET_MS, classificationRemainingMs()) + ) await reconcileDaemonPidOwnership(adoptionClient, pidPath) } catch { adoptionClient.disconnect() @@ -641,12 +646,19 @@ function createOutOfProcessLauncher( budgetMs: probeBudgetMs(), connectBudgetMs }) - // Why grace at all: a wedged-but-connectable daemon (a Windows update relaunch) may - // still own live sessions and simply need a moment. A permanent wedge (#8689) spends - // the window, and 'rejected' skips it — a refused handshake is never going to be - // adopted. Retries stop when the clock can no longer fund a handshake and an answer, - // so the count is a ceiling rather than the budget. - let occupancy = await askDaemonWhatItHosts(OCCUPANCY_CONNECT_BUDGET_MS) + // Why the tolerant question goes first: this path is only reached because a 3s health + // check timed out, so a cheap probe re-asks on a stricter budget than the one that + // triaged the daemon here and can only ever agree with it. The patient ask is the only + // one that can disagree, and asking it last meant asking it with the clock already + // spent. It gets every millisecond the answer itself does not still need, and never + // less than the cheap ask would have had. + const patientConnectBudgetMs = (): number => + Math.max(OCCUPANCY_CONNECT_BUDGET_MS, probeBudgetMs() - OCCUPANCY_REQUEST_BUDGET_MS) + let occupancy = await askDaemonWhatItHosts(patientConnectBudgetMs()) + // Why retry after it: a daemon that recovers on its own answers a cheap question, and + // recovering is a different failure from being slow. The patient ask cannot see it, + // because it asked before the recovery. Retries stop once the clock can no longer fund + // both halves of an ask — funded to knock but not to listen is no ask at all. let graceRetry = 0 while ( occupancy.state === 'unknown' && @@ -658,21 +670,6 @@ function createOutOfProcessLauncher( occupancy = await askDaemonWhatItHosts(OCCUPANCY_CONNECT_BUDGET_MS) graceRetry++ } - // Why one patient ask after the impatient ones: retrying a four-second handshake never - // reaches a daemon that consistently needs longer, and this path is only reached - // because a three-second health check already timed out — so every probe so far has - // judged it on a stricter budget than the one that triaged it here. Spending what is - // left on a single tolerant attempt is the only question that can disagree. - if ( - occupancy.state === 'unknown' && - health !== 'rejected' && - probeBudgetMs() > 0 && - // Nothing to be patient with when the endpoint is provably gone — a cold start - // reaches here too, and has no daemon to wait for. - !endpointIsProvenDead(await probeSocketConnect(socketPath)) - ) { - occupancy = await askDaemonWhatItHosts(probeBudgetMs()) - } // The evidence that separates a wedged daemon still hosting terminals from one with // nothing left to lose (#8689). Read once IPC has had its full chance, and only when // it never answered — both because it costs a process scan, and because identity has