From 06a922490418f6dea751328e8ee0970762036d6e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:00:21 +0800 Subject: [PATCH] fix(subprocess): retain failed terminal cleanup --- .../subprocess/subprocess-local/src/index.ts | 3 +- .../subprocess-local/tests/local.spec.ts | 30 +++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 4c5cad8018..6d7684eeeb 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -64,10 +64,9 @@ export class LocalSubprocessService extends SubprocessService { // an identity-fenced descendant survives escalation). Await the cleanup // transaction directly so disposal reports that failure rather than // waiting forever on `done`. - pending.push(terminal.waitForExit()) + pending.push(terminal.waitForExit().then(() => { this.terminals.delete(terminal) })) } this.live.clear() - this.terminals.clear() await Promise.all(pending) await rm(this.runtimeRoot, { recursive: true, force: true }) }, 'local subprocess teardown') diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index b2ebfcfed0..b772545f7f 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -1,6 +1,6 @@ import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' -import { stat } from 'node:fs/promises' +import { rm, stat } from 'node:fs/promises' import { basename, delimiter, dirname, relative } from 'node:path' import { Context } from 'cordis' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' @@ -108,10 +108,36 @@ describe('LocalSubprocessService', () => { terminate, waitForExit, } - ;(ctx.subprocess as unknown as { terminals: Set }).terminals.add(terminal) + const terminals = (ctx.subprocess as unknown as { terminals: Set }).terminals + terminals.add(terminal) await fiber.dispose() expect(terminate).toHaveBeenCalledOnce() expect(waitForExit).toHaveBeenCalledOnce() + expect(terminals.size).toBe(0) + }) + + it('retains an owned terminal when disposal cleanup rejects', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const service = ctx.subprocess + const runtimeRoot = service.runtimeRoot + const terminal: SubprocessTerminalHandle = { + pid: 1, + output: new PassThrough(), + done: Promise.resolve({ exitCode: 0, signal: null }), + write: async () => {}, + inspectForeground: async () => undefined, + signalForeground: async () => 1, + terminate: vi.fn(), + waitForExit: vi.fn(async () => { throw new Error('retryable cleanup failure') }), + } + const terminals = (service as unknown as { terminals: Set }).terminals + terminals.add(terminal) + + await fiber.dispose() + expect(terminals).toEqual(new Set([terminal])) + expect((await stat(runtimeRoot)).isDirectory()).toBe(true) + await rm(runtimeRoot, { recursive: true, force: true }) }) it('releases a terminal after top-level exit reaches quiescence', async () => {