From cee0f1e86275f0b93a2b23249babbc6c7e616bb6 Mon Sep 17 00:00:00 2001 From: nothingyuancando <1579035512@qq.com> Date: Mon, 24 Aug 2026 19:44:42 +0800 Subject: [PATCH 1/2] fix(desktop): wait for idle worker exit before restart Signed-off-by: nothingyuancando <1579035512@qq.com> --- .../__tests__/nodeRuntimeBroker.test.ts | 44 +++++++++++++++++++ .../src/main/cindy-brain/nodeRuntimeBroker.ts | 32 ++++++++++++++ 2 files changed, 76 insertions(+) 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 5ae07915be..73a9e5412c 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeBroker.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeBroker.test.ts @@ -431,6 +431,50 @@ 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('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 cddfb2e5d1..1ddcd767f0 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,26 @@ 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 an unhandled promise while retaining it for + // the next request to surface as a bounded start failure. + void barrier.catch(() => undefined); + 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 +1125,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; From a308e16f14452b109352f1d7118f6f3db45b598e Mon Sep 17 00:00:00 2001 From: nothingyuancando <1579035512@qq.com> Date: Tue, 25 Aug 2026 14:59:55 +0800 Subject: [PATCH 2/2] test(desktop): harden node worker exit barrier Signed-off-by: nothingyuancando <1579035512@qq.com> --- .../__tests__/nodeRuntimeBroker.test.ts | 82 +++++++++++++++++++ .../src/main/cindy-brain/nodeRuntimeBroker.ts | 10 ++- 2 files changed, 90 insertions(+), 2 deletions(-) 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 295f67f4e5..f4fb30297f 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeBroker.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/nodeRuntimeBroker.test.ts @@ -474,6 +474,88 @@ describe('nodeRuntimeBroker · 进程生命周期', () => { 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 1f6ebbe8f3..bd420d6942 100644 --- a/apps/desktop/src/main/cindy-brain/nodeRuntimeBroker.ts +++ b/apps/desktop/src/main/cindy-brain/nodeRuntimeBroker.ts @@ -1101,9 +1101,15 @@ export class GhostNodeRuntimeBroker { 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 an unhandled promise while retaining it for + // a rejection from becoming unhandled while preserving its diagnostic for // the next request to surface as a bounded start failure. - void barrier.catch(() => undefined); + 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);