From 982394908274cd2d25083ecd907932fd511d47c4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:27:02 -0700 Subject: [PATCH] fix(orcad): replace a stale terminal daemon once it drains on Linux and WSL hosts (#31235) * fix(orcad): replace a stale terminal daemon once it drains on Linux and WSL hosts A daemon forked by an older orcad (before #31135, at umask 077) kept serving new terminals after an upgrade if it was busy at startup, because the runtime stale-bundle recycle was macOS-only. orcad now opts in on every platform; the replacement still waits for exactly zero live sessions and treats an unverifiable count as keep. * test(daemon): pin the restart recycle test to linux; tighten wording --- .../daemon/daemon-init-dependency-mocks.ts | 8 +++++++- src/main/daemon/daemon-init-mock-types.ts | 2 ++ .../daemon-init-provider-installation.test.ts | 18 ++++++++++++++++++ .../daemon-init-restart-sequence.test.ts | 19 +++++++++++++++++++ src/main/daemon/daemon-launch-paths.ts | 13 ++++++++++--- .../daemon/daemon-out-of-process-launcher.ts | 2 ++ src/main/daemon/daemon-provider-init.ts | 9 ++++++--- src/main/daemon/daemon-provider-restart.ts | 5 ++--- src/main/daemon/daemon-spawner.ts | 4 ++++ .../orcad/orcad-daemon-supervision.test.ts | 2 ++ src/main/orcad/orcad-daemon-supervision.ts | 2 ++ 11 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/main/daemon/daemon-init-dependency-mocks.ts b/src/main/daemon/daemon-init-dependency-mocks.ts index c0e3fa67a6b..aa9e2aa164e 100644 --- a/src/main/daemon/daemon-init-dependency-mocks.ts +++ b/src/main/daemon/daemon-init-dependency-mocks.ts @@ -64,10 +64,16 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { readonly resetRespawnWindow: Mock readonly shutdown: Mock readonly getHandle: Mock + readonly staleDaemonRecycleVersion: string | null private socketCounter: number private handle: MockSpawnerHandle | null - constructor(opts: { runtimeDir: string; launcher: unknown }) { + constructor(opts: { + runtimeDir: string + launcher: unknown + staleDaemonRecycleVersion?: string | null + }) { this.launcher = opts.launcher + this.staleDaemonRecycleVersion = opts.staleDaemonRecycleVersion ?? null this.socketCounter = 0 this.handle = null // Why: each ensureRunning bumps a counter into socketPath so tests can tell the replacement adapter used the second call, not the first. diff --git a/src/main/daemon/daemon-init-mock-types.ts b/src/main/daemon/daemon-init-mock-types.ts index c5dddec8e1f..73822a4632d 100644 --- a/src/main/daemon/daemon-init-mock-types.ts +++ b/src/main/daemon/daemon-init-mock-types.ts @@ -8,6 +8,7 @@ export type MockSpawner = { shutdown: Mock getHandle: Mock launcher: unknown + staleDaemonRecycleVersion: string | null } /** Fake DaemonPtyAdapter instance every mocked `new DaemonPtyAdapter()` records. */ @@ -41,6 +42,7 @@ export type MockAdapter = { export type MockSpawnerConstructor = new (opts: { runtimeDir: string launcher: unknown + staleDaemonRecycleVersion?: string | null }) => MockSpawner export type MockAdapterConstructor = new (opts: MockAdapter['options']) => MockAdapter diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index 4ab01e1fb87..ee9a27f91ef 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -90,6 +90,24 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) }) + it('passes the app version on every platform when the launch policy opts in', async () => { + const mod = await importFresh() + isPackagedMock.mockReturnValue(true) + + await mod.initDaemonPtyProvider(undefined, { recycleStaleDaemonOnEveryPlatform: true }) + + expect(spawnerInstances[0].staleDaemonRecycleVersion).toBe('1.2.3') + expect(adapterInstances[0].options.packagedAppVersion).toBe('1.2.3') + }) + + it('never recycles an unpackaged daemon even when the policy opts in', async () => { + const mod = await importFresh() + + await mod.initDaemonPtyProvider(undefined, { recycleStaleDaemonOnEveryPlatform: true }) + + expect(adapterInstances[0].options.packagedAppVersion).toBeNull() + }) + it('uses daemon-owned idle retirement when a fresh launch fails permanent adoption', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ diff --git a/src/main/daemon/daemon-init-restart-sequence.test.ts b/src/main/daemon/daemon-init-restart-sequence.test.ts index 3d761aed59e..28939fa5ab3 100644 --- a/src/main/daemon/daemon-init-restart-sequence.test.ts +++ b/src/main/daemon/daemon-init-restart-sequence.test.ts @@ -24,6 +24,7 @@ const { killStaleDaemonMock, daemonClientMock, spawnerInstances, + isPackagedMock, ensureRunningOverrides, adoptionLeaseReleases, lifecycleLeaseErrors, @@ -250,6 +251,24 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(originalSpawner.ensureRunning).toHaveBeenCalledTimes(2) }) + it('keeps the launch policy stale-daemon recycle across a restart', async () => { + const mod = await importFresh() + isPackagedMock.mockReturnValue(true) + await mod.initDaemonPtyProvider(undefined, { recycleStaleDaemonOnEveryPlatform: true }) + // Why linux: darwin already recycles without the opt-in, which would hide a dropped policy. + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + try { + await mod.restartDaemon() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(adapterInstances[1].options.packagedAppVersion).toBe('1.2.3') + }) + it('builds a fresh adapter whose respawn callback closes over the same spawner', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() diff --git a/src/main/daemon/daemon-launch-paths.ts b/src/main/daemon/daemon-launch-paths.ts index 532bc75c574..8454fe30ecc 100644 --- a/src/main/daemon/daemon-launch-paths.ts +++ b/src/main/daemon/daemon-launch-paths.ts @@ -40,10 +40,17 @@ export function getDaemonEntryPath(): string { : join(basePath, 'out', 'main', 'daemon-entry.js') } -// macOS TCC attribution pins the daemon to a packaged app bundle; there is none on a Node host. -export function resolvePackagedDarwinAppVersion(): string | null { +/** + * The version a stale daemon is replaced against once its sessions drain, or null to keep it. + * Desktop: macOS only, where TCC attribution pins the daemon to the bundle that launched it. + * orcad opts in everywhere: another version's daemon keeps that version's code and umask (#31135). + */ +export function resolveStaleDaemonRecycleVersion(onEveryPlatform = false): string | null { const environment = getAppEnvironment() - return process.platform === 'darwin' && environment.isPackaged() ? environment.getVersion() : null + if (!environment.isPackaged()) { + return null + } + return onEveryPlatform || process.platform === 'darwin' ? environment.getVersion() : null } // Why: pass a log-file arg so field failures are diagnosable, but honor the ORCA_DIAGNOSTICS_DISABLED privacy switch. diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index d528ac32b56..bc8309c3b54 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -83,6 +83,8 @@ export type DaemonLaunchPolicy = { macosLoginSessionWatch?: boolean startupTimeoutMs?: number spawnAdmission?: 'observe' | 'enforce' + /** Replace a stale-version daemon once it drains on every platform, not only macOS. */ + recycleStaleDaemonOnEveryPlatform?: boolean } export function createOutOfProcessLauncher( diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index 40645f40040..90da101f122 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -16,7 +16,7 @@ import { import { getDaemonHistoryDir as getHistoryDir, getDaemonRuntimeDir as getRuntimeDir, - resolvePackagedDarwinAppVersion + resolveStaleDaemonRecycleVersion } from './daemon-launch-paths' import { attributeNextDaemonReplacement, @@ -65,7 +65,10 @@ export async function initDaemonPtyProvider( const newSpawner = new DaemonSpawner({ runtimeDir, - launcher: createOutOfProcessLauncher(runtimeDir, options) + launcher: createOutOfProcessLauncher(runtimeDir, options), + staleDaemonRecycleVersion: resolveStaleDaemonRecycleVersion( + options.recycleStaleDaemonOnEveryPlatform === true + ) }) // Why: assign the module-level spawner/adapter only after both succeed, so a failed ensureRunning() leaves no stale spawner. @@ -95,7 +98,7 @@ export async function initDaemonPtyProvider( pidPath: getDaemonPidPath(runtimeDir), profileScope: runtimeDir, runtimeDir, - packagedAppVersion: resolvePackagedDarwinAppVersion(), + packagedAppVersion: newSpawner.staleDaemonRecycleVersion, historyPath: getHistoryDir(), // Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying. respawn: async (reason: DaemonRespawnReason) => { diff --git a/src/main/daemon/daemon-provider-restart.ts b/src/main/daemon/daemon-provider-restart.ts index 06cbfd3a018..471a6bfb446 100644 --- a/src/main/daemon/daemon-provider-restart.ts +++ b/src/main/daemon/daemon-provider-restart.ts @@ -11,8 +11,7 @@ import { } from './daemon-endpoint-adoption' import { getDaemonHistoryDir as getHistoryDir, - getDaemonRuntimeDir as getRuntimeDir, - resolvePackagedDarwinAppVersion + getDaemonRuntimeDir as getRuntimeDir } from './daemon-launch-paths' import { trackDaemonRetired } from './daemon-lifecycle-event' import { attributeNextDaemonReplacement } from './daemon-out-of-process-launcher' @@ -134,7 +133,7 @@ async function runRestartDaemon( pidPath: getDaemonPidPath(runtimeDir), profileScope: runtimeDir, runtimeDir, - packagedAppVersion: resolvePackagedDarwinAppVersion(), + packagedAppVersion: currentSpawner.staleDaemonRecycleVersion, historyPath: getHistoryDir(), respawn: async (reason: DaemonRespawnReason) => { // Why: attribute rather than emit — the launcher below is the one that completes the diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index a17aeae0496..a9b6d41d7f0 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -59,6 +59,8 @@ export type DaemonSpawnerOptions = { launcher: DaemonLauncher /** Test seam; production uses the default window. */ respawnThrottle?: DaemonRespawnThrottle + /** Why here: restart reuses this spawner, so the launch policy's recycle choice survives it. */ + staleDaemonRecycleVersion?: string | null } export class DaemonSpawner { @@ -69,9 +71,11 @@ export class DaemonSpawner { private tokenPath: string private pidPath: string private respawnThrottle: DaemonRespawnThrottle + readonly staleDaemonRecycleVersion: string | null constructor(opts: DaemonSpawnerOptions) { this.runtimeDir = opts.runtimeDir + this.staleDaemonRecycleVersion = opts.staleDaemonRecycleVersion ?? null this.launcher = opts.launcher this.respawnThrottle = opts.respawnThrottle ?? new DaemonRespawnThrottle() this.socketPath = getDaemonSocketPath(this.runtimeDir) diff --git a/src/main/orcad/orcad-daemon-supervision.test.ts b/src/main/orcad/orcad-daemon-supervision.test.ts index 562717c0783..dee4f2915a1 100644 --- a/src/main/orcad/orcad-daemon-supervision.test.ts +++ b/src/main/orcad/orcad-daemon-supervision.test.ts @@ -55,6 +55,7 @@ describe('startOrcadDaemon', () => { // moment the operator logged out, which is the opposite of the property being bought. expect(initDaemonPtyProviderMock).toHaveBeenCalledWith(undefined, { macosLoginSessionWatch: false, + recycleStaleDaemonOnEveryPlatform: true, spawnAdmission: 'enforce' }) }) @@ -63,6 +64,7 @@ describe('startOrcadDaemon', () => { await startOrcadDaemon('win32') expect(initDaemonPtyProviderMock).toHaveBeenCalledWith(undefined, { macosLoginSessionWatch: false, + recycleStaleDaemonOnEveryPlatform: true, spawnAdmission: 'enforce', startupTimeoutMs: 30_000 }) diff --git a/src/main/orcad/orcad-daemon-supervision.ts b/src/main/orcad/orcad-daemon-supervision.ts index aebbf6a0f38..501ad473c5c 100644 --- a/src/main/orcad/orcad-daemon-supervision.ts +++ b/src/main/orcad/orcad-daemon-supervision.ts @@ -42,6 +42,8 @@ export async function startOrcadDaemon( // session dies. An orcad daemon must survive its SSH session ending — that is the point. const policy = { macosLoginSessionWatch: false, + // Why: a daemon another orcad version forked keeps its code and umask; swap it once no terminal would die. + recycleStaleDaemonOnEveryPlatform: true, spawnAdmission: resolveTerminalSpawnAdmissionMode(), // A freshly uploaded node.exe plus conpty can take well over 10 s on its first, AV-scanned exec. ...(platform === 'win32' ? { startupTimeoutMs: WINDOWS_DAEMON_STARTUP_TIMEOUT_MS } : {})