Преглед изворни кода

fix(pty): fence delayed signal prompts

Tianyi Cui пре 2 месеци
родитељ
комит
7eedfda961

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml

@@ -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 .agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md
-2026-07-28-portable-execution-world-consumers.md: 715529e5f24df0c2fe0afc40716737285afa223b
-2026-07-28-portable-execution-world-consumers.zh.md: a9d864b581bd546c12776152ad4515816ee6b83a
+2026-07-28-portable-execution-world-consumers.md: 54b8ec19bc74e734f2cb940a475d2c7b4c9336fd
+2026-07-28-portable-execution-world-consumers.zh.md: f21be00fd35267fe93e2fbb62642809cc42c0742

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md

@@ -22,7 +22,7 @@ Generic consumers use that execution world:
 
 - `dsh-bash-local` continues to map Bash semantics onto ordinary `ctx.subprocess.spawn()`.
 - `dsh-lsp-local` reads and contains source through `ctx.fs`, resolves and launches language servers through `ctx.subprocess`, and carries provider-owned file URIs through initialization and result rendering. Its JSON-RPC, pooling, synchronization, cancellation, and normalization stay unchanged.
-- `dsh-pty-local` maps persistent-shell semantics onto `ctx.subprocess.spawnTerminal()`. The local `node-pty` and process-inspection implementation moves into `dsh-subprocess-local`; another subprocess provider supplies the same primitive. The allocation signal is detached before publication, while readiness initialization retains setup cancellation. Prompt and silence evidence collected during asynchronous pre-write inspection is discarded when the provider write begins. Cancellation retains the send reservation until asynchronous foreground signalling settles, so the signal cannot target a successor; a timed-out asynchronous write, or a signal failure during that write, retains the reservation until the provider settles the write. Completion of a stale inspection resumes polling for the current send.
+- `dsh-pty-local` maps persistent-shell semantics onto `ctx.subprocess.spawnTerminal()`. The local `node-pty` and process-inspection implementation moves into `dsh-subprocess-local`; another subprocess provider supplies the same primitive. The allocation signal is detached before publication, while readiness initialization retains setup cancellation. Prompt and silence evidence collected during asynchronous pre-write inspection is discarded when the provider write begins. Cancellation retains the send reservation until asynchronous foreground signalling settles, so the signal cannot target a successor; if a signaled send returns `inferred_idle` before its prompt arrives, that marker remains attributed to the prior send instead of settling a successor after its echo. A timed-out asynchronous write, or a signal failure during that write, retains the reservation until the provider settles the write. Completion of a stale inspection resumes polling for the current send.
 - `dsh-code-runtime-subprocess` materializes a dependency-free runner through `ctx.fs` and launches it through `ctx.subprocess`, preserving the Code Runtime binding and output contract across local or remote worlds. It shares host-side worker mechanics through the non-plugin `dsh-code-runtime-worker/runtime-host` subpath instead of copying them. Preparation carries one lifecycle signal through filesystem resolution, materialization, and executable lookup so disposal can abort a stalled provider. The heap-bounded worker rejects oversized binding frames before transfer, each outer hop enforces the same bound before serialization, and the launcher publishes an accepted terminal frame before reaping its controller so a descendant that inherits controller pipes cannot suppress completion; the host still awaits process-group quiescence.
 
 `dsh-code-runtime-worker` remains a separate implementation. It is the smaller in-process backend and works in single-file distributions that cannot assume an installed Node executable. Remote filesystem/process compositions select `dsh-code-runtime-subprocess`; they do not need a provider-specific Code Runtime package.

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md

@@ -22,7 +22,7 @@ Status: implemented
 
 - `dsh-bash-local` 继续把 Bash 语义映射到普通的 `ctx.subprocess.spawn()`。
 - `dsh-lsp-local` 通过 `ctx.fs` 读取源文件并验证包含关系,通过 `ctx.subprocess` 解析和启动语言服务器,并让由提供方负责的文件 URI 贯穿初始化与结果渲染。其 JSON-RPC、池化、同步、取消和规范化保持不变。
-- `dsh-pty-local` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。分配信号会在发布前解除关联,而就绪初始化仍保留设置阶段的取消。提供方开始写入时,系统会丢弃异步写入前检查期间收集的提示符与静默证据。取消会保留发送预留,直至异步前台信号发送结算,从而使该信号无法把后续发送作为目标;异步写入超时,或该写入期间发生信号发送失败,都会保留预留,直至提供方将写入结算。陈旧检查完成后,会针对当前发送恢复轮询。
+- `dsh-pty-local` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。分配信号会在发布前解除关联,而就绪初始化仍保留设置阶段的取消。提供方开始写入时,系统会丢弃异步写入前检查期间收集的提示符与静默证据。取消会保留发送预留,直至异步前台信号发送结算,从而使该信号无法把后续发送作为目标;如果收到信号的 send 在其提示符到达前返回 `inferred_idle`,随后到达的标记仍归属于先前的 send,而不会在后续 send 回显后使其完成。异步写入超时,或该写入期间发生信号发送失败,都会保留预留,直至提供方将写入结算。陈旧检查完成后,会针对当前发送恢复轮询。
 - `dsh-code-runtime-subprocess` 通过 `ctx.fs` 物化无依赖 runner,并通过 `ctx.subprocess` 启动它,从而在本地或远程执行世界中保留代码运行时的绑定与输出契约。它通过非插件子路径 `dsh-code-runtime-worker/runtime-host` 共享宿主侧 worker 机制,而不是复制这些机制。准备阶段让同一个生命周期信号贯穿文件系统解析、物化和可执行文件查找,使资源释放能够中止停滞的提供方操作。受堆上限约束的 worker 会在传输前拒绝过大的绑定帧;每个外层转发环节都会在序列化前执行相同的上限检查;launcher 会在回收 controller 前发布已接纳的终态帧,使继承 controller 管道的后代进程无法阻止完成;宿主仍会等待进程组完全停稳。
 
 `dsh-code-runtime-worker` 仍是独立实现。它是较小的进程内后端,可用于无法假定已安装 Node 可执行文件的单文件分发。远程文件系统/进程组合选择 `dsh-code-runtime-subprocess`;它们不需要提供方专用的代码运行时包。

+ 2 - 2
packages/pty/pty-local/README.i18n.yaml

@@ -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: 7e31adfbc486a190252be867e2113948b17e7924
-README.zh.md: eb628aef9ad6b0fea14a5e833197b4fac7856785
+README.md: c7b7a363f22821363a0fed81982165fd35326d12
+README.zh.md: 859b94e4f29b82abb298468f028f9eba7cc067e6

Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
packages/pty/pty-local/README.md


Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
packages/pty/pty-local/README.zh.md


+ 15 - 1
packages/pty/pty-local/src/session.ts

@@ -166,6 +166,7 @@ export class LocalPtySession implements PtyBackendSession {
   private activeDeadlineTimer: NodeJS.Timeout | undefined
   private activeAbort: (() => void) | undefined
   private readonly terminalOperations = new Set<Promise<void>>()
+  private signaledOperation: LocalSendOperation | undefined
   private interrupting: LocalSendOperation | undefined
   private writing: LocalSendOperation | undefined
   private pollingReady: LocalSendOperation | undefined
@@ -173,6 +174,7 @@ export class LocalPtySession implements PtyBackendSession {
   private promptSeen = false
   private promptTextSeen = false
   private promptTail = ''
+  private delayedSignaledPrompt = false
   private shellPgid: number | undefined
   private initializing = false
   private lastOutputAt = Date.now()
@@ -317,6 +319,7 @@ export class LocalPtySession implements PtyBackendSession {
   }
 
   async signal(signal: PtySignal): Promise<PtySignalResult> {
+    if (this.active !== undefined) this.signaledOperation = this.active
     const targetPgid = await this.terminal.signalForeground(signal)
     return { delivered: true, targetPgid }
   }
@@ -365,7 +368,9 @@ export class LocalPtySession implements PtyBackendSession {
   private onData(data: string): void {
     const sanitized = this.sanitizer.push(data)
     this.appendOutput(sanitized.text)
-    if (sanitized.prompt) {
+    if (sanitized.prompt && this.delayedSignaledPrompt) {
+      this.delayedSignaledPrompt = false
+    } else if (sanitized.prompt) {
       // 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.
@@ -461,6 +466,13 @@ 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()
@@ -486,6 +498,7 @@ 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
@@ -506,6 +519,7 @@ export class LocalPtySession implements PtyBackendSession {
 
   private interrupt(operation: LocalSendOperation): void {
     if (this.active !== operation) return
+    this.signaledOperation = operation
     this.interrupting = operation
     this.stopPolling()
     this.ownTerminalOperation(this.interruptOnce(operation))

+ 29 - 0
packages/pty/pty-local/tests/session.spec.ts

@@ -591,6 +591,35 @@ 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()

Неке датотеке нису приказане због велике количине промена