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
This commit is contained in:
Neil
2026-10-20 15:27:02 -07:00
committed by GitHub
parent 8f56a599e2
commit 9823949082
11 changed files with 74 additions and 10 deletions
@@ -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.
@@ -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
@@ -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 () => ({
@@ -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()
+10 -3
View File
@@ -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.
@@ -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(
+6 -3
View File
@@ -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) => {
+2 -3
View File
@@ -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
+4
View File
@@ -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)
@@ -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
})
@@ -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 } : {})