fix(pty): close retained lifecycle review gaps

This commit is contained in:
Tianyi Cui
2026-07-22 23:17:53 +08:00
parent 58657db1b5
commit 90ac883512
8 changed files with 149 additions and 56 deletions

View File

@@ -7,6 +7,7 @@
import { Context } from 'cordis'
import * as nodePty from 'node-pty'
import type { IPtyForkOptions } from 'node-pty'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
@@ -21,10 +22,37 @@ export type { Config as PtyLocalConfig } from './config.ts'
/** Cordis plugin name. */
export const name = 'pty-local'
/** Required services: owner/PTY registries plus the one shared confinement policy. */
export const inject = ['agents', 'pty', 'sandbox', 'sandboxPolicy']
/** Required services: PTY registry plus the one shared confinement policy. */
export const inject = ['pty', 'sandbox', 'sandboxPolicy']
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
interface SandboxModeFenceState {
pty: Context['pty']
sandboxPolicy: Context['sandboxPolicy']
}
const sandboxModeFences = new WeakMap<Agent, SandboxModeFenceState>()
function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
const existing = sandboxModeFences.get(owner)
if (existing !== undefined) {
existing.pty = ctx.pty
existing.sandboxPolicy = ctx.sandboxPolicy
return
}
const state: SandboxModeFenceState = { pty: ctx.pty, sandboxPolicy: ctx.sandboxPolicy }
sandboxModeFences.set(owner, state)
owner.ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
if (session !== owner.session || event.type !== 'sandbox/mode') return
const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode
if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return
throw new Error(
`cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`,
)
}, { global: true })
}
function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
@@ -75,6 +103,7 @@ export class LocalPtyBackend implements PtyBackend {
async spawn(spec: PtyBackendSpawnSpec): Promise<LocalPtySession> {
spec.signal?.throwIfAborted()
ensureSandboxModeFence(this.ctx, spec.owner)
const argv = spawnArgv(this.ctx, this.config, spec)
const file = argv[0]
if (file === undefined) throw new Error('pty-local: sandbox returned empty argv')
@@ -106,17 +135,4 @@ export function apply(ctx: Context, config: Config): void {
validateConfig(config)
const inspector = createProcessInspector()
ctx.pty.registerBackend(new LocalPtyBackend(ctx, config, inspector))
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
if (event.type !== 'sandbox/mode') return
const currentMode = effectiveSandboxMode(session.events) ?? ctx.sandboxPolicy.defaultMode
if (event.data.mode === currentMode) return
const owner = ctx.agents.get(session.id)
if (owner === undefined) return
if (!ctx.pty.hasOwnerActivity(owner)) return
throw new Error(
`cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`,
)
}, { global: true })
}

View File

@@ -394,16 +394,31 @@ export class LocalPtySession implements PtyBackendSession {
}
}
private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] {
const members: ProcessIdentity[] = []
const seen = new Set<string>()
for (const group of groups) {
for (const member of group) {
const key = JSON.stringify([member.pid, member.started])
if (seen.has(key)) continue
seen.add(key)
members.push(member)
}
}
return members
}
private async stopDescendants(): Promise<ProcessIdentity[]> {
let members = this.descendants()
this.signalMembers(members, 'SIGTERM')
await this.waitForExit(members)
const captured = this.descendants()
this.signalMembers(captured, 'SIGTERM')
const capturedSurvivors = await this.waitForExit(captured)
// A TERM-handling descendant may have forked while winding down. Rescan
// while the shell can still reap every member, then kill the fresh tree.
members = this.descendants()
// while the shell can still reap every member, then kill both the fresh
// tree and captured survivors that were reparented out of that tree.
const members = this.unionMembers(capturedSurvivors, this.descendants())
this.signalMembers(members, 'SIGKILL')
await this.waitForExit(members)
return this.descendants().filter(member => this.inspector.isAlive(member))
const survivors = await this.waitForExit(members)
return this.survivors(this.unionMembers(survivors, this.descendants()))
}
private async stopShell(): Promise<void> {