Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
38 changes: 38 additions & 0 deletions apps/desktop/src/main/cindy-brain/nodeRuntimeBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
// 级联:先收孩子再收本体,不留孤儿进程。
Expand Down Expand Up @@ -1089,6 +1090,32 @@ export class GhostNodeRuntimeBroker {
private readonly startingWorkers = new Map<string, Promise<WorkerEntry>>();
private readonly startingWorkerScopes = new Map<string, unknown>();

/**
* 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<string, Promise<void>>();

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<string>();

Expand All @@ -1104,6 +1131,17 @@ export class GhostNodeRuntimeBroker {
ownerScopeSnapshot: unknown,
): Promise<WorkerEntry> {
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;
Expand Down