refactor(runtime): collapse speculative portability layers
Remove the one-consumer bounded-read primitive and shared terminal lifecycle controller, make terminal cleanup one awaited provider operation, and reuse one Code Runtime contract suite. Keep only reproduced cancellation and policy fixes; defer unproven replacement, prompt-attribution, and streaming-frame concerns to scoped markers.
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: bccef05ca73ec8d826621f814f232206fa9fea4b
|
||||
README.zh.md: 27d4d1193047eb1bb3bf2289697be3c0cc0253aa
|
||||
README.md: c4af7bc8293689d64c58eebab2606d4f9b52fd2f
|
||||
README.zh.md: 21075ccbf53d173a54220b58b384f01cfe50ced1
|
||||
|
||||
@@ -8,9 +8,9 @@ Persistent shell backend for `ctx.pty` over `ctx.subprocess.spawnTerminal`. It s
|
||||
|
||||
The plugin injects `pty`, `sandbox`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
|
||||
|
||||
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 a delayed earlier prompt therefore cannot settle the current send. After a signaled send returns `inferred_idle` without a prompt marker, the next marker remains attributed to that prior send and cannot settle a successor even when it follows the successor's echoed input. 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 terminal-allocation signal is detached when allocation returns, while readiness initialization keeps the setup signal, so later cancellation cannot terminate a published persistent session. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
|
||||
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. The canceled send retains its slot until foreground signalling settles, so a successor cannot become that signal's target. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. A send that times out during an asynchronous provider write, or whose cancellation signal fails while that write remains pending, reports its result but retains the slot until the write settles, so late bytes cannot interleave with a successor. Close rejects new public signals after shutdown begins, drains public signals already in flight, starts provider-owned TERM-to-KILL whole-session cleanup, and awaits quiescence after the terminal outcome. A cleanup failure does not cache a permanently rejected close; a later close retries the provider operation.
|
||||
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`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
该插件注入 `pty`、`sandbox`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 会直接启动 shell;受限模式则通过 `ctx.sandbox` 包装确切的 shell argv。系统在 spawn 时解析会话的实际模式。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建结算并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
|
||||
|
||||
就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最近一个自有标记之后的可打印尾部与受控 `PS1` 完全相等时,系统才会把标记视为就绪;即使 OSC 标记和提示符被拆到多个数据回调中也是如此。因此,如果回显的输入或输出跟在延迟到达的先前提示符之后,该提示符无法使当前 send 完成。在一次经过信号处理的 send 未出现提示符标记却返回 `inferred_idle` 后,下一个标记仍归属于该先前 send;即使该标记出现在后续 send 的输入回显之后,也不能使后续 send 完成。系统会在写入边界丢弃提供方写入前收集的提示符与静默证据,包括写入前的前台检查尚未完成时收集的证据。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`PtyBackendCleanupError` 会单独保留清理失败。终端分配返回时,分配信号会解除关联;就绪初始化则保留设置阶段信号,因此后续取消无法终止已发布的持久会话。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
|
||||
就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最近一个自有标记之后的可打印尾部与受控 `PS1` 完全相等时,系统才会把标记视为就绪;即使 OSC 标记和提示符被拆到多个数据回调中也是如此。因此,如果回显的输入或输出跟在先前提示符之后,该提示符无法使当前 send 完成。系统会在写入边界丢弃提供方写入前收集的提示符与静默证据,包括写入前的前台检查尚未完成时收集的证据。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`PtyBackendCleanupError` 会单独保留清理失败。调用方信号会转发给终端分配和就绪初始化;句柄一经发布,便负责自身生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
|
||||
|
||||
取消发送会先把排队输入标记为已取消,再请求终端句柄向当前前台进程组发送真正的 `SIGINT`;如果异步的写入前检查随后才结算,也无法执行该输入。被取消的发送会保留其槽位,直至前台信号发送结算,因此后续发送不会成为该信号的目标。取消绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。发送在提供方异步写入期间超时,或在该写入仍未完成时其取消信号发送失败,都会报告各自结果,但继续占用该槽位,直至写入结算,从而避免延迟到达的字节与后续发送交错。关闭操作会在关闭开始后拒绝新的公开信号请求,等待已经在途的公开信号请求全部结算,再启动由提供方负责的 TERM→KILL 全会话清理,并在终端结果之后等待完全停稳。清理失败不会缓存成永久拒绝的关闭操作;后续关闭会重试提供方操作。
|
||||
取消发送会先把排队输入标记为已取消,再请求终端句柄向当前前台进程组发送真正的 `SIGINT`;如果异步的写入前检查随后才结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待写入结算;写入被拒绝时不会发送信号。被取消的发送会保留其槽位,直至写入和前台信号发送都结算,因此后续发送既不会收到延迟字节,也不会成为该信号的目标。取消等待期间,绝对截止时间仍保持启用。信号发送失败属于终结性传输失败,并会使当前发送被拒绝。取消绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待句柄执行由提供方负责的完整会话终止,之后才将当前发送以 `session_exit` 结算。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -101,30 +101,15 @@ export class LocalPtyBackend implements PtyBackend {
|
||||
ensureSandboxModeFence(this.ctx, spec.owner)
|
||||
const argv = spawnArgv(this.ctx, this.config, spec)
|
||||
if (argv[0] === undefined) throw new Error('pty-local: sandbox returned empty argv')
|
||||
let terminalSignal: AbortSignal | undefined
|
||||
let detachSetupSignal: (() => void) | undefined
|
||||
if (spec.signal !== undefined) {
|
||||
const source = spec.signal
|
||||
const controller = new AbortController()
|
||||
const onAbort = (): void => { controller.abort(source.reason) }
|
||||
source.addEventListener('abort', onAbort, { once: true })
|
||||
detachSetupSignal = () => { source.removeEventListener('abort', onAbort) }
|
||||
terminalSignal = controller.signal
|
||||
}
|
||||
let terminal: SubprocessTerminalHandle
|
||||
try {
|
||||
terminal = await this.spawnTerminal({
|
||||
argv,
|
||||
cwd: spec.cwd ?? this.ctx.sandboxPolicy.workspaceRoot,
|
||||
env: childEnvironment(spec),
|
||||
rows: this.config.rows,
|
||||
cols: this.config.cols,
|
||||
graceMs: this.config.disposeGraceMs,
|
||||
signal: terminalSignal,
|
||||
})
|
||||
} finally {
|
||||
detachSetupSignal?.()
|
||||
}
|
||||
const terminal = await this.spawnTerminal({
|
||||
argv,
|
||||
cwd: spec.cwd ?? this.ctx.sandboxPolicy.workspaceRoot,
|
||||
env: childEnvironment(spec),
|
||||
rows: this.config.rows,
|
||||
cols: this.config.cols,
|
||||
graceMs: this.config.disposeGraceMs,
|
||||
signal: spec.signal,
|
||||
})
|
||||
const session = this.createSession(terminal, this.config)
|
||||
try {
|
||||
await session.initialize(spec.signal)
|
||||
|
||||
@@ -155,7 +155,7 @@ class LocalSendOperation implements PtySendOperation {
|
||||
export class LocalPtySession implements PtyBackendSession {
|
||||
motd = ''
|
||||
readonly pid: number
|
||||
private readonly decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
private readonly decoder = new TextDecoder()
|
||||
private readonly sanitizer: TerminalSanitizer
|
||||
private readonly scrollback: BoundedTextBuffer
|
||||
private readonly outputEnded = Promise.withResolvers<void>()
|
||||
@@ -165,16 +165,13 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private activeTimer: NodeJS.Timeout | undefined
|
||||
private activeDeadlineTimer: NodeJS.Timeout | undefined
|
||||
private activeAbort: (() => void) | undefined
|
||||
private readonly terminalOperations = new Set<Promise<unknown>>()
|
||||
private signaledOperation: LocalSendOperation | undefined
|
||||
private interrupting: LocalSendOperation | undefined
|
||||
private writing: LocalSendOperation | undefined
|
||||
private activeWrite: { operation: LocalSendOperation; settled: Promise<boolean> } | undefined
|
||||
private pollingReady: LocalSendOperation | undefined
|
||||
private polling = false
|
||||
private promptSeen = false
|
||||
private promptTextSeen = false
|
||||
private promptTail = ''
|
||||
private delayedSignaledPrompt = false
|
||||
private shellPgid: number | undefined
|
||||
private initializing = false
|
||||
private lastOutputAt = Date.now()
|
||||
@@ -239,23 +236,14 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
this.activeDeadlineTimer = setTimeout(() => {
|
||||
if (this.active === operation) this.settleActive('timeout', this.writing === operation)
|
||||
if (this.active === operation) {
|
||||
this.settleActive('timeout', this.activeWrite?.operation === operation)
|
||||
}
|
||||
}, this.config.timeoutMs)
|
||||
this.ownTerminalOperation(this.beginSend(operation, request))
|
||||
void this.beginSend(operation, request)
|
||||
return operation
|
||||
}
|
||||
|
||||
/** Retain one contained provider operation until its asynchronous work finishes. */
|
||||
private ownTerminalOperation(operation: Promise<void>): void {
|
||||
void this.trackTerminalOperation(operation)
|
||||
}
|
||||
|
||||
private trackTerminalOperation<T>(operation: Promise<T>): Promise<T> {
|
||||
const tracked = operation.finally(() => { this.terminalOperations.delete(tracked) })
|
||||
this.terminalOperations.add(tracked)
|
||||
return tracked
|
||||
}
|
||||
|
||||
private async beginSend(operation: LocalSendOperation, request: PtySendRequest): Promise<void> {
|
||||
try {
|
||||
const foreground = await this.terminal.inspectForeground()
|
||||
@@ -264,13 +252,20 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
const input = `${request.text}${request.submit ? '\r' : ''}`
|
||||
if (input.length > 0 && !operation.cancelRequested) {
|
||||
this.resetReadinessEvidence()
|
||||
this.writing = operation
|
||||
const write = this.terminal.write(input)
|
||||
const activeWrite = {
|
||||
operation,
|
||||
settled: write.then(() => true, () => false),
|
||||
}
|
||||
this.activeWrite = activeWrite
|
||||
try {
|
||||
await this.terminal.write(Buffer.from(input, 'utf8'))
|
||||
await write
|
||||
} finally {
|
||||
this.writing = undefined
|
||||
this.activeWrite = undefined
|
||||
}
|
||||
}
|
||||
// Cancellation owns post-write signalling and reservation release.
|
||||
if (operation.cancelRequested) return
|
||||
if (this.active === operation && operation.settled) {
|
||||
this.clearActive()
|
||||
return
|
||||
@@ -282,7 +277,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.schedulePoll(operation)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (this.active === operation) {
|
||||
if (this.active === operation && !this.closing) {
|
||||
if (operation.settled) this.clearActive()
|
||||
else this.failActive(error)
|
||||
}
|
||||
@@ -323,8 +318,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
|
||||
async signal(signal: PtySignal): Promise<PtySignalResult> {
|
||||
if (this.closing) throw new Error('PTY session is closing')
|
||||
if (this.active !== undefined) this.signaledOperation = this.active
|
||||
const targetPgid = await this.trackTerminalOperation(this.terminal.signalForeground(signal))
|
||||
const targetPgid = await this.terminal.signalForeground(signal)
|
||||
return { delivered: true, targetPgid }
|
||||
}
|
||||
|
||||
@@ -345,23 +339,14 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
}
|
||||
|
||||
private readonly onTerminalData = (chunk: Buffer | Uint8Array | string): void => {
|
||||
try {
|
||||
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
|
||||
this.onData(this.decoder.decode(bytes, { stream: true }))
|
||||
} catch (error: unknown) {
|
||||
this.onTransportFailure(new Error('PTY emitted invalid UTF-8', { cause: error }))
|
||||
}
|
||||
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
|
||||
this.onData(this.decoder.decode(bytes, { stream: true }))
|
||||
}
|
||||
|
||||
private readonly onTerminalEnd = (): void => {
|
||||
try {
|
||||
this.onData(this.decoder.decode())
|
||||
this.appendOutput(this.sanitizer.flush())
|
||||
} catch (error: unknown) {
|
||||
this.onTransportFailure(new Error('PTY ended with invalid UTF-8', { cause: error }))
|
||||
} finally {
|
||||
this.outputEnded.resolve()
|
||||
}
|
||||
this.onData(this.decoder.decode())
|
||||
this.appendOutput(this.sanitizer.flush())
|
||||
this.outputEnded.resolve()
|
||||
}
|
||||
|
||||
private readonly onTerminalError = (error: Error): void => {
|
||||
@@ -372,9 +357,9 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private onData(data: string): void {
|
||||
const sanitized = this.sanitizer.push(data)
|
||||
this.appendOutput(sanitized.text)
|
||||
if (sanitized.prompt && this.delayedSignaledPrompt) {
|
||||
this.delayedSignaledPrompt = false
|
||||
} else if (sanitized.prompt) {
|
||||
if (sanitized.prompt) {
|
||||
// TODO(pty-delayed-signal-prompt): With a reproducer, define a marker-generation boundary
|
||||
// before attributing a signal-delayed prompt to a later send.
|
||||
// Bash can print PROMPT_COMMAND before the kernel publishes its return
|
||||
// to the foreground process group. Retain the marker; polling below is
|
||||
// the authority that accepts it only after bash owns the foreground.
|
||||
@@ -402,7 +387,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.transportFailure ??= failure
|
||||
this.statusValue = { kind: 'exited', exitCode: null, signal: null }
|
||||
this.failActive(failure)
|
||||
this.terminal.terminate()
|
||||
void this.terminal.terminate().catch(() => {})
|
||||
}
|
||||
|
||||
private appendOutput(text: string): void {
|
||||
@@ -417,7 +402,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
|
||||
this.activeTimer = setTimeout(() => {
|
||||
this.activeTimer = undefined
|
||||
this.ownTerminalOperation(this.pollReadiness(operation))
|
||||
void this.pollReadiness(operation)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
@@ -457,7 +442,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.settleActive('inferred_idle')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (this.active === operation) this.failActive(error)
|
||||
if (this.active === operation && !this.closing) this.failActive(error)
|
||||
} finally {
|
||||
this.polling = false
|
||||
const active = this.active
|
||||
@@ -470,13 +455,6 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private settleActive(waitReason: PtyWaitReason, retainOwnership = false): void {
|
||||
const operation = this.active
|
||||
if (operation === undefined) return
|
||||
// A signaled command can return by silence before bash emits its prompt.
|
||||
// Reserve that marker so it cannot become successor readiness after echo.
|
||||
const signaled = this.signaledOperation === operation
|
||||
if (signaled) this.signaledOperation = undefined
|
||||
if (waitReason === 'inferred_idle' && !this.promptSeen && signaled) {
|
||||
this.delayedSignaledPrompt = true
|
||||
}
|
||||
const scrollbackTruncated = this.scrollback.snapshot().truncated
|
||||
if (retainOwnership) {
|
||||
this.stopPolling()
|
||||
@@ -489,10 +467,14 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
|
||||
this.activeTimer = undefined
|
||||
this.stopReadinessPolling()
|
||||
if (this.activeDeadlineTimer !== undefined) clearTimeout(this.activeDeadlineTimer)
|
||||
this.activeDeadlineTimer = undefined
|
||||
}
|
||||
|
||||
private stopReadinessPolling(): void {
|
||||
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
|
||||
this.activeTimer = undefined
|
||||
this.pollingReady = undefined
|
||||
}
|
||||
|
||||
@@ -502,43 +484,38 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.activeAbort?.()
|
||||
this.activeAbort = undefined
|
||||
if (this.interrupting === operation) this.interrupting = undefined
|
||||
if (this.signaledOperation === operation) this.signaledOperation = undefined
|
||||
this.writing = undefined
|
||||
this.pollingReady = undefined
|
||||
this.active = undefined
|
||||
}
|
||||
|
||||
private failActive(error: unknown, retainOwnership = false): void {
|
||||
private failActive(error: unknown): void {
|
||||
const operation = this.active
|
||||
if (operation === undefined) return
|
||||
if (retainOwnership) {
|
||||
this.stopPolling()
|
||||
this.activeAbort?.()
|
||||
this.activeAbort = undefined
|
||||
} else {
|
||||
this.clearActive()
|
||||
}
|
||||
this.clearActive()
|
||||
operation.fail(error)
|
||||
}
|
||||
|
||||
private interrupt(operation: LocalSendOperation): void {
|
||||
if (this.active !== operation) return
|
||||
this.signaledOperation = operation
|
||||
this.interrupting = operation
|
||||
this.stopPolling()
|
||||
this.ownTerminalOperation(this.interruptOnce(operation))
|
||||
this.stopReadinessPolling()
|
||||
void this.interruptOnce(operation)
|
||||
}
|
||||
|
||||
private async interruptOnce(operation: LocalSendOperation): Promise<void> {
|
||||
try {
|
||||
const activeWrite = this.activeWrite
|
||||
if (activeWrite?.operation === operation && !await activeWrite.settled) return
|
||||
await this.terminal.signalForeground('SIGINT')
|
||||
} catch (error: unknown) {
|
||||
if (this.active === operation) this.failActive(error, this.writing === operation)
|
||||
if (this.active === operation && !this.closing) this.onTransportFailure(error)
|
||||
return
|
||||
} finally {
|
||||
if (this.interrupting === operation) this.interrupting = undefined
|
||||
}
|
||||
if (this.active === operation && !operation.settled && !this.closing && this.writing !== operation) {
|
||||
if (this.active === operation && operation.settled) {
|
||||
this.clearActive()
|
||||
} else if (this.active === operation && !this.closing) {
|
||||
this.pollingReady = operation
|
||||
this.schedulePoll(operation, 0)
|
||||
}
|
||||
@@ -549,20 +526,13 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
// it as session_exit below, so an in-flight send is never mis-settled as
|
||||
// stdin_read/inferred_idle/timeout during the grace period.
|
||||
this.stopPolling()
|
||||
this.terminal.terminate()
|
||||
const quiescent = await this.terminal.waitForExit()
|
||||
if (!quiescent) {
|
||||
throw new Error(`PTY cleanup failed (${reason}); terminal session did not reach quiescence`)
|
||||
try {
|
||||
await this.terminal.terminate()
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`PTY cleanup failed (${reason})`, { cause: error })
|
||||
}
|
||||
// Quiescence is the active send's terminal outcome. Detach its abort
|
||||
// listener before snapshotting provider operations so no late interrupt
|
||||
// can enter the owned set after the drain starts.
|
||||
// Quiescence is the active send's terminal outcome.
|
||||
this.settleActive('session_exit')
|
||||
await Promise.all(this.terminalOperations)
|
||||
// Whole-session cleanup can fail before the top-level process exits. Wait
|
||||
// for it first so that failure is reported instead of blocking forever on
|
||||
// `done`; successful quiescence guarantees `done` can now settle status and
|
||||
// drain the terminal output.
|
||||
await this.completion
|
||||
this.terminal.output.off('data', this.onTerminalData)
|
||||
this.terminal.output.off('end', this.onTerminalEnd)
|
||||
|
||||
@@ -61,8 +61,7 @@ function terminalHandle(): SubprocessTerminalHandle {
|
||||
write: async () => {},
|
||||
inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }),
|
||||
signalForeground: async () => 123,
|
||||
terminate: () => { output.end() },
|
||||
waitForExit: async () => true,
|
||||
terminate: async () => { output.end() },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +186,7 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
expect(initialized).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('forwards setup cancellation only while terminal allocation is unpublished', async () => {
|
||||
it('forwards terminal allocation cancellation directly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
@@ -204,10 +203,9 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
() => stubLocalSession(),
|
||||
)
|
||||
await published.spawn(spec(agent(ctx), publishedController.signal))
|
||||
expect(publishedSignal).toBeDefined()
|
||||
expect(publishedSignal).not.toBe(publishedController.signal)
|
||||
expect(publishedSignal).toBe(publishedController.signal)
|
||||
publishedController.abort(new Error('originating turn ended'))
|
||||
expect(publishedSignal?.aborted).toBe(false)
|
||||
expect(publishedSignal?.aborted).toBe(true)
|
||||
|
||||
const pendingController = new AbortController()
|
||||
const seen = Promise.withResolvers<AbortSignal>()
|
||||
@@ -245,11 +243,10 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
write: async () => {},
|
||||
inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }),
|
||||
signalForeground: async () => 123,
|
||||
terminate() {
|
||||
async terminate() {
|
||||
output.end()
|
||||
outcome.resolve({ exitCode: null, signal: 'SIGTERM' })
|
||||
},
|
||||
waitForExit: async () => true,
|
||||
}
|
||||
queueMicrotask(() => { output.write(Buffer.from('\x1b]133;D;0\x07dsh> ')) })
|
||||
const backend = new LocalPtyBackend(
|
||||
|
||||
@@ -50,8 +50,8 @@ class FakeTerminal implements SubprocessTerminalHandle {
|
||||
throwWrite = false
|
||||
throwKill = false
|
||||
autoExitOnKill = true
|
||||
quiescent = true
|
||||
waitError: Error | undefined
|
||||
terminateError: Error | undefined
|
||||
private cleanup: Promise<void> | undefined
|
||||
|
||||
constructor(public inspector = new FakeInspector()) {}
|
||||
|
||||
@@ -80,9 +80,9 @@ class FakeTerminal implements SubprocessTerminalHandle {
|
||||
})
|
||||
}
|
||||
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
async write(data: string): Promise<void> {
|
||||
if (this.throwWrite) throw new Error('write failed')
|
||||
this.writes.push(Buffer.from(data).toString('utf8'))
|
||||
this.writes.push(data)
|
||||
}
|
||||
|
||||
async inspectForeground() {
|
||||
@@ -102,16 +102,20 @@ class FakeTerminal implements SubprocessTerminalHandle {
|
||||
return foreground.processGroupId
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
terminate(): Promise<void> {
|
||||
if (this.cleanup !== undefined) return this.cleanup
|
||||
const cleanup = this.terminateOnce()
|
||||
this.cleanup = cleanup
|
||||
void cleanup.catch(() => { this.cleanup = undefined })
|
||||
return cleanup
|
||||
}
|
||||
|
||||
private async terminateOnce(): Promise<void> {
|
||||
if (this.terminateError !== undefined) throw this.terminateError
|
||||
if (this.throwKill) throw new Error('kill failed')
|
||||
this.kills.push('SIGTERM')
|
||||
if (this.autoExitOnKill) this.emitExit(0, 15)
|
||||
}
|
||||
|
||||
async waitForExit(): Promise<boolean> {
|
||||
if (this.waitError !== undefined) throw this.waitError
|
||||
return this.quiescent
|
||||
}
|
||||
}
|
||||
|
||||
function makeSession(
|
||||
@@ -370,6 +374,100 @@ describe('LocalPtySession readiness and output', () => {
|
||||
expect(inspector.groups).not.toContainEqual([789, 'SIGINT'])
|
||||
})
|
||||
|
||||
it('signals only after an in-flight provider write lands', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const writeGate = Promise.withResolvers<undefined>()
|
||||
terminal.write = async () => { await writeGate.promise }
|
||||
const operation = session.startSend({ text: 'must be interrupted', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(operation.cancel()).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(inspector.groups).toEqual([])
|
||||
|
||||
writeGate.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('does not signal when a cancelled provider write rejects', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const writeGate = Promise.withResolvers<undefined>()
|
||||
terminal.write = async () => { await writeGate.promise }
|
||||
const operation = session.startSend({ text: 'rejected write', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(operation.cancel()).toBe(true)
|
||||
|
||||
const rejected = expect(operation.done).rejects.toThrow('write failed after cancellation')
|
||||
writeGate.reject(new Error('write failed after cancellation'))
|
||||
await rejected
|
||||
expect(inspector.groups).toEqual([])
|
||||
|
||||
const next = session.startSend({ text: '', submit: false })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect((await next.done).waitReason).toBe('inferred_idle')
|
||||
})
|
||||
|
||||
it('releases a timed-out cancellation after the provider write and signal settle', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const writeGate = Promise.withResolvers<undefined>()
|
||||
terminal.write = async () => { await writeGate.promise }
|
||||
const operation = session.startSend({ text: 'slow cancelled write', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(operation.cancel()).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect((await operation.done).waitReason).toBe('timeout')
|
||||
expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow('active send')
|
||||
|
||||
writeGate.resolve(undefined)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||||
|
||||
const next = session.startSend({ text: '', submit: false })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect((await next.done).waitReason).toBe('inferred_idle')
|
||||
})
|
||||
|
||||
it('retains the absolute timeout after cancellation while output stays active', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const operation = session.startSend({ text: 'ignore-sigint-and-write', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(operation.cancel()).toBe(true)
|
||||
for (let elapsed = 20; elapsed <= 100; elapsed += 20) {
|
||||
terminal.emitData('.')
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
}
|
||||
|
||||
expect(await operation.done).toMatchObject({ waitReason: 'timeout' })
|
||||
})
|
||||
|
||||
it('does not resume cancellation polling after the terminal exits during signalling', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
@@ -437,21 +535,24 @@ describe('LocalPtySession readiness and output', () => {
|
||||
|
||||
const writeGate = Promise.withResolvers<undefined>()
|
||||
terminal.write = async () => { await writeGate.promise }
|
||||
terminal.signalForeground = async () => { throw new Error('interrupt failed') }
|
||||
let signalCalls = 0
|
||||
terminal.signalForeground = async () => {
|
||||
signalCalls += 1
|
||||
throw new Error('interrupt failed')
|
||||
}
|
||||
const operation = session.startSend({ text: 'slow write', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const rejected = expect(operation.done).rejects.toThrow('interrupt failed')
|
||||
expect(operation.cancel()).toBe(true)
|
||||
await rejected
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(signalCalls).toBe(0)
|
||||
expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow('active send')
|
||||
|
||||
writeGate.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const next = session.startSend({ text: '', submit: false })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect((await next.done).waitReason).toBe('inferred_idle')
|
||||
await expect(operation.done).rejects.toThrow('interrupt failed')
|
||||
expect(signalCalls).toBe(1)
|
||||
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
|
||||
})
|
||||
|
||||
it('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => {
|
||||
@@ -591,35 +692,6 @@ describe('LocalPtySession readiness and output', () => {
|
||||
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read' })
|
||||
})
|
||||
|
||||
it('does not attribute a post-echo prompt from an inferred prior send to its successor', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal, config({ idleSilenceMs: 50, timeoutMs: 200 }))
|
||||
await initialize(session, terminal)
|
||||
|
||||
const interrupted = session.startSend({ text: 'sleep', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await session.signal('SIGINT')
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect((await interrupted.done).waitReason).toBe('inferred_idle')
|
||||
|
||||
const successor = session.startSend({ text: "printf 'PID=%s\\n' \"$!\"", submit: true })
|
||||
let settled = false
|
||||
void successor.done.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
terminal.emitData('printf \'PID=%s\\n\' "$!"\r\n\x1b]133;D;130\x07dsh> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
terminal.emitData('PID=123\r\n\x1b]133;D;0\x07dsh> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
const result = await successor.done
|
||||
expect(result.waitReason).toBe('stdin_read')
|
||||
expect(result.viewport).toContain('PID=123')
|
||||
})
|
||||
|
||||
it('retains a prompt marker until the startup shell regains the foreground group', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
@@ -683,6 +755,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
|
||||
it('contains terminal transport failures and preserves the first failure', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.terminateError = new Error('cleanup after transport failure')
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
const operation = session.startSend({ text: '', submit: false })
|
||||
terminal.output.emit('data', 'plain text')
|
||||
@@ -691,6 +764,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
.onTransportFailure(new Error('later failure'))
|
||||
await expect(operation.done).rejects.toThrow('output transport failed')
|
||||
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
|
||||
terminal.terminateError = undefined
|
||||
await expect(session.close('transport')).rejects.toThrow('output transport failed')
|
||||
|
||||
const rejectedTerminal = new FakeTerminal()
|
||||
@@ -700,19 +774,21 @@ describe('LocalPtySession readiness and output', () => {
|
||||
await expect(rejectedOperation.done).rejects.toThrow('raw transport failure')
|
||||
})
|
||||
|
||||
it('rejects invalid UTF-8 in a data chunk and at stream end', async () => {
|
||||
it('replaces invalid UTF-8 terminal output', async () => {
|
||||
const chunkTerminal = new FakeTerminal()
|
||||
const chunkSession = new LocalPtySession(chunkTerminal, config())
|
||||
const chunkOperation = chunkSession.startSend({ text: '', submit: false })
|
||||
chunkTerminal.emitBytes(Uint8Array.from([0xff]))
|
||||
await expect(chunkOperation.done).rejects.toThrow('PTY emitted invalid UTF-8')
|
||||
expect(chunkOperation.readOutput()).toEqual({ delta: '<27>', truncated: false })
|
||||
chunkTerminal.emitExit()
|
||||
await chunkOperation.done
|
||||
|
||||
const endTerminal = new FakeTerminal()
|
||||
const endSession = new LocalPtySession(endTerminal, config())
|
||||
const endOperation = endSession.startSend({ text: '', submit: false })
|
||||
endTerminal.emitBytes(Uint8Array.from([0xe2]))
|
||||
endTerminal.emitExit()
|
||||
await expect(endOperation.done).rejects.toThrow('PTY ended with invalid UTF-8')
|
||||
expect((await endOperation.done).viewport).toBe('<EFBFBD>')
|
||||
})
|
||||
|
||||
it('contains readiness inspection failure and a stale inspection result', async () => {
|
||||
@@ -952,43 +1028,32 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
await expect(session.signal('SIGTERM')).rejects.toThrow('cannot resolve')
|
||||
})
|
||||
|
||||
it('drains an in-flight public signal and rejects signals after close starts', async () => {
|
||||
it('closes idempotently and rejects new signals', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
const signal = Promise.withResolvers<number>()
|
||||
terminal.signalForeground = async () => await signal.promise
|
||||
|
||||
const signaling = session.signal('SIGINT')
|
||||
const closing = session.close('public signal')
|
||||
let closed = false
|
||||
void closing.then(() => { closed = true })
|
||||
await Promise.resolve()
|
||||
expect(closed).toBe(false)
|
||||
await expect(session.signal('SIGTERM')).rejects.toThrow('closing')
|
||||
|
||||
signal.resolve(456)
|
||||
await expect(signaling).resolves.toEqual({ delivered: true, targetPgid: 456 })
|
||||
await closing
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
|
||||
it('closes idempotently, contains signal races, and reports survivors', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.quiescent = false
|
||||
terminal.throwKill = true
|
||||
const session = new LocalPtySession(terminal, config({ disposeGraceMs: 1 }))
|
||||
const closing = session.close('test')
|
||||
expect(session.close('other')).toBe(closing)
|
||||
await expect(closing).rejects.toThrow('did not reach quiescence')
|
||||
await expect(closing).rejects.toThrow('PTY cleanup failed (test)')
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
|
||||
await expect(session.signal('SIGTERM')).rejects.toThrow('closing')
|
||||
})
|
||||
|
||||
it('reports cleanup failure without waiting for top-level exit', async () => {
|
||||
it('reports cleanup failure without waiting for top-level exit and permits retry', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.autoExitOnKill = false
|
||||
terminal.waitError = new Error('terminal cleanup failed; surviving pids: 456')
|
||||
terminal.terminateError = new Error('terminal cleanup failed; surviving pids: 456')
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
|
||||
await expect(session.close('survivor')).rejects.toThrow('surviving pids: 456')
|
||||
await expect(session.close('survivor')).rejects.toMatchObject({
|
||||
message: 'PTY cleanup failed (survivor)',
|
||||
cause: terminal.terminateError,
|
||||
})
|
||||
expect(terminal.kills).toEqual([])
|
||||
|
||||
terminal.terminateError = undefined
|
||||
terminal.autoExitOnKill = true
|
||||
await expect(session.close('retry')).resolves.toBeUndefined()
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
@@ -1010,73 +1075,25 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
await closing
|
||||
})
|
||||
|
||||
it('does not finish close while a pre-write terminal operation is pending', async () => {
|
||||
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 inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||||
terminal.inspectForeground = async () => await inspection.promise
|
||||
const operation = session.startSend({ text: 'must not run', submit: true })
|
||||
const closing = session.close('pending inspection')
|
||||
let closed = false
|
||||
void closing.then(() => { closed = true })
|
||||
const terminate = terminal.terminate.bind(terminal)
|
||||
terminal.terminate = async () => {
|
||||
inspection.reject(new Error('terminal terminated'))
|
||||
await terminate()
|
||||
}
|
||||
const operation = session.startSend({ text: 'pending inspection', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(closed).toBe(false)
|
||||
|
||||
inspection.resolve({ processGroupId: 456, inputWaiting: false })
|
||||
await closing
|
||||
expect(closed).toBe(true)
|
||||
await session.close('pending inspection')
|
||||
|
||||
expect((await operation.done).waitReason).toBe('session_exit')
|
||||
expect(terminal.writes).toEqual([])
|
||||
expect((await operation.done).waitReason).toBe('session_exit')
|
||||
})
|
||||
|
||||
it('does not finish close while a terminal write is pending', 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 operation = session.startSend({ text: 'pending write', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const closing = session.close('pending write')
|
||||
let closed = false
|
||||
void closing.then(() => { closed = true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(closed).toBe(false)
|
||||
|
||||
write.resolve(undefined)
|
||||
await closing
|
||||
expect(closed).toBe(true)
|
||||
expect((await operation.done).waitReason).toBe('session_exit')
|
||||
})
|
||||
|
||||
it('detaches active cancellation before draining terminal operations', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||||
terminal.inspectForeground = async () => await inspection.promise
|
||||
const signalForeground = vi.spyOn(terminal, 'signalForeground')
|
||||
const controller = new AbortController()
|
||||
const operation = session.startSend({ text: 'pending inspection', submit: true, signal: controller.signal })
|
||||
const closing = session.close('pending cancellation')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
controller.abort('late cancellation')
|
||||
expect(signalForeground).not.toHaveBeenCalled()
|
||||
inspection.resolve({ processGroupId: 456, inputWaiting: false })
|
||||
await closing
|
||||
expect((await operation.done).waitReason).toBe('session_exit')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user