fix(pty,subprocess,lsp): address review round on cancellation, lookup, and contracts
- pty-local: a pre-write inspection rejection no longer releases a canceled send while its foreground SIGINT is in flight; the interrupt path retains the slot and its post-signal tail resumes polling. Regression pins the failure-shaped cancellation and a close-during-write release. - pty-local: SEND_ACTIVE names which provider operation is draining; README states the never-settling-provider outcome (slot retained, close recovers). - subprocess-local: resolveExecutable rejects relative paths containing separators instead of expanding them per PATH entry with a misleading not-found error; seam JSDoc pins the rule for every provider. - subprocess-local: LocalTerminalHandle documents why the seam's in-flight-join promise holds without operation tracking. - lsp-local: the oversized-source diagnostic reports the observed byte lower bound; README documents that processId: null trades away server-side orphan cleanup after a hard-killed harness. - pty/subprocess seams: cross-reference the twin five-member signal unions. - pty-local: TODO markers for send-state consolidation and initialize-race relocation.
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/pty/pty-local/README.md
|
||||
README.md: 92a3d7be68ada6f38ab3c4ca5bdd2622ebefc8ee
|
||||
README.zh.md: 016043f7842df8bb963cdc5eea015666c9ce6941
|
||||
README.md: 5acc92853e6e8fcb8938c48e391559bf4a28fb75
|
||||
README.zh.md: 353c2a4bdac7e8402fc63071dfb6fb85dcff66d5
|
||||
|
||||
@@ -10,7 +10,7 @@ The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the
|
||||
|
||||
Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `PtyBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
|
||||
|
||||
Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`.
|
||||
Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. A provider write or signal that never settles therefore retains the slot indefinitely; closing the session (`terminal_close`) is the recovery. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`PtyBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
|
||||
|
||||
取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`。
|
||||
取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。因此,永不结算的提供方写入或信号会无限期保留该位置;恢复手段是关闭会话(`terminal_close`)。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSp
|
||||
}).argv
|
||||
}
|
||||
|
||||
// TODO(pty-initialize-race-home): Fold this outer abort race into
|
||||
// LocalPtySession.initialize when the send-state consolidation lands; the
|
||||
// session already owns the send lifecycle the race protects.
|
||||
async function initializeSession(session: LocalPtySession, signal?: AbortSignal): Promise<void> {
|
||||
if (signal === undefined) {
|
||||
await session.initialize(signal)
|
||||
|
||||
@@ -162,6 +162,11 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private readonly outputEnded = Promise.withResolvers<void>()
|
||||
private readonly completion: Promise<void>
|
||||
private statusValue: PtySessionStatus = { kind: 'running' }
|
||||
// TODO(pty-send-state-consolidation): Fold the per-send fields below
|
||||
// (active/activeTimer/activeDeadlineTimer/activeAbort/interrupting/
|
||||
// activeWrite/pollingReady/polling) into one send-lifecycle owner; the
|
||||
// cancellation/readiness interplay now has enough pinned tests to carry
|
||||
// that refactor safely.
|
||||
private active: LocalSendOperation | undefined
|
||||
private activeTimer: NodeJS.Timeout | undefined
|
||||
private activeDeadlineTimer: NodeJS.Timeout | undefined
|
||||
@@ -220,7 +225,14 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
startSend(request: PtySendRequest): PtySendOperation {
|
||||
if (this.closing) throw new Error('PTY session is closing')
|
||||
if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited')
|
||||
if (this.active !== undefined) throw new PtyError('PTY session already has an active send or draining provider operation', 'SEND_ACTIVE')
|
||||
if (this.active !== undefined) {
|
||||
const draining = this.activeWrite !== undefined
|
||||
? ' or draining provider write'
|
||||
: this.interrupting !== undefined
|
||||
? ' or draining foreground interrupt'
|
||||
: ''
|
||||
throw new PtyError(`PTY session already has an active send${draining}`, 'SEND_ACTIVE')
|
||||
}
|
||||
if (request.signal?.aborted === true) throw new Error('PTY send aborted before write')
|
||||
|
||||
const operation = new LocalSendOperation(
|
||||
@@ -246,8 +258,22 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
}
|
||||
|
||||
private async beginSend(operation: LocalSendOperation, request: PtySendRequest): Promise<void> {
|
||||
let foreground: SubprocessTerminalForeground | undefined
|
||||
try {
|
||||
foreground = await this.terminal.inspectForeground()
|
||||
} catch (error: unknown) {
|
||||
// A pre-write inspection failure while cancellation owns the slot must not
|
||||
// release it: interruptOnce's in-flight foreground signal could land on a
|
||||
// successor's foreground group. The interrupt path's post-signal tail
|
||||
// resumes polling, whose guarded catch propagates a persistent failure.
|
||||
// A retained settled operation implies that same in-flight interrupt, so
|
||||
// this guard admits only an unsettled active send.
|
||||
if (this.active === operation && !this.closing && this.interrupting !== operation) {
|
||||
this.failActive(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const foreground = await this.terminal.inspectForeground()
|
||||
if (this.active !== operation || this.closing || this.interrupting === operation) return
|
||||
operation.setInitialForeground(foreground)
|
||||
const input = `${request.text}${request.submit ? '\r' : ''}`
|
||||
|
||||
@@ -345,6 +345,47 @@ describe('LocalPtySession readiness and output', () => {
|
||||
await operation.done
|
||||
})
|
||||
|
||||
it('retains a canceled send when the pre-write inspection rejects while its signal is in flight', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const failed = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||||
terminal.inspectForeground = async () => await failed.promise
|
||||
const uncanceled = session.startSend({ text: 'plain failure', submit: true })
|
||||
failed.reject(new Error('inspect failed before write'))
|
||||
await expect(uncanceled.done).rejects.toThrow('inspect failed before write')
|
||||
|
||||
terminal.inspectForeground = FakeTerminal.prototype.inspectForeground.bind(terminal)
|
||||
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||||
terminal.inspectForeground = async () => await inspection.promise
|
||||
const signalGate = Promise.withResolvers<undefined>()
|
||||
terminal.signalForeground = async (signal) => {
|
||||
await signalGate.promise
|
||||
inspector.signalGroup(456, signal)
|
||||
return 456
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const operation = session.startSend({ text: 'must stay owned', submit: true, signal: controller.signal })
|
||||
controller.abort()
|
||||
inspection.reject(new Error('transient inspection failure'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// The slot stays reserved while the cancellation's foreground signal is in flight.
|
||||
expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
|
||||
terminal.inspectForeground = async () => ({ processGroupId: 456, inputWaiting: false })
|
||||
signalGate.resolve(undefined)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||||
|
||||
terminal.emitData('\x1b]133;D;130\x07dsh> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await operation.done
|
||||
})
|
||||
|
||||
it('retains a canceled send until asynchronous foreground signalling settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
@@ -1176,6 +1217,24 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
})
|
||||
|
||||
it('settles a closing send when provider termination cancels inspection', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
await initialize(session, terminal)
|
||||
const write = Promise.withResolvers<undefined>()
|
||||
terminal.write = async () => { await write.promise }
|
||||
const writeOperation = session.startSend({ text: 'pending write', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await session.close('pending write')
|
||||
expect((await writeOperation.done).waitReason).toBe('session_exit')
|
||||
// The rejection lands after close released the send; it must stay contained.
|
||||
write.reject(new Error('write rejected during close'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('settles a closing send when provider termination cancels a pending inspection', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
|
||||
@@ -28,7 +28,11 @@ export class PtyBackendCleanupError extends AggregateError {
|
||||
/** Why one interactive send returned control to its caller. */
|
||||
export type PtyWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit'
|
||||
|
||||
/** Signals the model-facing PTY surface permits for foreground process groups. */
|
||||
/**
|
||||
* Signals the model-facing PTY surface permits for foreground process groups.
|
||||
* Kept member-identical to `SubprocessTerminalSignal` in
|
||||
* `@deepseek-ai/dsh-subprocess` without a cross-seam dependency; change both together.
|
||||
*/
|
||||
export type PtySignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP'
|
||||
|
||||
/** Top-level PTY process status, independent of a send's wait reason. */
|
||||
|
||||
Reference in New Issue
Block a user