fix(core): address @tanzhenxin's PR-1 review notes (post-merge follow-up to #3842) (#3886)

* fix(core): wrap hasOwnProperty.call inside try + add post-abort PTY data assertion

Two non-blocking review notes from @tanzhenxin's PR-1 approval
(#3842, post-merge follow-up):

- **Note 2 (real bug)**: `getShellAbortReasonKind` had
  `Object.prototype.hasOwnProperty.call(reason, 'kind')` outside the
  try/catch. `hasOwnProperty.call` triggers the `[[GetOwnProperty]]`
  Proxy trap (`getOwnPropertyDescriptor` handler). A Proxy whose
  `getOwnPropertyDescriptor` throws — separate from a throwing `get`
  trap, which the prior commit already covered — would propagate past
  the helper, leaving the abort handler's switch on `kind` to throw
  through `addEventListener` (which doesn't await async listener
  return values), so the shell process would stay alive instead of
  being killed on cancel. Moved the descriptor probe inside the same
  try block as the value read.

  My own multi-round audit covered six attack vectors but missed this
  one — only `get` trap throws were considered. The reviewer caught
  it on first pass.

- **Note 3 (test parity)**: the PTY post-promotion handoff test
  asserted `dispose` was called but never re-invoked the data
  callback to verify the foreground `onOutputEvent` actually stops
  firing — the child_process equivalent has that assertion. Mirrored
  it: emit data AFTER abort by re-invoking the captured `dataCallback`
  reference, and assert `onOutputEventMock.mock.calls.length` does NOT
  increase past the moment of promote. Exercises the production
  `listenersDetached` guard inside the chain callback, which the
  bare dispose-was-called check didn't.

Note 1 from the same review (the `aborted: true + promoted: true`
shape forcing PR-2 callers to check `promoted` before `aborted`) is
deliberately NOT addressed here — it's a contract simplification that
affects PR-2's branching, so it belongs in PR-2 along with the
caller-side decision on whether to flip `aborted` for promoted
results. Added a TODO upstream in #3831 (PR-2 design) to track.

70 / 70 tests pass (69 baseline + 1 new helper boundary for the
throwing-getOwnPropertyDescriptor case). tsc + ESLint clean.

* test(core): fix tautological PTY post-promote assertion (audit follow-up)

The PTY post-promotion handoff test added in the previous commit
copied the child_process equivalent's pattern verbatim — sync
\`expect(count).toBe(countAtPromote)\` immediately after dataCallback
returns. That works for child_process because its \`handleOutput\` is
fully synchronous (sniff → decoder → emit, all on the same call
stack), so the count change happens BEFORE the assertion.

PTY's \`handleOutput\` is async — \`processingChain.then(...)\` queues a
microtask that does the sniff + write + render-then-emit work. The
sync assertion captures both \`countAtPromote\` and the post-emit count
BEFORE the chain microtask ever runs, so both reads return whatever
happened before the assert (typically 0). The test would
tautologically pass even if the production \`listenersDetached\` guard
were removed — i.e., it didn't actually verify the guard.

Restructured to:
1. Drive the PTY through \`simulateExecution\` so \`await handle.result\`
   forces all queued microtasks (including pre-promote chain items
   AND the abort handler's drain) to settle.
2. Capture \`eventCountAfterSettle\` once everything has stabilized.
3. Re-invoke the captured \`dataCallback\` with post-promote data,
   await two more macrotask boundaries to let the new chain item
   fully run.
4. Assert the count hasn't moved.

If the production \`listenersDetached\` guard is removed, the
post-promote chain item emits, count increases past
\`eventCountAfterSettle\`, and this assertion fails. So the test
actually exercises the guard now.

Found in self-audit while reviewing my own follow-up commit. Caught
because audit was paranoid about *whether the test verifies what it
claims to verify*, not just whether it passes.

70 / 70 tests pass; tsc + ESLint clean.

* test(core): pin eventCountAfterSettle === 0 in PTY post-promote test

The previous fix asserted post-promote count equals the
\`eventCountAfterSettle\` baseline, but didn't pin the baseline
itself. With the production \`listenersDetached\` guard intact, both
halves (pre-promote chain and post-promote chain) suppress emit, so
\`eventCountAfterSettle === 0 === post-count\` and the relative
comparison is vacuously true.

If a future refactor changed the production guard semantics so the
pre-promote chain item DID emit (count becomes 1+ after settle), the
relative-comparison test would still pass as long as post-promote
also emitted the same number — that's a regression the test should
catch. Adding \`expect(eventCountAfterSettle).toBe(0)\` makes the
contract explicit: once \`listenersDetached\` is set during the abort
handler's sync part, BOTH the in-flight chain item (pre-promote) and
the future chain item (post-promote) skip emit.

Found in another self-audit pass — even after fixing the tautological
assertion, the test could still mask certain future regressions.
70 / 70 tests pass; tsc + ESLint clean.

* test(core): drop dataCallbackHolder pattern, read mock.calls directly

The dataCallbackHolder pattern (capturing the onData callback inside
simulateExecution then invoking it after via a closure-shared object)
was unnecessary indirection — \`mockPtyProcess.onData.mock.calls[0][0]\`
reads the same callback reference whether you read it inside or outside
the simulation closure. Vi's mock.calls array is per-mock-instance and
beforeEach re-creates mockPtyProcess + .onData freshly, so there's no
stale-reference risk in the simpler form.

No behavior change in what's tested. 70 / 70 pass.

* chore(core): drop in-source @-mention attribution + dead Proxy.get handler

Two cosmetic cleanups found in another self-audit pass:

- **Helper comment**: removed the parenthetical attribution ("Caught
  by @tanzhenxin in the PR-1 review; my own audit only covered \`get\`
  trap throws."). The technical content of the comment — explaining
  why both the descriptor probe and the value read live inside the
  try — stands on its own. The reviewer credit lives in commit
  history / PR description, where it belongs; an in-source @-mention
  ages poorly (handles change, the relevant person may move on) and
  doesn't help future readers reason about the code.

- **Test Proxy**: \`throwingDescriptorProxy\` declared a \`get()\` handler
  that always returned \`undefined\`. The descriptor probe throws
  before the helper ever reaches the value read, so the \`get\`
  handler is unreachable — dropped it and added a one-line comment
  explaining why no \`get\` handler is needed for this test. Mirror
  test (\`throwingReason\` with throwing accessor + \`proxyReason\` with
  throwing \`get\`) keeps the symmetric "throwing-`get`" coverage.

70 / 70 tests pass; tsc + ESLint clean.
This commit is contained in:
Shaojin Wen 2026-05-07 13:42:42 +08:00 committed by GitHub
parent 93139d0eb0
commit ddd21b5330
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 85 additions and 21 deletions

View file

@ -679,19 +679,57 @@ describe('ShellExecutionService', () => {
// listeners now). Without dataDisposable.dispose() in the abort
// handler, the listener-retention bug would let post-promote bytes
// leak into the foreground consumer.
//
// Implementation note: PTY's handleOutput is async (`processingChain`
// queues microtasks for headlessTerminal.write callbacks), unlike
// child_process's sync handleOutput. Sync `expect` immediately
// after emit-then-abort would only see the call count BEFORE chain
// items run — both pre and post would read 0 and the assertion
// would tautologically pass without exercising the
// `listenersDetached` guard. We drive the test using the
// `simulateExecution` helper, which awaits `handle.result` — by
// the time the result resolves, the abort handler has run its
// drain (so all queued chain items have settled) and we can read
// the final emit count.
const { result } = await simulateExecution(
'tail -f /tmp/never.log',
(pty, abortController) => {
// Data BEFORE promote — fed via the live onData listener so it
(pty, ac) => {
// Pre-promote data — fed via the live onData listener so it
// reaches the foreground onOutputEvent normally.
pty.onData.mock.calls[0][0]('pre-promote-data\n');
abortController.abort({
ac.abort({
kind: 'background',
shellId: 'bg_test123',
} satisfies ShellAbortReason);
},
);
expect(result.promoted).toBe(true);
// Snapshot the count after promote settled. Pre-promote chain
// item ran AFTER abort set `listenersDetached = true` (chain
// items queue at handleOutput time but only execute when their
// .then microtask runs, which is after the sync abort dispatch
// that set the flag), so the pre-promote emit was already
// suppressed by the guard. Asserting `0` here pins both halves
// of the contract — pre-promote AND post-promote bytes are both
// suppressed once `listenersDetached` is set. Without the guard,
// pre-promote's render path would emit a `'data'` event into
// `onOutputEventMock` and this would be `>= 1`, failing the
// assertion.
const eventCountAfterSettle = onOutputEventMock.mock.calls.length;
expect(eventCountAfterSettle).toBe(0);
// Drive the data callback again after promote: production-side
// dataDisposable was disposed, but the mock stub doesn't actually
// detach the callback (the disposable returned by `vi.fn()` is a
// no-op). Re-invoking via `mock.calls[0][0]` exercises the
// production-side `listenersDetached` guard inside the chain
// callback, which is the real backstop against post-promote
// bytes leaking to the foreground onOutputEvent.
mockPtyProcess.onData.mock.calls[0][0]('post-promote-data\n');
// Wait one macrotask + a microtask flush to let any chain items
// queued by the post-promote dataCallback fully settle.
await new Promise((res) => setImmediate(res));
await new Promise((res) => setImmediate(res));
expect(onOutputEventMock.mock.calls.length).toBe(eventCountAfterSettle);
// The disposable returned by mockPtyProcess.onData was disposed by
// the abort handler — verify by calling .dispose's mock.
@ -1880,6 +1918,27 @@ describe('getShellAbortReasonKind (defensive abort-reason read)', () => {
expect(getShellAbortReasonKind(proxyReason)).toBe('cancel');
});
it("returns 'cancel' when the `getOwnPropertyDescriptor` Proxy trap throws", () => {
// `Object.prototype.hasOwnProperty.call(reason, 'kind')` triggers
// the `[[GetOwnProperty]]` Proxy trap. A Proxy whose
// `getOwnPropertyDescriptor` handler throws (separate from the
// `get` trap covered by the test above) used to propagate past
// the helper because `hasOwnProperty.call` was outside the try.
// Now the helper wraps both the descriptor probe and the property
// read, so this also falls back to 'cancel'. (No `get` handler on
// the proxy: `hasOwnProperty.call` throws before the helper ever
// tries to read `kind`.)
const throwingDescriptorProxy = new Proxy(
{},
{
getOwnPropertyDescriptor() {
throw new Error('getOwnPropertyDescriptor blew up');
},
},
);
expect(getShellAbortReasonKind(throwingDescriptorProxy)).toBe('cancel');
});
it("returns 'background' for the canonical happy-path reason", () => {
expect(getShellAbortReasonKind({ kind: 'background' })).toBe('background');
expect(

View file

@ -62,26 +62,31 @@ const PROMOTE_DRAIN_TIMEOUT_MS = 200;
export function getShellAbortReasonKind(
reason: unknown,
): ShellAbortReason['kind'] {
if (
reason !== null &&
typeof reason === 'object' &&
Object.prototype.hasOwnProperty.call(reason, 'kind')
) {
if (reason !== null && typeof reason === 'object') {
try {
const kind = (reason as { kind?: unknown }).kind;
// INVARIANT — three points must be kept in sync when extending
// `ShellAbortReason`:
// (1) the discriminated union below (`type ShellAbortReason`),
// (2) the value-equality whitelist on this line, and
// (3) the `case` arms in both abort-handler switches (the
// `default: { const _exhaustive: never = kind; ... }`
// statically forces #3 when #1 grows, but #2 has no
// compile-time tie to the union; if you forget to extend
// it the new variant silently degrades to 'cancel' here
// and the `case` you added in #3 is never reached).
if (kind === 'background' || kind === 'cancel') return kind;
// Both `hasOwnProperty.call` AND the `kind` read are inside the
// try: `hasOwnProperty.call` triggers the `[[GetOwnProperty]]`
// Proxy trap (`getOwnPropertyDescriptor` handler), so a Proxy
// whose `getOwnPropertyDescriptor` throws — separate from a
// throwing `get` trap — would otherwise propagate past the
// helper.
if (Object.prototype.hasOwnProperty.call(reason, 'kind')) {
const kind = (reason as { kind?: unknown }).kind;
// INVARIANT — three points must be kept in sync when extending
// `ShellAbortReason`:
// (1) the discriminated union below (`type ShellAbortReason`),
// (2) the value-equality whitelist on this line, and
// (3) the `case` arms in both abort-handler switches (the
// `default: { const _exhaustive: never = kind; ... }`
// statically forces #3 when #1 grows, but #2 has no
// compile-time tie to the union; if you forget to extend
// it the new variant silently degrades to 'cancel' here
// and the `case` you added in #3 is never reached).
if (kind === 'background' || kind === 'cancel') return kind;
}
} catch {
// Throwing accessor / Proxy trap — fall back to safe kill below.
// Throwing accessor / Proxy trap (either `get` or
// `getOwnPropertyDescriptor`) — fall back to safe kill below.
}
}
return 'cancel';