fix(runtime): close teardown gaps and simplify framing

This commit is contained in:
Tianyi Cui
2026-07-29 13:51:55 +08:00
parent 045e8462ee
commit 4fecc54998
20 changed files with 164 additions and 36 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/lsp/lsp-local/README.md
README.md: 299a5282bbca0a6d517af9342c4101cbdae33d4c
README.zh.md: edf706fdb424756aa43b303b018b11e3764c2559
README.md: c96c4febc9d047b41789f2b7a73e3eaf4d35012b
README.zh.md: 5bc2c3c8bb7a89afb673797f5a3b25bb9fc06748

View File

@@ -11,7 +11,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes.
- Lazily single-flights one server process per `(server id, canonical workspace target)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process.
- Uses a compatibility-first **transient-open** sequence per query: resolve and boundedly read the source through `ctx.fs`, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel.
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. Provider disposal aborts filesystem and protocol work, awaits workspace lookups that have not entered a queue, then drains every queue and server.
- After protocol shutdown fails, terminates the server's descendant tree through the subprocess seam (POSIX process-group signaling; Windows `taskkill /T /F`). Tree-kill delivery is contained like every group signal — it races server exit — and quiescence is confirmed by the handle's tree-liveness wait rather than by the kill's own outcome.
- Resolves the server executable, cwd, process, and protocol streams through `ctx.subprocess`; `initialize.processId` is `null` because another machine or PID namespace must not monitor the harness process.
- Uses `ctx.fs` canonical containment, file URIs, and stable bounded reads, but emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.

View File

@@ -11,7 +11,7 @@ Namespace 插件(`name``inject``Config``apply`,无默认导出)
- 在注册前解析每项服务器局部设置;无效映射或注册冲突会回滚较早配置项,因此加载失败不会留下提供方路由。
- 每个 `(server id, canonical workspace target)` 惰性 single-flight 一个服务器进程。存活服务器错误不会回放;如果选中的池化传输在只读查询之前或期间失败,提供方会等待其释放,并在新进程上重试该查询一次。
- 每次查询都使用兼容性优先的**临时打开** 序列:通过 `ctx.fs` 解析源文件并进行有界读取、`textDocument/didOpen`(版本 1、完整文本、所请求操作然后执行 `textDocument/didClose`,该操作位于 `finally` 中。写入 `didOpen` 失败或取消时,会先终止实例再允许池复用。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。
- 通过一条逐 Workspace、可中止的队列串行执行每个源读取打开查询关闭生命周期因此排队调用只会在轮到自身时读取当前源不同 Workspace 并行运行。
- 通过一条逐 Workspace、可中止的队列串行执行每个源读取打开查询关闭生命周期因此排队调用只会在轮到自身时读取当前源不同 Workspace 并行运行。提供方资源释放会中止文件系统与协议工作,等待尚未进入队列的 Workspace 查找结算,再排空所有队列并等待所有服务器结算。
- 协议 shutdown 失败后,经由进程管理器 seam 终止服务器后代树POSIX 进程组信号Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认。
- 通过 `ctx.subprocess` 解析服务器可执行文件、cwd、进程与协议流`initialize.processId``null`,因为另一台机器或 PID 命名空间不得监控 harness 进程。
- 使用 `ctx.fs` 提供的规范 containment、文件 URI 与稳定有界读取,但不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。

View File

@@ -208,6 +208,9 @@ class LocalLspProvider implements LspProvider {
private readonly instances = new Map<WorkspaceKey, LspInstance>()
/** One complete source-read→open→query→close serialization tail per canonical workspace. */
private readonly queues = new Map<WorkspaceKey, Promise<void>>()
/** Workspace canonicalizations that have not entered a provider-owned queue yet. */
private readonly workspaceLookups = new Set<Promise<void>>()
private readonly lifetime = new AbortController()
private disposed = false
constructor(
@@ -234,32 +237,48 @@ class LocalLspProvider implements LspProvider {
if (signal?.aborted) throw abortError(signal)
}
/** Fuse caller cancellation with provider disposal for every filesystem and protocol await. */
private querySignal(signal?: AbortSignal): AbortSignal {
return signal === undefined
? this.lifetime.signal
: AbortSignal.any([signal, this.lifetime.signal])
}
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
// Honor an already-aborted signal before provider I/O so a canceled request never starts a server.
this.assertActive(signal)
const workspace = await canonicalizeWorkspace(this.fs, request.workspaceRoot, signal)
this.assertActive(signal)
const querySignal = this.querySignal(signal)
const workspaceResult = canonicalizeWorkspace(this.fs, request.workspaceRoot, querySignal)
const workspaceLookup = workspaceResult.then(() => undefined, () => undefined)
this.workspaceLookups.add(workspaceLookup)
let workspace: HostWorkspace
try {
workspace = await workspaceResult
} finally {
this.workspaceLookups.delete(workspaceLookup)
}
this.assertActive(querySignal)
const workspaceKey = workspace.target.targetKey
return this.enqueue(workspaceKey, signal, async () => {
this.assertActive(signal)
return this.enqueue(workspaceKey, querySignal, async () => {
this.assertActive(querySignal)
// Read inside the workspace queue but before spawning: a queued query sees current bytes when
// its turn starts, while an invalid source still cannot leave an idle process pooled.
const source = await readHostSource(this.fs, request.filePath, workspace, this.config.maxDocumentBytes, signal)
const source = await readHostSource(this.fs, request.filePath, workspace, this.config.maxDocumentBytes, querySignal)
// Disposal may have snapshotted the instance map while host I/O was pending. Re-check before a
// synchronous get-or-create so every spawned process remains owned by teardown.
this.assertActive(signal)
this.assertActive(querySignal)
let instance = this.instanceFor(workspaceKey, workspace)
try {
return await instance.query(request, source, signal)
return await instance.query(request, source, querySignal)
} catch (error) {
// A selected child can have died while idle or fail during the next write. Queries are
// read-only, so replace that transport once and retry transparently.
if (!instance.isTransportFailure(error)) throw error
await instance.dispose()
this.evictIfCurrent(workspaceKey, instance)
this.assertActive(signal)
this.assertActive(querySignal)
instance = this.instanceFor(workspaceKey, workspace)
return await instance.query(request, source, signal)
return await instance.query(request, source, querySignal)
} finally {
// Reach quiescence before dropping a dead slot; a replacement must survive this ownership check.
if (instance.dead) {
@@ -320,13 +339,17 @@ class LocalLspProvider implements LspProvider {
/** Dispose every live instance and block further queries. */
async disposeAll(): Promise<void> {
this.disposed = true
this.lifetime.abort(new LspError('lsp-local provider is disposed', 'LSP_DISPOSED'))
const live = [...this.instances.values()]
const draining = [...this.queues.values()]
const resolving = [...this.workspaceLookups]
this.instances.clear()
await Promise.all([
...live.map(instance => instance.dispose()),
...draining,
...resolving,
])
this.queues.clear()
this.workspaceLookups.clear()
}
}

View File

@@ -301,6 +301,52 @@ describe('lsp-local end to end over a fake server', () => {
await ctx.fiber.dispose()
})
it('aborts and awaits a workspace lookup when the provider is disposed', async () => {
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
const fs = ctx.fs
const resolve = fs.resolve.bind(fs)
const started = Promise.withResolvers<AbortSignal>()
const release = Promise.withResolvers<undefined>()
vi.spyOn(fs, 'resolve').mockImplementation(async (path, options) => {
if (path !== ws) return await resolve(path, options)
const signal = options?.signal
if (signal === undefined) throw new Error('workspace lookup missing provider lifetime signal')
started.resolve(signal)
return await rejectWhenAborted(signal, release.promise)
})
const pending = ctx.lsp.query(query('goToDefinition'))
const signal = await started.promise
let disposed = false
const disposing = ctx.fiber.dispose().then(() => { disposed = true })
await new Promise<void>(resolve => setImmediate(resolve))
expect(signal.aborted).toBe(true)
expect(disposed).toBe(false)
release.resolve(undefined)
await expect(pending).rejects.toThrow('provider is disposed')
await expect(disposing).resolves.toBeUndefined()
})
it('aborts a queued source read when the provider is disposed', async () => {
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
const fs = ctx.fs
const started = Promise.withResolvers<AbortSignal>()
vi.spyOn(fs, 'readTextBounded').mockImplementation(async (_target, _maxBytes, signal) => {
if (signal === undefined) throw new Error('source read missing provider lifetime signal')
started.resolve(signal)
return await rejectWhenAborted(signal)
})
const pending = ctx.lsp.query(query('goToDefinition'))
const signal = await started.promise
const disposing = ctx.fiber.dispose()
await expect(pending).rejects.toThrow('provider is disposed')
await expect(disposing).resolves.toBeUndefined()
expect(signal.aborted).toBe(true)
})
it('runs distinct workspaces in parallel instances', async () => {
const ws2 = join(root, 'ws2')
await mkdir(ws2)
@@ -358,3 +404,16 @@ async function waitFor(condition: () => Promise<boolean>, timeoutMs = 3000): Pro
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}
/** Hold one fake provider operation until cancellation, optionally behind a cleanup gate. */
function rejectWhenAborted<T>(signal: AbortSignal, release: Promise<unknown> = Promise.resolve()): Promise<T> {
return new Promise((_resolve, reject) => {
const onAbort = (): void => {
void release.then(() => {
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
})
}
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) onAbort()
})
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
README.md: 61ee8ff9e09d5177ff6d5b3805dd84fb74c59961
README.zh.md: 06a8e4bdd225e212aa67817ebbf66581314ab2bf
README.md: 9f30fa6dc676e7b82f87b75b78b7d3143f204c94
README.zh.md: 0b6813ec7d754f42e8bf4c65a1dee33d77bf3787

View File

@@ -11,7 +11,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README
- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
- **Execution-world coordinates** — `cwd` is the host process cwd, `runtimeRoot` is an owner-private temporary directory removed on disposal before any process-cleanup failure is reported, and `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions.
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal bytes, inspects and signals the current foreground process group, and cleans descendants before the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal bytes, inspects and signals the current foreground process group, and sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement.
## Model Experience

View File

@@ -11,7 +11,7 @@
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*``*SECRET*``*TOKEN*`)和所有环境中已有的 `DSH_*` 名称spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **基于偏移量的读取**收集模式的读取器以全流字节坐标返回增量服务自身从不持有游标因此消费方自有的游标bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
- **执行世界坐标**`cwd` 是宿主进程 cwd`runtimeRoot` 是所有者私有的临时目录,会在资源释放时删除,并且删除发生在报告任何进程清理失败之前;`resolveExecutable` 检查绝对文件,或使用平台感知的可执行扩展名在清理后的有效 PATH 中查找。
- **终端进程所有权**`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端字节,检查当前前台进程组并向其发送信号,并先于顶层 shell 清理后代。每次前台检查都会保留有根进程树中的精确身份Linux 还会在会话 leader 退出后枚举该 POSIX 会话。因此,先前观察到的 macOS 后代以及任何同会话 Linux 成员在重新设定父进程后仍受身份围栏保护,而 pid启动身份可防止清理因 PID 复用而跟随到其他进程。上层 PTY 后端负责提示符就绪检测、缓冲和面向模型的操作。
- **终端进程所有权**`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端字节,检查当前前台进程组并向其发送信号,并在终止顶层 shell 前后清理后代。每次前台检查都会保留有根进程树中的精确身份Linux 还会在会话 leader 退出后枚举该 POSIX 会话。因此,先前观察到的 macOS 后代以及任何同会话 Linux 成员在重新设定父进程后仍受身份围栏保护,而 pid启动身份可防止清理因 PID 复用而跟随到其他进程。上层 PTY 后端负责提示符就绪检测、缓冲和面向模型的操作。
- **先终止再等待退出的 dispose**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。
## 模型体验

View File

@@ -193,11 +193,15 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
}
private async closeOnce(): Promise<void> {
const survivors = await this.stopDescendants()
let survivors = await this.stopDescendants()
if (survivors.length > 0) {
throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
}
await this.stopShell()
survivors = await this.stopDescendants()
if (survivors.length > 0) {
throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
}
this.dataDisposable.dispose()
this.exitDisposable.dispose()
}

View File

@@ -13,6 +13,7 @@ class FakePty {
readonly kills: string[] = []
autoExitOnKill = true
throwKill = false
onKill?: () => void
private readonly dataListeners = new Set<(data: string) => void>()
private readonly exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
@@ -39,6 +40,7 @@ class FakePty {
kill(signal?: string): void {
if (this.throwKill) throw new Error('process raced')
this.kills.push(signal ?? 'SIGHUP')
this.onKill?.()
if (this.autoExitOnKill) this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
}
@@ -213,6 +215,46 @@ describe('LocalTerminalHandle', () => {
expect(pty.kills).toEqual(['SIGTERM'])
})
it('sweeps a same-session descendant forked while the shell handles TERM', async () => {
const pty = new FakePty()
const inspector = new FakeInspector()
const late = { pid: 124, started: 'shell-term-trap' }
pty.onKill = () => {
inspector.sessionMembers = [late]
inspector.alive.add(late.pid)
}
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
handle.terminate()
await handle.waitForExit()
expect(inspector.processes).toEqual([[late.pid, 'SIGTERM']])
expect(pty.kills).toEqual(['SIGTERM'])
})
it('keeps a failed post-shell sweep retryable until its survivor leaves', async () => {
vi.useFakeTimers()
const pty = new FakePty()
const inspector = new FakeInspector()
const late = { pid: 124, started: 'shell-term-survivor' }
inspector.removeOnSignal = false
pty.onKill = () => {
inspector.sessionMembers = [late]
inspector.alive.add(late.pid)
}
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
handle.terminate()
const failed = expect(handle.waitForExit()).rejects.toThrow('surviving pids: 124')
await vi.advanceTimersByTimeAsync(25)
await failed
inspector.alive.delete(late.pid)
handle.terminate()
expect(await handle.waitForExit()).toBe(true)
expect(inspector.processes).toEqual([[late.pid, 'SIGTERM'], [late.pid, 'SIGKILL']])
})
it('retains captured descendants after reparenting', async () => {
vi.useFakeTimers()
const pty = new FakePty()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-subprocess/README.md
README.md: fd8cfccdb333018e551459195564daad157d3867
README.zh.md: b5b495e22bbf4328d82925cc793588ee45e5c6e4
README.md: 38ee201a1754c6f50b7fae60b77af7dae734c8f9
README.zh.md: 6fec39de550b7e3f57cd613dbe23c9687cd840ac