From 1724d4511eace0a250c9c8d82a766ec715e72d3a Mon Sep 17 00:00:00 2001 From: CuSO41108 <1737268347@qq.com> Date: Sun, 23 Aug 2026 21:11:56 +0800 Subject: [PATCH] fix(runtime-host): reject incomplete successor identity Treat only the prepared PID or a fully stopped service as the same retirement generation. This keeps a PID-less active successor from being stopped during the State Root handoff. Generated-by: Codex --- .../runtime-host-service-manager.test.ts | 25 +++++++++++++++++++ .../cli/src/runtime-host-service-manager.ts | 5 +++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index b7c277b19b..1689d2f169 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -888,6 +888,7 @@ describe('managed Runtime Host service', () => { let startingPid: number | null = null; let stops = 0; let observedStartingFence = false; + let publishPidlessSuccessor = false; const backend: RuntimeHostServiceBackend = { ...createReadyBackend(), status: async () => ({ @@ -930,6 +931,10 @@ describe('managed Runtime Host service', () => { allow: boolean, ) => { assert.equal(expectedPid, 42); + if (publishPidlessSuccessor) { + serviceState = 'starting'; + startingPid = null; + } return allow ? ({ kind: 'prepared', hostEpoch: 'host-1', pid: 42 } as const) : ({ kind: 'active_tasks' } as const); @@ -1018,6 +1023,26 @@ describe('managed Runtime Host service', () => { pid: 42, }); assert.equal(serviceState, 'stopped'); + + serviceState = 'running'; + publishPidlessSuccessor = true; + const stopsBeforeSuccessor = stops; + await assert.rejects( + manageRuntimeHostService( + { + ...common, + action: 'retire', + expectedTarget, + allowInterruptActiveTasks: true, + }, + backend, + deps, + ), + (error: unknown) => + error instanceof RuntimeHostServiceManagerError && error.code === 'retirement_failed', + ); + assert.equal(stops, stopsBeforeSuccessor); + assert.equal(serviceState, 'starting'); }); it('fails closed without stopping a successor that won the State Root', async (t) => { diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 3c1a15634d..c0754a3835 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -958,7 +958,10 @@ async function acquirePreparedRuntimeHostRootRetirementFence( while (Date.now() < deadline) { const owner = await tryAcquireInteractiveRootOwner(root); const status = await backend.status(); - if (status.pid !== null && status.pid !== expectedPid) { + if ( + status.pid !== expectedPid && + !(status.pid === null && !status.active && status.state === 'stopped') + ) { await owner?.close().catch(() => undefined); throw new RuntimeHostServiceManagerError( 'retirement_failed',