fix: recover standalone bridge from closed stdin

Merged
LL
2mo ago
+114-13

Summary

  • treat a live child with non-writable stdin as an unusable standalone transport
  • retire the stale child before respawn so BridgePool cannot keep returning a permanently wedged bridge
  • capture the child used for each write and invalidate it only when it is still current, preventing a late callback from killing a replacement

Outcome semantics

The two failure windows intentionally differ:

  1. If stdin is already non-writable when ensureSpawned() runs, no write has been attempted. The stale child is invalidated, a replacement is spawned, and that send() continues on the replacement.
  2. If stdin.write() reports an asynchronous error after initiation, the current request rejects with BridgeTransportUnavailableError because mutation outcome may be unknown. The child is invalidated so the next request respawns; the current request is never blindly replayed.

This removes the permanent-wedge class while preserving conservative unknown-outcome handling.

Verification

  • regression first reproduced the old permanent stdin not writable wedge
  • targeted closed-stdin and async-write tests: 2 passed
  • complete bridge-transport.test.ts: 22 passed
  • @cortexkit/aft-bridge typecheck: passed
  • Biome on changed files: passed
  • live driver: one BinaryBridge replaced child PID 77753 with PID 77767 and served the next ping successfully

Closes #231


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Fixes a permanent wedge in aft-bridge when a child stays alive but its stdin closes. Previously the bridge stayed stuck; now non-writable stdin is treated as transport failure, the child is invalidated, and a fresh child is spawned. In-flight writes that fail asynchronously reject and are not replayed.

  • BinaryBridge.ensureSpawned now invalidates a live child with non-writable stdin and respawns, so BridgePool no longer returns a wedged bridge.
  • send() captures the child used for the write and only invalidates it if it’s still current, avoiding late-callback races that could kill a replacement.
  • On async stdin.write errors, the request rejects with BridgeTransportUnavailableError; the child is invalidated so the next request respawns.
  • Adds tests for closed-stdin-before-exit and async write failure.

Greptile Summary

The PR recovers standalone bridges when a live child has unusable stdin and makes asynchronous write-error invalidation child-specific.

  • Adds transport invalidation that detaches and kills unusable children, clears process-scoped state, and rejects pending requests.
  • Respawns before sending when the current child is alive but stdin is not writable.
  • Captures the child used for each write so delayed callbacks cannot invalidate a newer replacement.
  • Adds regressions for closed stdin and asynchronous write failures.

Confidence Score: 3/5

The PR should not merge until replacement processes are isolated from the retired child's stream handlers and configuration lifecycle.

Immediate respawn allows delayed stdout closure from the retired child to consume replacement framing, while an in-flight configure continuation can incorrectly transfer configured state to the new process.

Files Needing Attention: packages/aft-bridge/src/bridge.ts

Important Files Changed

FilenameOverview
packages/aft-bridge/src/bridge.tsAdds closed-stdin recovery and generation-aware write callbacks, but immediate replacement remains exposed to stale stream listeners and configuration continuations.
packages/aft-bridge/src/tests/bridge-transport.test.tsAdds useful recovery regressions, but they do not exercise retired-child stream events or configuration completion racing with replacement.

Sequence Diagram

sequenceDiagram
  participant A as Request A
  participant B as BinaryBridge
  participant Old as Retired child
  participant New as Replacement child
  A->>B: send while configure is active
  Old-->>B: stdin becomes non-writable
  B->>Old: SIGKILL
  B->>New: spawn immediately
  New-->>B: partial stdout response
  Old-->>B: delayed stdout end
  B->>B: flush shared stdout buffer
  Note over B: Replacement frame may be discarded
  B->>B: stale configure continuation marks configured
  Note over B,New: New child may never receive configure

Reviews (1): Last reviewed commit: "fix: recover standalone bridge from clos..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Context used: