fix(runtime): close teardown gaps and simplify framing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 结果对模型可见,因此查询不满足先读后写策略。
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.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 失败的句柄在结算时即离开存活集合。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user