qwen-code/packages
Shaojin Wen 0240c310fd
feat(core): PR-2.5 — post-promote stream redirect + natural-exit registry settle (#3831 follow-up) (#4102)
* feat(core): PR-2.5 — post-promote stream redirect + natural-exit registry settle

Closes the two limitations PR-2 (#3894) deferred for the Phase D part
(b) Ctrl+B promote flow (#3831):

1. **Post-promote stream redirect**: today the `bg_xxx.output` file
   is frozen at promote time because `ShellExecutionService` detaches
   its data listener as part of PR-1's ownership-transfer contract.
   PR-2.5 wires a caller-side `onPostPromoteData` callback so bytes
   from the still-running child append to the file via an
   `fs.createWriteStream` opened in `handlePromotedForeground`.
2. **Natural-exit registry settle**: today the registry entry stays
   `'running'` until `task_stop` / session-end `abortAll` fires its
   abort listener. PR-2.5 wires `onPostPromoteSettle` so natural
   child exit transitions the entry to `'completed'` / `'failed'`
   with the right exitCode / signal / error message.

- New exported types: `ShellExecuteOptions`, `ShellPostPromoteHandlers`,
  `ShellPostPromoteSettleInfo`.
- `execute()` options bag now accepts `postPromote?: { onData, onSettle }`.
  Threaded through to both `executeWithPty` and `childProcessFallback`.
- PTY's `performBackgroundPromote` (line ~1159): after disposing
  the foreground data + exit + error listeners, RE-ATTACH minimal
  forwarders that call `postPromote.onData` / `postPromote.onSettle`
  when the caller opted in. Backwards compat: when `postPromote` is
  unset the PR-2 detach-everything contract is preserved (the
  re-attach is gated on each callback being defined).
- `childProcessFallback`'s `performBackgroundPromote` (line ~706):
  same pattern — re-attach `stdout.on('data', ...)`, `stderr.on('data',
  ...)`, `child.once('exit', ...)`, `child.once('error', ...)` when
  the caller opted in. `error` listener routes through `onSettle`
  with `error` populated, so spawn-side errors after the foreground
  errorHandler detached don't crash the daemon via the default
  unhandled `'error'` event.
- Both paths wrap caller callbacks in try/catch so a thrown handler
  doesn't crash the child's data loop / unhandled-rejection the
  service.

- New `PromoteArtifacts` type — slots shared between the foreground
  `execute()` postPromote handlers (which fire on the service side
  as soon as promote happens) and the post-resolve
  `handlePromotedForeground` finalizer (which runs after
  `await resultPromise` returns). The two race; the buffer +
  settle-queue absorb that race so neither chunks nor the eventual
  exit info are lost.
- `executeForeground` wires `postPromote` handlers that route data
  to either `promoteArtifacts.stream` (if open) or
  `promoteArtifacts.buffer` (drained when the stream opens), and
  queue settle info if the wired handler isn't yet installed.
- `handlePromotedForeground` opens `fs.createWriteStream(outputPath,
  { flags: 'w' })`, writes the initial snapshot first, drains the
  buffer, then registers the entry and wires `onSettleWired` with
  the full registry decision table:
    - `error` set → `registry.fail(shellId, error.message, endTime)`
    - `exitCode === 0` → `registry.complete(shellId, 0, endTime)`
    - non-zero exitCode → `registry.fail(shellId, "Exited with code N", endTime)`
    - signal !== null → `registry.fail(shellId, "Terminated by signal N", endTime)`
    - all-null fallback → `registry.fail(shellId, "Exited with unknown status", endTime)`
- Fires queued settle synchronously after wiring so a fast command
  that exits between promote and finalizer doesn't get lost.
- Self-audit catch: closes the output stream on the
  `registry.register` throw path so the FD doesn't leak past the
  orphan-child kill.

- 3 new in `shellExecutionService.test.ts`:
  - `post-promote bytes route to postPromote.onData when callback provided`
  - `postPromote.onSettle fires on natural child exit after promote`
  - `backwards compat: without postPromote, listeners stay fully detached`
- 3 new in `shell.test.ts` under a `foreground → background promote
  PR-2.5` describe block:
  - `post-promote bytes APPEND to bg_xxx.output via write stream`
  - `natural child exit transitions registry entry to "completed"`
  - `non-zero exit / signal / error → "failed" with descriptive message`
- Bulk-replaced 50 prior `{},` (empty 6th-arg shellExecutionConfig)
  with `expect.objectContaining({}),` + added `expect.objectContaining({
  postPromote: expect.any(Object) }),` as the 7th-arg expectation for
  the foreground execute call.
- Updated the existing `registers a bg_xxx entry on result.promoted`
  test to assert on `fs.createWriteStream` + `stream.write` instead
  of the now-removed `fs.writeFileSync` snapshot path.

182/182 shell.test.ts pass + 73/73 shellExecutionService.test.ts pass
+ 111/111 coreToolScheduler.test.ts pass + 60/60 AppContainer.test.tsx
pass; tsc + ESLint clean.

Self-audit: 3 rounds (positive / reverse / cross-file) found one
issue — output stream FD leak on `registry.register` throw — and
fixed it before flagging complete. All flagged edge cases (stream
errors, child-exits-before-wire-up race, task_stop during natural-
exit window, promote-never-happens cleanup, backwards compat
without callbacks) have explicit handling and / or test pinning.

* fix(core): #4102 review wave — 3 Critical + UTF-8 + tests

3 Critical race/correctness issues + 1 multibyte-corruption suggestion
+ 3 test coverage gaps addressed:

**Critical 1 — child_process late-chunk drop (service)**
Settle was fired on 'exit', but stdout/stderr can emit buffered data
between 'exit' and 'close'. Late chunks landed in
`promoteArtifacts.buffer` after shell.ts had already closed the
stream + transitioned the registry → silently dropped → truncated
`bg_xxx.output`. Switched to listening on 'close' which guarantees
all stdio is fully drained. (code, signal) payload is identical to
'exit', just with proper ordering.

**Critical 2 — stream-flush wait before registry transition (shell)**
`stream.end()` is asynchronous; pending writes can still be in the
libuv queue when it returns. The old code transitioned the registry
immediately after `.end()`, so a /tasks consumer could observe a
`completed` entry and read the output file BEFORE the trailing
bytes were on disk. Fixed: wired settle now `stream.once('finish',
...)` BEFORE calling `registry.complete/fail`. `error` event also
short-circuits to the transition so a late ENOSPC doesn't hang the
settle path forever.

**Critical 3 — stream-open-fail buffer leak (shell)**
If `fs.createWriteStream` threw, the catch path set `stream = null`
but the foreground `onData` handler would still take the
`stream === null` branch and push chunks into `promoteArtifacts.buffer`
— unbounded growth under a sustained child whose output file
couldn't be opened. Added a `streamFailed: boolean` latch on
`PromoteArtifacts`. When set, `onData` drops chunks (with a debug
log) instead of buffering. The catch branch sets the latch.

**Suggestion — shared TextDecoder corrupts multibyte UTF-8 (service)**
child_process post-promote used ONE TextDecoder for both stdout AND
stderr. The decoder's continuation-byte state machine assumes one
byte source; interleaved multibyte chunks corrupted. Now uses
separate decoders + flushes both with `decode()` (no `stream: true`)
on settle so trailing bytes surface as their final characters.

**Suggestion — llmContent reflects already-settled status (shell)**
When the queued-settle drain transitions the registry synchronously
(fast-exit race), the model-facing copy was still saying "Status:
running. … task_stop({...})". Updated to branch on
`postPromoteAlreadySettled` / `postPromoteFinalStatus` — when the
process is already gone, the copy says "Status: completed/failed"
and replaces the `task_stop` suggestion with "Process has already
exited; no `task_stop` needed".

**Suggestion — test coverage gaps**
Added: (a) `queued-settle race: onSettle BEFORE
handlePromotedForeground completes` — custom service impl fires
onSettle synchronously before resolving the promote promise, pins
the drain path. (b) child_process post-promote tests for stdout/stderr
forwarding + 'close'-not-'exit' settle + spawn-error settle.

**Self-audit**: Round 1 + reverse audit. Stream.once mock added to
fire 'finish' synchronously so existing tests don't hang on the new
flush wait. 76/76 shellExecutionService.test.ts (+3) + 183/183
shell.test.ts (+1) pass; tsc + ESLint clean.

* fix(core): #4102 review wave-2 — 3 more

C1 (shell.ts:2227): the WriteStream `'error'` event handler only
logged. `fs.createWriteStream` reports common open failures
(ENOENT / EACCES / ENOSPC) asynchronously via that event rather
than throwing. Result: `promoteArtifacts.stream` kept pointing at
the failed stream; `onSettleWired` attached a `.once('finish')`
listener that would never fire → registry stuck on `running`
forever. Latch the failure (null the shared `stream` slot,
set `streamFailed`); `onSettleWired`'s existing `if (!stream)`
branch then transitions the registry immediately.

C2 (shellExecutionService.ts:1468): the promote handoff removes the
foreground `ptyErrorHandler` and only re-attaches data + exit
listeners. A subsequent PTY `error` event had no listener — Node
treats an unhandled `error` from an EventEmitter as a fatal
exception that takes the whole CLI down. Attach a post-promote
forwarder that ignores expected PTY read-exit codes (EIO / EAGAIN,
same filter the foreground handler uses) and routes unexpected
errors through `postPromote.onSettle` with `error` populated.
Single-fire latch shared with `onExit` so settle never fires twice.

C3 (shell.ts:2503): `onSettleWired` waits for the stream's
asynchronous `'finish'` event before flipping
`postPromoteAlreadySettled`, but the model-facing `statusLine` was
built immediately after invoking `onSettleWired` on the queued
settle. A fast-exited promoted command could therefore land
"Status: running" + a `task_stop` instruction in production even
though settle was already observed. Split into two flags:
`postPromoteSettleObserved` (set synchronously when settle is
classified) drives the model copy; the registry transition stays
behind the stream flush.

Tests: +1 PR-2.5 wave-2 PTY error-routing test; +2 shell.ts tests
(stream open async error → registry still transitions; async
`'finish'` after queued-settle drain → llmContent says 'completed'
before registry transition fires).

* fix(core): #4102 review wave-3 — 4 actionable

T2 (shell.ts:2456) — Critical buffer-leak race
`onSettleWired` previously set `promoteArtifacts.stream = null`
BEFORE calling `stream.end()`. Any `postPromote.onData` chunk that
landed between that null assignment and the actual flush completing
saw `stream === null && streamFailed === false` and pushed into
`promoteArtifacts.buffer` — a buffer that has no further drain path
(the foreground finalizer has already returned). Result: chunks
stranded indefinitely; PTY mode in particular hits this because
`onExit` can fire while kernel buffers still hold data. Fix drains
the pre-settle buffer to the stream BEFORE nulling AND latches
`streamFailed = true` so any subsequent chunk drops via the
existing `else if (streamFailed)` arm in `onData` instead of
leaking. Updates the `streamFailed` doc to cover both setters
(open-fail and settle-done) so the dual semantic is explicit.

T3 (shell.ts:2262) — silent chunk-drop in catch path
When `fs.createWriteStream` throws synchronously (rare: ENOENT on
a vanished tmpdir), chunks already in `promoteArtifacts.buffer`
were silently lost with no observability — oncall reading a
truncated `bg_xxx.output` had no way to distinguish "stream open
failed" from "child produced nothing." Logs the dropped chunk
count and empties the buffer.

T5 (shell.ts:2443) — opaque all-null fallback
The "Exited with unknown status" fallback fired the registry to
'failed' without any context about which fields were null. This
branch is meant to be unreachable; hitting it indicates the
service emitted a defective settle info object. Includes the
field values in both the fail message and a warn log so the
oncall engineer can tell this path apart from the other "failed"
branches.

T6 (shellExecutionService.ts:1452) — leaked PTY post-promote listeners
`ptyProcess.onData(...)` returns an `IDisposable` that was being
discarded; same for `onExit`. The `'error'` listener function was
also not captured (no way to `removeListener` it). EventEmitter
holds refs to listener closures, which transitively hold refs to
`onPostData` / `onPostSettle` / the caller's `promoteArtifacts`.
While bounded by the PTY's lifetime, the closures keep the
caller's state pinned for the post-settle delay window. Captures
all three handles into `postPromoteDataDisposable` /
`postPromoteExitDisposable` / `postPromoteErrorListener`, then
releases them via a shared `disposePostPromoteListeners()` call
from `firePostSettle` (idempotent — each slot null-checked and
nulled after disposal).

Tests: +1 service test for IDisposable + error-listener cleanup;
+2 shell.ts tests for buffer drain race and catch-path snapshot
fallback. Existing tests stay green (262 → 265 in the touched
suites; 7819 → 7822 across the core package).

* fix(core/test): drop unused 'registry' in wave-3 T2 test (TS6133)

CI build failed across all platforms with src/tools/shell.test.ts(4395,15): error TS6133. The variable was a leftover from copying the queued-settle test pattern; the wave-3 T2 test inspects writeStreamMock.write call history directly and never reads the registry, so the assignment is dead code. Drop it.

* fix(core): #4102 review wave-4 — 6 actionable

T1 (Critical, shellExecutionService.ts:860 child_process onSettle
exactly-once)
The PTY path used a `firePostSettle` latch but child_process wired
`close` and `error` independently to `onPostSettle`. A spawn-side
error followed by Node's auto-emitted `'close'` would call the
caller's settle TWICE, racing the registry transition. Added the
same single-fire latch on the child_process path.

T2 (Critical, shell.ts:2264 handoff race reorder)
Original order was `write(snapshot) -> drain buffer -> assign stream`.
Synchronous today (no race in current code), but assign-after-drain
leaves a hazard for any future refactor that adds an `await` inside
the drain loop — a chunk arriving in that window would land in
`promoteArtifacts.buffer`, then post-assign chunks would write to
the stream first, producing out-of-order bytes until the settle
drain. Reordered to `write(snapshot) -> assign stream -> drain
buffer`, which closes the hazard regardless of future async
additions.

T3 (Suggestion, shellExecutionService.ts:816 decoder flush gated
on onSettle)
The trailing-multibyte flush ran inside the `child.once('close', ...)`
handler, which was only installed when `onSettle` was set. An
`onData`-only caller (no onSettle) lost trailing continuation
bytes silently. Hoisted flush into `flushPostPromoteDecoders`
called from `firePostSettle`, and made `firePostSettle` available
on the `'close'` path independent of onSettle (T6 install).

T4 (Suggestion, shell.ts:1700 promoted ANSI passthrough)
The regular `executeBackground` path strips ANSI before writing to
`bg_xxx.output`; the promoted-foreground onData path appended raw
chunks. Reading `bg_xxx.output` after Ctrl+B showed plain text up
to the snapshot then raw `\x1b[31m` / cursor-move / clear-screen
sequences for the post-promote tail — unreadable. Apply
`stripAnsi(rawChunk)` before write/buffer, matching the
executeBackground contract.

T5 (Suggestion, shellExecutionService.ts:786 UTF-8 hardcoded)
The post-promote child_process decoders were hard-coded to
`new TextDecoder('utf-8')`, but the foreground decoder runs
encoding detection via `getCachedEncodingForBuffer`. On a non-UTF-8
child (e.g. GBK on a Chinese Windows shell), the snapshot decoded
correctly but the post-promote tail was mojibake. Capture the
foreground decoder's `.encoding` property and reuse it for
post-promote (with utf-8 fallback if foreground hadn't seen any
bytes yet, and a try/catch around `new TextDecoder` for the rare
unsupported-encoding case).

T6 (Suggestion, shellExecutionService.ts:1540 `error` listener
gated on onSettle)
The post-promote `error` listener was attached only when `onSettle`
was set. An `onData`-only caller still had the foreground
errorHandler detached; a post-promote spawn error would then crash
the CLI via Node's unhandled-error default. Hoisted the close +
error listeners into `if (postPromote)` so any caller opting into
post-promote gets crash protection; if `onSettle` is absent the
listeners log + drop instead of routing.

T7 (Suggestion, shellExecutionService.ts:791 onSettle-only
pipe-block deadlock)
Same root cause as T6: when only `onSettle` is set, the foreground
`stdout`/`stderr` 'data' listeners are detached and no post-promote
listener replaces them. The Readables stay paused, the OS pipe
buffer fills (~64KB on Linux), the child blocks on `stdout.write`,
'close' never fires, onSettle never fires. Added `child.stdout?.resume()`
and `child.stderr?.resume()` in the no-onData branch so the child
can drain its pipes and reach exit.

T8 (Suggestion, shell.ts:2614 dead inspectLine ternary)
`inspectLine`'s ternary returned the same string on both sides —
copy-paste leftover from when the other two adjacent ternaries
(statusLine / stopLine) were correctly varied. Collapsed to a
single string assignment.

Tests: +5 regression tests (4 child_process: T1 double-fire latch,
T3 onData-only flush, T6 onData-only error survives, T7 onSettle-
only resume; +1 shell.ts: T4 ANSI strip).

265 -> 270 in the touched suites; 7822 -> 7827 across the core
package; full suite green.

* fix(core/test): use ShellOutputEvent type in wave-4 onData callbacks (TS2345)

CI lint failed on the wave-4 (T3 / T6) tests with TS2345: pushing
ShellOutputEvent into Array<{type:string;chunk:unknown}> narrows
incompatibly. Switch to ShellOutputEvent[] (matches earlier helpers
at lines 758/966) and discriminate the union via .type === 'data'
when reading .chunk so the narrowed multibyte assertion still
type-checks.

* fix(core): address PR #4102 review — PTY error guard, flush timeout, diagnostic marker, failed-settle test

- Move PTY post-promote error listener from `if (postPromote?.onSettle)` to
  `if (postPromote)` to match child_process path and prevent unhandled error
  crashes for onData-only callers
- Add 10s flush timeout in onSettleWired so stalled streams don't leave
  registry entries stuck on 'running' forever
- Append diagnostic marker to output file on stream error so truncation
  is visible without debug logging
- Add queued-settle test with exitCode:1 asserting 'Status: failed.' in
  llmContent

* fix(core): address PR #4102 review — align PTY/child_process guards, add flush timeout, diagnostic marker, and tests

- Widen PTY post-promote onExit + error listener guard from
  `if (postPromote?.onSettle)` to `if (postPromote)` to match
  child_process path — prevents unhandled error crash and listener
  leak for onData-only callers
- Add 10s flush timeout in onSettleWired so stalled streams don't
  leave registry entries stuck on 'running' indefinitely
- Append diagnostic marker to output file on stream error so
  truncation is visible without debug logging
- Remove model name references from code comments
- Add tests: PTY onData-only error/exit, flush timeout fallback,
  appendFileSync diagnostic marker, queued-settle with failed exit code

* fix(core): address PR #4102 review round 2 — listener cleanup, rename, constant hoist

- Fix expect.objectContaining({}) misused as runtime arg in 2 execute() call sites
- Add child_process post-promote stdout/stderr listener cleanup in firePostSettle
- Rename streamFailed → streamClosed to reflect its overloaded semantics
- Hoist FLUSH_TIMEOUT_MS to module-level PROMOTE_FLUSH_TIMEOUT_MS constant
- Fix dangling FLUSH_TIMEOUT_MS reference (was undefined at runtime)
- Add Windows note to streams pause/resume comment
- Document PTY onData dispose-before-settle as known limitation
2026-05-17 17:57:08 +08:00
..
channels chore(release): v0.15.11 [skip ci] 2026-05-14 09:51:46 +08:00
cli feat(serve): add session-scoped permission route (#4232) 2026-05-17 17:48:30 +08:00
core feat(core): PR-2.5 — post-promote stream redirect + natural-exit registry settle (#3831 follow-up) (#4102) 2026-05-17 17:57:08 +08:00
sdk-java fix(sdk-java): pass custom env to CLI process (#3543) 2026-04-24 10:37:52 +08:00
sdk-python doc[sdk-python] Expand Python SDK usage documentation (#3995) 2026-05-12 15:27:00 +08:00
sdk-typescript feat(serve): add session-scoped permission route (#4232) 2026-05-17 17:48:30 +08:00
vscode-ide-companion Add stop hook blocking cap (#4208) 2026-05-17 06:52:56 +08:00
web-templates chore(release): v0.15.11 [skip ci] 2026-05-14 09:51:46 +08:00
webui chore(release): v0.15.11 [skip ci] 2026-05-14 09:51:46 +08:00
zed-extension chore(deps): upgrade ink 6.2.3 → 7.0.2 + bump Node engine to 22 (#3860) 2026-05-11 17:29:50 +08:00