diff --git a/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeBroker.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeBroker.test.ts index a92ae310e4..f4fb30297f 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeBroker.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeBroker.test.ts @@ -430,6 +430,132 @@ describe('nodeRuntimeBroker · 进程生命周期', () => { expect(broker.stateOf('node-ghost')).toBe('off'); }); + it('空闲回收等待旧进程真实退出后才允许同 key 重启', async () => { + vi.useFakeTimers(); + const ghost = fakeGhost(); + ghost.manifest.node!.idleTimeoutSeconds = 1; + const first = makeAutoReplyProcess(); + const second = makeAutoReplyProcess(); + const kill = vi.spyOn(first, 'kill').mockImplementation(() => { + first.killed = true; + return true; + }); + const children = [first, second]; + const spawnProcess = vi.fn(() => children.shift() as FakeNodeProcess); + const broker = new GhostNodeRuntimeBroker({ + getGhost: () => ghost, + spawnProcess: spawnProcess as never, + }); + + await expect(broker.handleRequest('node-ghost', rpcRequest('first'))).resolves.toMatchObject({ + ok: true, + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(kill).toHaveBeenCalledWith('SIGTERM'); + + const restart = broker.handleRequest('node-ghost', rpcRequest('second')); + await vi.runAllTicks(); + expect(spawnProcess).toHaveBeenCalledTimes(1); + + first.emit('exit', null, 'SIGTERM'); + await Promise.resolve(); + await Promise.resolve(); + expect(spawnProcess).toHaveBeenCalledTimes(2); + await Promise.resolve(); + await Promise.resolve(); + await vi.runAllTicks(); + second.emit('spawn'); + await Promise.resolve(); + await Promise.resolve(); + await expect(restart).resolves.toMatchObject({ + ok: true, + result: { method: 'second' }, + }); + expect(spawnProcess).toHaveBeenCalledTimes(2); + }); + + it('同 key 的并发请求共享退出屏障且只启动一个替代进程', async () => { + vi.useFakeTimers(); + const ghost = fakeGhost(); + ghost.manifest.node!.idleTimeoutSeconds = 1; + const first = makeAutoReplyProcess(); + const second = makeAutoReplyProcess(); + vi.spyOn(first, 'kill').mockImplementation(() => { + first.killed = true; + return true; + }); + const children = [first, second]; + const spawnProcess = vi.fn(() => children.shift() as FakeNodeProcess); + const broker = new GhostNodeRuntimeBroker({ + getGhost: () => ghost, + spawnProcess: spawnProcess as never, + }); + + await expect(broker.handleRequest('node-ghost', rpcRequest('first'))).resolves.toMatchObject({ + ok: true, + }); + await vi.advanceTimersByTimeAsync(1_000); + + const requestA = broker.handleRequest('node-ghost', rpcRequest('a')); + const requestB = broker.handleRequest('node-ghost', rpcRequest('b')); + await vi.runAllTicks(); + expect(spawnProcess).toHaveBeenCalledTimes(1); + + first.emit('exit', null, 'SIGTERM'); + await vi.runAllTicks(); + expect(spawnProcess).toHaveBeenCalledTimes(2); + second.emit('spawn'); + const results = await Promise.all([requestA, requestB]); + expect(results[0]).toMatchObject({ ok: true, result: { method: 'a' } }); + expect(results[1]).toMatchObject({ ok: true, result: { method: 'b' } }); + expect(spawnProcess).toHaveBeenCalledTimes(2); + }); + + it('退出屏障超时后旧进程最终退出可恢复后续启动', async () => { + vi.useFakeTimers(); + const ghost = fakeGhost(); + ghost.manifest.node!.idleTimeoutSeconds = 1; + const first = makeAutoReplyProcess(); + const second = makeAutoReplyProcess(); + vi.spyOn(first, 'kill').mockImplementation(() => { + first.killed = true; + return true; + }); + const children = [first, second]; + const spawnProcess = vi.fn(() => children.shift() as FakeNodeProcess); + const warn = vi.fn(); + const broker = new GhostNodeRuntimeBroker({ + getGhost: () => ghost, + spawnProcess: spawnProcess as never, + log: { info: vi.fn(), warn }, + }); + + await expect(broker.handleRequest('node-ghost', rpcRequest('first'))).resolves.toMatchObject({ + ok: true, + }); + await vi.advanceTimersByTimeAsync(1_000); + const blocked = broker.handleRequest('node-ghost', rpcRequest('blocked')); + await vi.advanceTimersByTimeAsync(2_500); + await expect(blocked).resolves.toMatchObject({ + ok: false, + errorCode: 'PROCESS_START_FAILED', + }); + expect(warn).toHaveBeenCalledWith( + 'Node 工作进程退出屏障超时', + expect.objectContaining({ ghostId: 'node-ghost' }), + ); + + first.emit('exit', null, 'SIGTERM'); + const recovered = broker.handleRequest('node-ghost', rpcRequest('recovered')); + await vi.runAllTicks(); + expect(spawnProcess).toHaveBeenCalledTimes(2); + second.emit('spawn'); + await expect(recovered).resolves.toMatchObject({ + ok: true, + result: { method: 'recovered' }, + }); + }); + it('resident 档可提前启动且不会设置空闲关闭', async () => { vi.useFakeTimers(); const ghost = fakeGhost({ lifecycle: 'resident' }); diff --git a/apps/desktop/src/main/cindy-brain/nodeRuntimeBroker.ts b/apps/desktop/src/main/cindy-brain/nodeRuntimeBroker.ts index ee734ad7ff..bd420d6942 100644 --- a/apps/desktop/src/main/cindy-brain/nodeRuntimeBroker.ts +++ b/apps/desktop/src/main/cindy-brain/nodeRuntimeBroker.ts @@ -1052,6 +1052,7 @@ export class GhostNodeRuntimeBroker { private stopWorker(key: string, entry: WorkerEntry): void { entry.stopping = true; this.workers.delete(key); + this.registerStoppingWorker(key, entry); this.exitGen.set(key, (this.exitGen.get(key) ?? 0) + 1); this.clearIdleTimer(entry); // 级联:先收孩子再收本体,不留孤儿进程。 @@ -1089,6 +1090,32 @@ export class GhostNodeRuntimeBroker { private readonly startingWorkers = new Map>(); private readonly startingWorkerScopes = new Map(); + /** + * Idle stop removes the worker from `workers` before the OS process has + * emitted `exit`. Keep a per-key barrier so the next request cannot fork a + * replacement while the old utility process still owns resources. + */ + private readonly stoppingWorkers = new Map>(); + + private registerStoppingWorker(key: string, entry: WorkerEntry): void { + if (this.stoppingWorkers.has(key)) return; + const barrier = this.waitForProcessExit(entry.child, entry.ghost.manifest.id); + // A worker may be stopped without an immediate replacement request. Keep + // a rejection from becoming unhandled while preserving its diagnostic for + // the next request to surface as a bounded start failure. + void barrier.catch((error: unknown) => { + this.deps.log?.warn('Node 工作进程退出屏障超时', { + ghostId: entry.ghost.manifest.id, + entry: entry.entryRel, + error: error instanceof Error ? error.message : String(error), + }); + }); + this.stoppingWorkers.set(key, barrier); + entry.child.once('exit', () => { + if (this.stoppingWorkers.get(key) === barrier) this.stoppingWorkers.delete(key); + }); + } + /** stop(ghostId) 置入:在途重试检测到后立即中止,不继续拉新进程。 */ private readonly stoppedGhosts = new Set(); @@ -1104,6 +1131,17 @@ export class GhostNodeRuntimeBroker { ownerScopeSnapshot: unknown, ): Promise { const key = GhostNodeRuntimeBroker.keyOf(ghost.manifest.id, entryRel); + const stopping = this.stoppingWorkers.get(key); + if (stopping) { + try { + await stopping; + } catch (error) { + throw new WorkerStartError( + error instanceof Error ? error.message : `Node 工作进程停止失败(${ghost.manifest.id})`, + false, + ); + } + } const inflight = this.startingWorkers.get(key); if (inflight) { const entry = await inflight;