mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {})
|
||||
|
||||
Reference in New Issue
Block a user