Skip to content

test(ssh): dockerized relay fault injection with verdict assertions - #18017

Merged
nwparker merged 2 commits into
mainfrom
salvage/ssh-docker-fault-harness
Sep 2, 2026
Merged

nwparker merged 2 commits into
mainfrom
salvage/ssh-docker-fault-harness

Conversation

@nwparker

@nwparker nwparker commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​480 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​480
Prod 2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2

Salvaged from #17786 (see the review there). Test-only, independently mergeable, and it found a real defect on main.

What this is

A Docker SSH fault-injection harness with four genuinely distinct fault shapes, each documented with why it is not the others:

  • kill the per-connection sshd forks — transport dies, relay survives. The fault the reconnect path is actually built for. Matches both sshd and sshd-session (the OpenSSH ≥9.8 rename).
  • docker pause — silence with TCP still established. The laptop-lid shape; the only fault that exposes a liveness timeout firing on a healthy session.
  • SIGKILL every relay.js — the only fault where an explicit session-expired state is correct rather than resuming.
  • plus a fault-clearing helper so a failed spec cannot poison later specs.

Verbatim final run

✓  1 … recovers a live pane after the transport dies under it (21.2s)
✓  2 … stays bounded when a disconnected shell floods its pty (59.7s)
✓  3 … replaces the pane only when the host proves the session is gone (21.4s)
✓  4 … keeps the session while a frozen host goes silent (49.1s)
-  5 … accepts input again after a frozen host resumes
  1 skipped
  4 passed (2.6m)

Tests 3 and 4 are new. #17786's spec used one fault three ways and never asserted the verdict vocabulary at all — it read sshConnectionStates.status, a connection status, not a PTY verdict.

  • Test 3 uses killDockerSshRelayDaemon and asserts the exited verdict (pane bound to a different PTY).
  • Test 4 uses docker pause and asserts unverifiable (same PTY, scrollback intact).

That distinction — resumed vs silently started over — is what this whole sweep turns on, and it was previously untestable.

A real defect found, and deliberately left failing

Test 4's last assertion fails on main: after a 30s freeze the pane keeps its PTY and repaints its scrollback, but a command run afterwards produces no output within 60s. Live-looking and deaf.

Cherry-checked against #18016 applied: ✘ Error: Terminal did not contain "STALL_AFTER_1788297964282" — identical failure, so the delivery-ownership fix is not the cause.

I split the passing verdict assertions from the failing one, so the unverifiable verdict stays enforced in CI rather than being masked by a broken sibling, and left the reproduction as test.fixme with the measurements attached.

Deliberate reductions from #17786's version

Every remaining export is now exercised. #17786 shipped 9 exports with 2 used.

Gate wiring

Three one-line additions (pr-e2e-gate-contract, pr-e2e-source-routing, run-ssh-docker-e2e); pr-e2e-gate-contract.test.mjs 42 passed.

Worth knowing: the spec was already wired to run in #17786 — the source-routing gate matches its paths and e2e.yml auto-sets ORCA_E2E_SSH_DOCKER=1 — but reliability-gates.jsonc records no run evidence, so it appears never to have executed. The run above is its first real signal.

User-facing regressions

None — test-only.

…shapes

The existing SSH reconnect specs all reconnect by calling ssh.disconnect() then
ssh.connect() - a clean cycle the client knows is coming. Nothing covered the
faults the reconnect machinery exists for.

Four shapes, each documented with why it is not the others: killing sshd's
per-connection forks (transport dies, relay survives), `docker pause` (silence
with TCP still established), SIGKILLing every relay.js (the only fault where
`exited` is the correct verdict), and a 48MB flood with nobody attached.

The relay-kill case is the one that makes the rest meaningful: every other case
asserts the session survived, which only means something if a genuinely dead
session is distinguishable. It is the only case where replacing the pane is
correct, so it pins the boundary in
docs/reference/ssh-execution-boundary.md rather than just testing reconnection.

The `docker pause` case pins the other side of that boundary: after 30s of
silence from a healthy host the pane keeps its PTY and its scrollback, because
loss of contact is never evidence of death.

No network-blackhole fault: reconnecting the fixture does not restore its
published port mapping, so that fault is not reversible on this container and
would strand the worker it ran on.
@coderabbitai

coderabbitai Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: fe4ad2ca-cda6-4fe8-accf-d9aa6a70f1db

📥 Commits

Reviewing files that changed from the base of the PR and between bdabc9d and a44e7a6.

📒 Files selected for processing (1)
  • tests/e2e/ssh-docker-transport-drop-recovery.spec.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

Adds Docker SSH relay fault helpers and a gated Playwright spec. The tests cover transport drops, output flooding, relay daemon termination, paused targets, and a known paused-host input failure. The spec is added to SSH source routing, the gate contract mapping, and the SSH Docker E2E execution list.

Merge Risk: ⚪ Minimal · up to a44e7

This change adds Docker-based SSH fault-injection coverage and explicit assertions distinguishing session resumption from replacement, without changing production runtime behavior or public interfaces. No actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 5 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: Dockerized SSH relay fault injection with verdict assertions.
Description check ✅ Passed The description is detailed and explains the scope, rationale, test coverage, defects found, deliberate reductions, gate wiring, and user-facing impact. It references related issues and provides test …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description is detailed and explains the scope, rationale, test coverage, defects found, deliberate reductions, gate wiring, and user-facing impact. It references related issues and provides test results. It does not use all template headings, does not explicitly state visual proof as N/A, and omits the checklist sections, but the core required information is present.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 38a7e86b-11a6-41c3-a09d-b04f8db71050

📥 Commits

Reviewing files that changed from the base of the PR and between 7314ada and bdabc9d.

📒 Files selected for processing (5)
  • config/scripts/pr-e2e-gate-contract.test.mjs
  • config/scripts/pr-e2e-source-routing.mjs
  • config/scripts/run-ssh-docker-e2e.mjs
  • tests/e2e/helpers/docker-ssh-relay-faults.ts
  • tests/e2e/ssh-docker-transport-drop-recovery.spec.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

.toBe('connected')

await waitForActiveTerminalManager(orcaPage, 60_000)
await waitForActivePanePtyId(orcaPage, 60_000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the original PTY after transport recovery.

Both tests record the original ptyId, but neither compares it after recovery. A replacement pane can retain the old scrollback marker, and later commands run against the replacement PTY, so a cold start passes as a resumed session.

  • tests/e2e/ssh-docker-transport-drop-recovery.spec.ts#L93-L93: assert that the recovered active PTY equals ptyId.
  • tests/e2e/ssh-docker-transport-drop-recovery.spec.ts#L181-L181: assert that the active PTY equals ptyId before sending the post-flood command.
📍 Affects 1 file
  • tests/e2e/ssh-docker-transport-drop-recovery.spec.ts#L93-L93 (this comment)
  • tests/e2e/ssh-docker-transport-drop-recovery.spec.ts#L181-L181

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Solid, well-documented test-only PR — two inline comments worth addressing, nothing blocking.

Reviewed changes

  • tests/e2e/helpers/docker-ssh-relay-faults.ts — a new fault-injection helper with four fault shapes (sshd-fork transport drop, docker pause freeze, relay SIGKILL, and a fault-clearing helper), each documented with why it is distinct from the others.
  • tests/e2e/ssh-docker-transport-drop-recovery.spec.ts — five specs exercising those faults (four passing, one test.fixme reproduction of the "live-looking but deaf" post-freeze defect), asserting the resumed vs cold-start verdicts through terminal content and pty stability rather than connection status.
  • Three one-line gate entries wiring the new spec into pr-e2e-gate-contract.test.mjs, pr-e2e-source-routing.mjs, and run-ssh-docker-e2e.mjs.

ℹ️ Nitpicks

  • The test.fixme reproduction at ssh-docker-transport-drop-recovery.spec.ts:313 documents the defect it found in depth but carries no tracking-issue number; linking one would make the deliberate skip traceable for whoever picks up the fix.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@pullfrog

pullfrog Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Two inline comments that belong with the review above (the review API deduped a second submission, so posting here):

1. tests/e2e/ssh-docker-transport-drop-recovery.spec.ts:241 — the exited verdict is pinned on an implementation detail, not user-observable content.

The verdict here is await poll(() => waitForActivePanePtyId(...).catch(() => ptyId)).not.toBe(ptyId) — the pane's dataset.ptyId binding, which a user cannot observe. KILL_MARKER proves the doomed shell existed but is never asserted absent from the replacement, so a pane re-keyed to a fresh pty while still repainting old scrollback would pass, and a pane rebuilt on the same pty would falsely fail. Read the terminal content and assert it no longer contains the marker (getTerminalContent + .not.toContain(marker)), as ssh-docker-reconnect-pane-restore.spec.ts:132-134 already does, keeping the ptyId inequality as corroboration.

2. tests/e2e/helpers/docker-ssh-relay-faults.ts:98 — duplicates the relay-process scan and drops its mode filter.

killDockerSshRelayDaemon reimplements the /proc + relay.js argv scan already in docker-ssh-relay-processes.ts (LIST_RELAY_PROCESSES_COMMAND) and ssh-port-forward-transport-evidence.ts:57, and unlike both it filters no --detached/--connect mode. readDockerSshRelayProcessSnapshots already returns the detached relay pids, so kill -9 over those would reuse the shared scan and match the doc's "every detached relay process". As written the loop matches any relay.js process regardless of mode — harmless for the killed > 0 assertion, but the "detached" qualifier is inaccurate.

Neither is blocking — both are approved: false-style suggestions on a test-only PR.

Pullfrog  | View workflow run | via Pullfrog | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@nwparker

nwparker commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Merge-blocking request before this lands: e2e / changed e2e specs is red on this PR, and the failing case is this spec's own stays bounded when a disconnected shell floods its pty (Terminal did not contain "FLOOD_AFTER_…", 60s poll timeout — job). That is the same failure shape as #18018 (pane keeps its PTY and repaints but never accepts input again after a stall).

Please mark that one case test.fixme(...) with a reference to #18018, the same way accepts input again after a frozen host resumes already is, and keep the other three cases enforced. Landing a red spec normalizes red CI; the value of this PR is that the live / unverifiable / exited verdicts become CI-enforced, so the passing three should stay that way.

It fails in CI on its first real run: the pane keeps its PTY and repaints,
but a command run after the flood produces no output within the poll budget.
Same shape as #18018 and not caused by this spec. The three verdict
assertions around it stay enforced.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Marking the flood spec test.fixme also disables its RSS OOM guard, which is the test's stated purpose and appears to still pass — see inline.

Reviewed changes

  • test.fixme on the flood spec — the "stays bounded when a disconnected shell floods its pty" spec was marked test.fixme (tracked in #18018) after its first CI run failed on the trailing post-flood usability assertion, leaving the three verdict specs enforced.

ℹ️ Nitpicks

  • The new comment attributes the flood failure to #18018 ("deaf pane after a stalled host resumes"), but the sibling test.fixme at ssh-docker-transport-drop-recovery.spec.ts:317 ("accepts input again after a frozen host resumes") is the canonical reproduction of that exact defect and still carries no tracking-issue reference; linking #18018 there would close the loop flagged in the prior review.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

// run after the flood produces no output within the poll budget. Same shape as #18018 (deaf pane
// after a stalled host resumes), and not caused by this spec. Tracked there; the three verdict
// assertions around it stay enforced.
test.fixme('stays bounded when a disconnected shell floods its pty', async ({ orcaPage }, testInfo) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test.fixme disables the entire flood spec, including the RSS OOM guard at line 178 (.toBeLessThan(200_000)) — which is the test's stated purpose and appears to still pass, since the CI failure is only the trailing "command produces output" check. Split the passing RSS assertion from the failing usability check the way the stall verdict was split into specs 4 and 5, so the memory guard stays enforced in CI.

Technical details
# Preserve the flood spec's RSS OOM guard

## Affected sites
- tests/e2e/ssh-docker-transport-drop-recovery.spec.ts:120 — `test.fixme` now disables the whole flood spec.
- tests/e2e/ssh-docker-transport-drop-recovery.spec.ts:175-178 — the RSS bound assertion (`.toBeLessThan(200_000)`), the test's OOM guard.
- tests/e2e/ssh-docker-transport-drop-recovery.spec.ts:180-188 — the trailing `FLOOD_AFTER` usability assertion that actually fails in CI.

## Required outcome
- The RSS memory bound stays enforced in CI.
- Only the currently-failing usability check is `test.fixme`.

## Suggested approach (optional)
- Split into two specs, mirroring the stall split into specs 4/5: one passing spec that floods, drops transport, and asserts `afterRssKb - baselineRssKb < 200_000`; one `test.fixme` that re-runs the flood/drop and asserts the `FLOOD_AFTER` marker.
- Re-running the flood doubles the ~60s runtime; if that cost is unacceptable, keep one spec and hoist the RSS assertion so it still executes before the failing check — but note `test.fixme` suppresses failures even for assertions that pass today.

@nwparker
nwparker merged commit 0dbe9d0 into main Sep 2, 2026
32 checks passed
nwparker added a commit that referenced this pull request Sep 2, 2026
Gate 3 refused to respawn a pane whose records still named a PTY, which is
right for a transport drop and wrong for a killed relay: after the relay is
SIGKILLed and comes back, the leaf map still holds `pty2:<dead-epoch>:1` while
the new relay answers that it has no such id. #18017's "replaces the pane only
when the host proves the session is gone" regressed on exactly that.

The gap was not the predicate, it was its inputs. `handlePtyReattachFailure`
already distinguishes the three reattach outcomes and only its not-found branch
publishes anything — a lost link and an identity mismatch send nothing. But it
published `pty:exit { code: -1 }`, and `-1` is the sentinel every reader
resolves to `stop_unverified`, so the one branch holding positive host evidence
of absence arrived looking exactly like loss of contact. The renderer had no
host answer at all, which the gate's own comment conceded.

The exit now carries `livenessVerdict: 'exited'` beside the unchanged `-1`, so
the code keeps meaning "no provable status" for every existing reader while the
verdict rides its own field. A store bridge records those ids in
`hostAttestedAbsentPtyIds` regardless of whether a pane is mounted to hear it —
during reconnect none is — and the gate stops counting a recorded id the host
has disowned. Settled when a PTY answers to that id again, because a redeployed
relay renumbers from pty-1.

This narrows #17963, which pinned the same exit as unverified on the grounds
that not-found cannot separate "verified the pid is dead" from "my session map
never had this id". Everything #17963 protects is untouched: `-1` still fails
isProvenProcessExit, so the tab is not closed, the pane's leaf binding is not
dropped on exit, and markUnverifiedPtyLoss still fires. Only the reconnect
respawn gate reads the new field, and only for an id whose sole channel — the
relay that answered — has disowned it, which no client can reach again under
any verdict. That is the reading ssh-pty-relay-absence-verdict.test.ts already
pins for the spawn path; the reconnect path now agrees with it.

Rejected: parsing the relay's mint epoch out of `pty2:<epoch>:<n>`. It needs the
current epoch on the wire (a capability-negotiated relay change), it has no
answer for legacy `pty-N` ids, and a relay that comes back with zero PTYs gives
the client no epoch to compare against. Rejected: clearing the leaf record
outright, because the remote workspace snapshot re-hydrates those ids after the
clear and the gate would refuse again.
@AmethystLiang

Copy link
Copy Markdown
Contributor

thanks for reporting. looking into it

nwparker added a commit that referenced this pull request Sep 3, 2026
Gate 3 refused to respawn a pane whose records still named a PTY, which is
right for a transport drop and wrong for a killed relay: after the relay is
SIGKILLed and comes back, the leaf map still holds `pty2:<dead-epoch>:1` while
the new relay answers that it has no such id. #18017's "replaces the pane only
when the host proves the session is gone" regressed on exactly that.

The gap was not the predicate, it was its inputs. `handlePtyReattachFailure`
already distinguishes the three reattach outcomes and only its not-found branch
publishes anything — a lost link and an identity mismatch send nothing. But it
published `pty:exit { code: -1 }`, and `-1` is the sentinel every reader
resolves to `stop_unverified`, so the one branch holding positive host evidence
of absence arrived looking exactly like loss of contact. The renderer had no
host answer at all, which the gate's own comment conceded.

The exit now carries `livenessVerdict: 'exited'` beside the unchanged `-1`, so
the code keeps meaning "no provable status" for every existing reader while the
verdict rides its own field. A store bridge records those ids in
`hostAttestedAbsentPtyIds` regardless of whether a pane is mounted to hear it —
during reconnect none is — and the gate stops counting a recorded id the host
has disowned. Settled when a PTY answers to that id again, because a redeployed
relay renumbers from pty-1.

This narrows #17963, which pinned the same exit as unverified on the grounds
that not-found cannot separate "verified the pid is dead" from "my session map
never had this id". Everything #17963 protects is untouched: `-1` still fails
isProvenProcessExit, so the tab is not closed, the pane's leaf binding is not
dropped on exit, and markUnverifiedPtyLoss still fires. Only the reconnect
respawn gate reads the new field, and only for an id whose sole channel — the
relay that answered — has disowned it, which no client can reach again under
any verdict. That is the reading ssh-pty-relay-absence-verdict.test.ts already
pins for the spawn path; the reconnect path now agrees with it.

Rejected: parsing the relay's mint epoch out of `pty2:<epoch>:<n>`. It needs the
current epoch on the wire (a capability-negotiated relay change), it has no
answer for legacy `pty-N` ids, and a relay that comes back with zero PTYs gives
the client no epoch to compare against. Rejected: clearing the leaf record
outright, because the remote workspace snapshot re-hydrates those ids after the
clear and the gate would refuse again.
nwparker added a commit that referenced this pull request Sep 3, 2026
…17957)

* fix(ssh): stop respawning panes on client-side-only absence evidence

Three respawn gates acted on evidence weaker than host-attested exit.
Per docs/reference/ssh-execution-boundary.md, loss of contact, a failed
reattach, an identity mismatch and absence from a client map are all
`unverifiable`, never `exited`.

Gate 1 (ipc-pty-connect.ts): "belongs to SSH connection" is minted by the
id router from a pure client-side string compare, before any relay is
asked, and still returned `sessionExpired: true` -> fresh PTY + agent
resume. After an SSH target re-adoption the "other" connection is the
same machine, so that puts a second `claude --resume` on the transcript
the surviving PTY still owns. Now returns undefined with no error, which
routes the pane to recoverUnverifiableDirectSshReattach (remount +
reattach, no shell restart) and keeps #7661's no-red-toast outcome.

Gate 3 (ssh-reconnect-pane-retry.ts): `!tabPtyId` read `tab.ptyId`, which
is only the single-pane fallback for legacy attach. It diverges from the
real records deterministically: workspace-terminal-reconnect fills
ptyIdsByTabId from the leaf map but writes tab.ptyId only when a
tab-level id survives, and clearTransientTerminalState nulls tab.ptyId on
every hydrated row. Both leave live leaf PTYs with a null fallback field,
arming a generation bump onto the fresh-spawn path. Now consults
ptyIdsByTabId and the layout leaf map too; a tab with no PTY in any
record still retries.

Gate 2 (recoverTerminalPane): an `expired` lease plus `!pty.connected`
authorized createTerminal. Every writer of `expired` records that the
CLIENT lost its route, not that the shell died. Now also requires the
runtime's own liveness verdict to be neither `live` nor `unverifiable`,
and ssh-relay-session records markPtyLivenessLive at the persistPtyBinding
refusal, which is reached only after pty.attach succeeded. See the report
for why this branch is currently unreachable for SSH panes.

* fix(ssh): let the respawn gate see the relay's own absence answer

Gate 3 refused to respawn a pane whose records still named a PTY, which is
right for a transport drop and wrong for a killed relay: after the relay is
SIGKILLed and comes back, the leaf map still holds `pty2:<dead-epoch>:1` while
the new relay answers that it has no such id. #18017's "replaces the pane only
when the host proves the session is gone" regressed on exactly that.

The gap was not the predicate, it was its inputs. `handlePtyReattachFailure`
already distinguishes the three reattach outcomes and only its not-found branch
publishes anything — a lost link and an identity mismatch send nothing. But it
published `pty:exit { code: -1 }`, and `-1` is the sentinel every reader
resolves to `stop_unverified`, so the one branch holding positive host evidence
of absence arrived looking exactly like loss of contact. The renderer had no
host answer at all, which the gate's own comment conceded.

The exit now carries `livenessVerdict: 'exited'` beside the unchanged `-1`, so
the code keeps meaning "no provable status" for every existing reader while the
verdict rides its own field. A store bridge records those ids in
`hostAttestedAbsentPtyIds` regardless of whether a pane is mounted to hear it —
during reconnect none is — and the gate stops counting a recorded id the host
has disowned. Settled when a PTY answers to that id again, because a redeployed
relay renumbers from pty-1.

This narrows #17963, which pinned the same exit as unverified on the grounds
that not-found cannot separate "verified the pid is dead" from "my session map
never had this id". Everything #17963 protects is untouched: `-1` still fails
isProvenProcessExit, so the tab is not closed, the pane's leaf binding is not
dropped on exit, and markUnverifiedPtyLoss still fires. Only the reconnect
respawn gate reads the new field, and only for an id whose sole channel — the
relay that answered — has disowned it, which no client can reach again under
any verdict. That is the reading ssh-pty-relay-absence-verdict.test.ts already
pins for the spawn path; the reconnect path now agrees with it.

Rejected: parsing the relay's mint epoch out of `pty2:<epoch>:<n>`. It needs the
current epoch on the wire (a capability-negotiated relay change), it has no
answer for legacy `pty-N` ids, and a relay that comes back with zero PTYs gives
the client no epoch to compare against. Rejected: clearing the leaf record
outright, because the remote workspace snapshot re-hydrates those ids after the
clear and the gate would refuse again.

* refactor(ssh): name the relay-disowned signal for disownership, not exit
paidaxingyo666 pushed a commit to paidaxingyo666/Manta that referenced this pull request Sep 3, 2026
…tablyai#18017)

* test(ssh): add a dockerized SSH fault-injection lane with four fault shapes

The existing SSH reconnect specs all reconnect by calling ssh.disconnect() then
ssh.connect() - a clean cycle the client knows is coming. Nothing covered the
faults the reconnect machinery exists for.

Four shapes, each documented with why it is not the others: killing sshd's
per-connection forks (transport dies, relay survives), `docker pause` (silence
with TCP still established), SIGKILLing every relay.js (the only fault where
`exited` is the correct verdict), and a 48MB flood with nobody attached.

The relay-kill case is the one that makes the rest meaningful: every other case
asserts the session survived, which only means something if a genuinely dead
session is distinguishable. It is the only case where replacing the pane is
correct, so it pins the boundary in
docs/reference/ssh-execution-boundary.md rather than just testing reconnection.

The `docker pause` case pins the other side of that boundary: after 30s of
silence from a healthy host the pane keeps its PTY and its scrollback, because
loss of contact is never evidence of death.

No network-blackhole fault: reconnecting the fixture does not restore its
published port mapping, so that fault is not reversible on this container and
would strand the worker it ran on.

* test(ssh): fixme the flood case pending stablyai#18018

It fails in CI on its first real run: the pane keeps its PTY and repaints,
but a command run after the flood produces no output within the poll budget.
Same shape as stablyai#18018 and not caused by this spec. The three verdict
assertions around it stay enforced.

Mirror-Of: 0dbe9d0
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…tablyai#18017)

* test(ssh): add a dockerized SSH fault-injection lane with four fault shapes

The existing SSH reconnect specs all reconnect by calling ssh.disconnect() then
ssh.connect() - a clean cycle the client knows is coming. Nothing covered the
faults the reconnect machinery exists for.

Four shapes, each documented with why it is not the others: killing sshd's
per-connection forks (transport dies, relay survives), `docker pause` (silence
with TCP still established), SIGKILLing every relay.js (the only fault where
`exited` is the correct verdict), and a 48MB flood with nobody attached.

The relay-kill case is the one that makes the rest meaningful: every other case
asserts the session survived, which only means something if a genuinely dead
session is distinguishable. It is the only case where replacing the pane is
correct, so it pins the boundary in
docs/reference/ssh-execution-boundary.md rather than just testing reconnection.

The `docker pause` case pins the other side of that boundary: after 30s of
silence from a healthy host the pane keeps its PTY and its scrollback, because
loss of contact is never evidence of death.

No network-blackhole fault: reconnecting the fixture does not restore its
published port mapping, so that fault is not reversible on this container and
would strand the worker it ran on.

* test(ssh): fixme the flood case pending stablyai#18018

It fails in CI on its first real run: the pane keeps its PTY and repaints,
but a command run after the flood produces no output within the poll budget.
Same shape as stablyai#18018 and not caused by this spec. The three verdict
assertions around it stay enforced.
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…tablyai#17957)

* fix(ssh): stop respawning panes on client-side-only absence evidence

Three respawn gates acted on evidence weaker than host-attested exit.
Per docs/reference/ssh-execution-boundary.md, loss of contact, a failed
reattach, an identity mismatch and absence from a client map are all
`unverifiable`, never `exited`.

Gate 1 (ipc-pty-connect.ts): "belongs to SSH connection" is minted by the
id router from a pure client-side string compare, before any relay is
asked, and still returned `sessionExpired: true` -> fresh PTY + agent
resume. After an SSH target re-adoption the "other" connection is the
same machine, so that puts a second `claude --resume` on the transcript
the surviving PTY still owns. Now returns undefined with no error, which
routes the pane to recoverUnverifiableDirectSshReattach (remount +
reattach, no shell restart) and keeps stablyai#7661's no-red-toast outcome.

Gate 3 (ssh-reconnect-pane-retry.ts): `!tabPtyId` read `tab.ptyId`, which
is only the single-pane fallback for legacy attach. It diverges from the
real records deterministically: workspace-terminal-reconnect fills
ptyIdsByTabId from the leaf map but writes tab.ptyId only when a
tab-level id survives, and clearTransientTerminalState nulls tab.ptyId on
every hydrated row. Both leave live leaf PTYs with a null fallback field,
arming a generation bump onto the fresh-spawn path. Now consults
ptyIdsByTabId and the layout leaf map too; a tab with no PTY in any
record still retries.

Gate 2 (recoverTerminalPane): an `expired` lease plus `!pty.connected`
authorized createTerminal. Every writer of `expired` records that the
CLIENT lost its route, not that the shell died. Now also requires the
runtime's own liveness verdict to be neither `live` nor `unverifiable`,
and ssh-relay-session records markPtyLivenessLive at the persistPtyBinding
refusal, which is reached only after pty.attach succeeded. See the report
for why this branch is currently unreachable for SSH panes.

* fix(ssh): let the respawn gate see the relay's own absence answer

Gate 3 refused to respawn a pane whose records still named a PTY, which is
right for a transport drop and wrong for a killed relay: after the relay is
SIGKILLed and comes back, the leaf map still holds `pty2:<dead-epoch>:1` while
the new relay answers that it has no such id. stablyai#18017's "replaces the pane only
when the host proves the session is gone" regressed on exactly that.

The gap was not the predicate, it was its inputs. `handlePtyReattachFailure`
already distinguishes the three reattach outcomes and only its not-found branch
publishes anything — a lost link and an identity mismatch send nothing. But it
published `pty:exit { code: -1 }`, and `-1` is the sentinel every reader
resolves to `stop_unverified`, so the one branch holding positive host evidence
of absence arrived looking exactly like loss of contact. The renderer had no
host answer at all, which the gate's own comment conceded.

The exit now carries `livenessVerdict: 'exited'` beside the unchanged `-1`, so
the code keeps meaning "no provable status" for every existing reader while the
verdict rides its own field. A store bridge records those ids in
`hostAttestedAbsentPtyIds` regardless of whether a pane is mounted to hear it —
during reconnect none is — and the gate stops counting a recorded id the host
has disowned. Settled when a PTY answers to that id again, because a redeployed
relay renumbers from pty-1.

This narrows stablyai#17963, which pinned the same exit as unverified on the grounds
that not-found cannot separate "verified the pid is dead" from "my session map
never had this id". Everything stablyai#17963 protects is untouched: `-1` still fails
isProvenProcessExit, so the tab is not closed, the pane's leaf binding is not
dropped on exit, and markUnverifiedPtyLoss still fires. Only the reconnect
respawn gate reads the new field, and only for an id whose sole channel — the
relay that answered — has disowned it, which no client can reach again under
any verdict. That is the reading ssh-pty-relay-absence-verdict.test.ts already
pins for the spawn path; the reconnect path now agrees with it.

Rejected: parsing the relay's mint epoch out of `pty2:<epoch>:<n>`. It needs the
current epoch on the wire (a capability-negotiated relay change), it has no
answer for legacy `pty-N` ids, and a relay that comes back with zero PTYs gives
the client no epoch to compare against. Rejected: clearing the leaf record
outright, because the remote workspace snapshot re-hydrates those ids after the
clear and the gate would refuse again.

* refactor(ssh): name the relay-disowned signal for disownership, not exit
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants