Skip to content

feat(relay): fence and drain file and git response streams on shutdown - #24185

Merged
OrcaWin merged 3 commits into
mainfrom
OrcaWin/node-rt-p3-t1c
Oct 1, 2026
Merged

OrcaWin merged 3 commits into
mainfrom
OrcaWin/node-rt-p3-t1c

Conversation

@OrcaWin

@OrcaWin OrcaWin commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator
Files Added Deleted Net
Test 4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​442 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​442
Prod 7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​226 $\color{#cf222e}{\Huge{\mathbf{−}}}$​66 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​160

ELI5

Before the relay shuts down, it now waits for in-flight file and git response streams to finish and close their file handles. Once shutdown starts, new streams are refused. This ports the stream-shutdown slice of #16741. Nothing in production starts a drain yet.

What Changed

  • RelayStreamRegistry:
    • disposeAll blocks new streams and waits for every close and admitted operation.
    • It fails with relay_file_stream_shutdown_incomplete if a handle stays open.
  • GitResponseStreamRegistry:
    • Fences new streams, and adds disposeAllAndWait for in-flight pumps.
    • An aborted pump no longer sends git.responseEnd.
  • Terminal-frame slots: moved unchanged into fs-stream-terminal-frame-slots.ts. Each slot now holds a registry operation.
  • disposeOwnedProcesses: now waits for both stream drains. If either fails, shutdown is postponed instead of exiting with a handle still open.
  • Porting notes are in the commit bodies:
    • Taken verbatim: the shutdown tests and the fs-handler changes.
    • Adapted: no Promise.withResolvers, since the relay targets node18; type guards instead of casts.
  • Wire: no changes.

Why

Design D9, T1: this lets a later shutdown or reset (T3) drain safely without leaking file handles.

Linked Issue

Part of the Node runtime plan (Phase 3). Stacked on #24181.

Visual Proof

N/A. There's no user-visible change yet.

Testing

  • 37 stream tests pass.

  • Added in review: coverage for the stream drains in disposeOwnedProcesses.

  • The full relay suite is green apart from 3 PTY tests that time out under full-suite load; they pass on their own.

  • Known behaviour, same as feat(orcad): consolidate Bun runtime and SSH/Remote migration #16741: a connected client whose socket stops draining can keep shutdown open.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

[High risk] Adds graceful shutdown coordination to relay request and stream handling.

The PR does not appear safe to merge while shutdown can remain blocked by an unsettled socket write or be deferred after a handle has successfully closed.

Findings

  1. P1 Shutdown can wait forever ▶
  2. P1 Recovered close still fails shutdown ▶

Summary

The PR fences new file and git response streams during shutdown and waits for their pumps, file handles, and outstanding publications to settle.

  • Adds dispatcher work admission and producer-publication settlement tracking.
  • Adds shutdown tests for stream drains and owned-process disposal.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Shutdown begins] --> B[Fence new streams]
  B --> C[Drain git response pumps]
  B --> D[Close file handles and await stream operations]
  C --> E[Dispose remaining owned processes]
  D --> E
  E --> F{All drains complete?}
  F -->|Yes| G[Continue shutdown]
  F -->|No| H[Defer shutdown]
Loading

Reviews (3) · Last reviewed commit: "test(relay): cover the stream drains in ..."

Comment on lines +144 to +145
await responses
await fileStreams

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Shutdown can wait forever When shutdown starts while a client socket has stopped draining, a file terminal frame or git chunk write can remain unsettled. These awaits block until the stream drains finish, but the dispatcher writer is closed only afterward. The close that would settle the write is therefore never reached, so shutdown cannot complete.

Comment on lines +187 to +188
const failures = results.filter((result) => result.status === 'rejected')
if (failures.length > 0 || this.streams.size > 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Recovered close still fails shutdown If a handle close fails during disposal but an in-flight pump retries and closes it before its operation finishes, failures still contains the first rejection. Disposal reports relay_file_stream_shutdown_incomplete even though no handles remain, causing the lifecycle to defer shutdown until another attempt.

@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.

✅ No new issues found.

Reviewed changes — the stream-shutdown slice of the Node runtime plan (Design D9/T1), reviewed at 5da66b8 against 04c9331.

  • RelayStreamRegistry drain and fence. Adds a disposed flag, beginOperation()/finish() refcounting, a coalesced async release (EBADF tolerated, other close failures retained for retry), and a disposeAll() that aborts every stream, awaits each close and every admitted operation, and rejects with relay_file_stream_shutdown_incomplete while a handle remains.
  • File-stream pump accounting. readRelayFileStreamMetadata wraps metadata prep in one operation and the detached pump in another; terminal-frame slot reservation moved verbatim into fs-stream-terminal-frame-slots.ts, where each slot is itself a registry operation so shutdown also waits for undelivered terminal frames.
  • GitResponseStreamRegistry drain and fence. Fences new streams via disposed, tracks in-flight pumps in pendingPumps, adds disposeAllAndWait, and suppresses git.responseEnd/git.responseError once a pump is aborted or stale.
  • disposeOwnedProcesses. Kicks off both registry drains, aggregates their failures, and rejects relay_owned_process_shutdown_incomplete so the grace lifecycle defers shutdown and retries instead of exiting with a file handle still open.
  • Tests. New shutdown suites for both registries and for disposeOwnedProcesses, plus an fs-handler case for an undelivered terminal frame. All 33 tests in the four touched files pass.

I traced the hang/leak candidates in this change independently: disposed is set synchronously before the first await, so the operations/pendingPumps snapshots are always complete; the register fence and the post-register beginOperation share a gap-free synchronous window, so no slot or descriptor can be acquired without being drained; and the slot-release closure is idempotent and reached on every internal path. The only path that can hold a drain open is a sink that never settles — the "client socket stops draining" case the PR documents. Wire is unchanged.

ℹ️ Shutdown deferral has no upper bound

disposeOwnedProcesses now rejects when a drain cannot finish, and relay-grace-lifecycle.ts only re-arms a retry while readSocketClientCount() === 0. If a client that stops draining keeps a control write or a bulk notifyBulk unsettled, no retry is armed and the daemon lingers indefinitely. This is the behaviour the PR documents as known, so no change is requested here — flagging only so the boundary is explicit.

Technical details
# Deferred shutdown retry is client-count-gated

## Affected sites
- `src/relay/relay-runtime-services.ts:126` — `disposeOwnedProcesses` now rejects `relay_owned_process_shutdown_incomplete` when a registry drain fails.
- `src/relay/relay-grace-lifecycle.ts:132-140` — on rejection it clears `shutdownInFlight` and re-arms a `shutdown-deferred` grace only when `readSocketClientCount() === 0`.
- `src/relay/relay-grace-branch.ts:121-138` — the `shutdown-deferred` branch is bounded by `idleRelayGraceMs`, so retries do continue while the relay is clientless.

## Required outcome
None if indefinite deferral is the intended posture. Otherwise, cap the retries (or force exit after N) so a permanently unstuck sink cannot keep the daemon alive.

## Open questions for the human
- Is a bound on deferral wanted, or is unbounded deferral intended until the T2 lifecycle port revisits this?

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

m4air added 3 commits October 1, 2026 07:05
Third T1 slice of the #16741 port: the relay's two detached stream
producers can now be fenced and awaited, not just flagged.

RelayStreamRegistry (fs.readFileStream):
- disposeAll permanently fences register/beginOperation
  (relay_file_stream_shutdown_fenced), waits for every close and for every
  admitted operation, and rejects with relay_file_stream_shutdown_incomplete
  while any handle is still unclosed, so a later disposeAll retries only the
  failed handles.
- release coalesces concurrent callers onto one close, tolerates only EBADF
  as already-closed, and keeps a failed-close entry (aborted) for retry.
- beginOperation / releaseUnregisteredHandle let work that opens a handle
  before registration be drained too (wired in the next commit).

GitResponseStreamRegistry: disposeAll fences startStream
(relay_response_stream_shutdown_fenced); disposeAllAndWait also awaits
every scheduled or in-flight pump, and an aborted pump no longer publishes
git.responseEnd after its final chunk write settles.

reserveTerminalFrameSlot moves unchanged into fs-stream-terminal-frame-slots
and now holds a registry operation per slot, so shutdown also waits for
undelivered fs.streamEnd/fs.streamError frames.

No wire change: no new methods, fields, or opcodes. Mixed versions are
unaffected; a fenced relay answers with an ordinary request error.

Porting note (source: #16741 head a68b6f3, merge-base 277c289; T1 per
the track manifest):
- Taken verbatim: fs-stream-registry delta, git-response-stream delta,
  fs-stream-registry-shutdown.test, git-response-stream-shutdown.test.
- Adapted: Promise.withResolvers replaced with plain promises (the relay
  bundle targets node18). fs-stream-terminal-frame-slots is extracted from
  the reserveTerminalFrameSlot that main already has inline in
  fs-handler-file-read (keeping its rationale comment) rather than added as
  a second copy. The EBADF check uses a type guard instead of a cast, and the
  tests' stub casts carry SAFETY rationales.
- Dropped: nothing.
…processes

Wires the stream registries into relay shutdown. The dispatcher's
RelayWorkAdmission drain (#24181) ends once fs.readFileStream answers its
metadata or a handler answers a git response-stream sentinel; the pumps and
file descriptors behind those answers are detached producers it does not
see. RelayRuntimeServices.disposeOwnedProcesses now also awaits
GitResponseStreamRegistry.disposeAllAndWait and FsHandler.disposeFileStreams,
and rejects with relay_owned_process_shutdown_incomplete if either fails, so
the grace lifecycle defers shutdown (its existing catch path) instead of
exiting with an unclosed fd; the next attempt retries only failed handles.

fs.readFileStream now holds a registry operation across metadata
preparation (including the binary-prefix probe handle) and across each pump,
closes pre-registration handles through releaseUnregisteredHandle, releases
its terminal-frame slot even when the handle close fails, and publishes no
terminal frame for a stream that was aborted or went stale while its last
read was in flight.

Behavior today: the grace/SIGTERM shutdown path already calls
disposeOwnedProcesses after PTY disposal, so a shutdown now also waits for
in-flight stream pumps and queued terminal frames (which settle on client
close or dispatcher disposal). After disposal, stream requests are refused
with a fenced error; if shutdown is deferred by a failed close, the relay
stays up with streams fenced, matching #16741's one-way drain. No wire
change.

Porting note (source: #16741 head a68b6f3, merge-base 277c289; these
files are T2 in the track manifest, taken here only as far as the stream
drain needs them):
- Taken verbatim: fs-handler-file-read delta, fs-handler-utils
  isBinaryFilePrefix releaseHandle parameter, fs-handler disposeFileStreams.
- Adapted: relay-runtime-services takes only the responseStreams field and
  the response/file-stream drains in disposeOwnedProcesses. Skill-upload and
  AI Vault cleanup stay log-and-continue rather than joining the aggregate
  failure. Added a fs-handler-stream test proving disposeFileStreams waits
  for an undelivered terminal frame and fences new reads.
- Dropped (left for T2): agentExecHandler disposal, disposeWatchers, the
  ownership-transfer adapter/capture wiring, and the relay-grace-lifecycle
  beginWorkDrain -> disposeOwnedProcesses -> beginWorkDrain sequence.
disposeOwnedProcesses now waits for the git response-stream and file-stream
drains and rejects with relay_owned_process_shutdown_incomplete when either
fails, but nothing exercised that path: relay-daemon tests stub the whole
runtime. These tests pin that shutdown is not acknowledged before either
drain settles, that both drain failures are aggregated after every owner
was attempted (and a retry succeeds), and that skill/AI Vault failures stay
log-and-continue in this slice.

Porting note (source: #16741 head a68b6f3,
src/relay/relay-runtime-owned-process-shutdown.test.ts, T2 in the track
manifest):
- Taken: the prototype-stub fixture, the "does not acknowledge cleanup
  before %s have settled" case (responses, fileStreams), and the
  no-vault-service case.
- Adapted: Promise.withResolvers replaced with a plain promise; the fixture
  cast carries a SAFETY rationale; the retry case targets the stream drains
  instead of skill/vault, and a new case pins skill/vault as
  log-and-continue to match this slice's adaptation.
- Dropped (left for T2): agentExecHandler and disposeWatchers cases, and
  skill/vault aggregate-failure cases.
@OrcaWin
OrcaWin force-pushed the OrcaWin/node-rt-p3-t1c branch from 4446ea6 to 3977d5b Compare October 1, 2026 14:05
@OrcaWin
OrcaWin changed the base branch from OrcaWin/node-rt-p3-t1b to main October 1, 2026 14:06
@OrcaWin OrcaWin closed this Oct 1, 2026
@OrcaWin OrcaWin reopened this Oct 1, 2026
@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

🧰 Additional context used
📚 Code guidelines (1)
AGENTS.md — auto-discovered

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 42263dc1-ef25-4662-8b0f-52d31190cc0d

📥 Commits

Reviewing files that changed from the base of the PR and between 0b812bd and 3977d5b.

📒 Files selected for processing (11)
  • src/relay/fs-handler-file-read.ts
  • src/relay/fs-handler-stream.test.ts
  • src/relay/fs-handler-utils.ts
  • src/relay/fs-handler.ts
  • src/relay/fs-stream-registry-shutdown.test.ts
  • src/relay/fs-stream-registry.ts
  • src/relay/fs-stream-terminal-frame-slots.ts
  • src/relay/git-response-stream-shutdown.test.ts
  • src/relay/git-response-stream.ts
  • src/relay/relay-runtime-owned-process-shutdown.test.ts
  • src/relay/relay-runtime-services.ts

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


📝 Walkthrough

Walkthrough

File and Git response stream registries now track active work and fence new streams during shutdown. File stream setup and cleanup release handles through the registry, and terminal-frame slots remain tracked until released. Runtime-owned process cleanup awaits both stream drains and reports their failures together.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 3977d

The stream drains have no identified blocking defect. Disconnected clients release pending terminal frames; a connected client that stops draining can intentionally delay shutdown. Mergeable subject to normal checks.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 3977d

The change improves cleanup but already affects production shutdown. A live client with stalled output can postpone graceful shutdown and therefore delay restart or rollback. Disconnect releases pending writes, and the supported impact is limited to one relay process.

Retained concerns

  • Medium · reliability · inferred: Graceful shutdown now waits for client-backed stream writes before closing the dispatcher that can cancel those writes. A live, non-draining client can therefore postpone daemon termination while stream admission is already permanently fenced and other cleanup has begun. This affects shutdown and rollback containment within that daemon; disconnect resolves the dependency, and external forced-termination limits remain unestablished.
Security review details

Security Blast Radius

  • inferred — A client-held publication can affect graceful shutdown of the whole owning daemon, including streams from other attached clients. The supported outcome is delayed termination and restart, not additional filesystem access, credential access, or cross-daemon authority.

Security Findings and Attack Paths

  • inferred — A client able to request streamed output can leave a write unsettled while its transport remains live. If graceful shutdown then begins, pump abortion does not cancel that write, and the new drain waits before reaching dispatcher closure. This is a shutdown-containment concern supported by static control flow, not a demonstrated runtime exploit.

Trust Boundaries and Controls

  • observed — Socket admission continues through the existing endpoint handshake; configurations requiring a credential refuse connections before credential publication. Stream ACK and abort ownership checks remain in the Git registry. The changed shutdown coordination does not add a dispatcher shutdown route.

Resilience and Maintainability Implications

  • observed — Disconnect and writer closure settle queued and in-flight publications, so ordinary peer loss is not an orphaned reservation. Disposal also wakes ACK waiters, and Git completion tracking settles on pump fulfillment or rejection. These controls mitigate disconnected-client stalls but do not themselves cancel a pending write on a still-live transport.

Hardening Proposals

  • proposed — Define a bounded graceful-publication phase followed by explicit transport cancellation that settles outstanding callbacks before joining the remaining stream work. Preserve failed file-handle ownership and incomplete-shutdown reporting rather than treating a timeout as successful cleanup.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 11 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 and concisely describes the main change: shutdown fencing and draining for relay file and Git response streams.
Description check ✅ Passed The description covers the required ELI5, change summary, rationale, issue reference, visual proof, testing, and upstream-boundary sections. It provides meaningful test results and known limitations. …
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.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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.

@OrcaWin
OrcaWin merged commit dc08ffe into main Oct 1, 2026
91 of 124 checks passed
OrcaWin added a commit that referenced this pull request Oct 2, 2026
* Revert "feat(orcad): source-side dormant export of a relay-hosted SSH target (#16741 T6-8) (#24519)"

This reverts commit 783101b.

* Revert "feat(ssh): update, roll back, recover and stop a managed orcad server (#16741 T6-5 follow-up) (#24463)"

This reverts commit 38c2d1d.

* Revert "feat(ssh): deploy and pair an empty managed orcad server over SSH (#16741 T6-5) (#24453)"

This reverts commit 8b76683.

* Revert "fix(ssh): orcad GC honors the activation journal; readiness requires proven daemon coverage (#16741 T6 follow-up) (#24451)"

This reverts commit d3f8c50.

* Revert "feat(ssh): remote orcad stop by request file and journaled decommission (#16741 T6-4) (#24449)"

This reverts commit 43d9b43.

* Revert "feat(orcad): supervisable server: stop requests, managed stop receipts and a lifetime that keeps its lock on failed teardown (#16741 T6-3) (#24433)"

This reverts commit b093d3a.

* Revert "feat(ssh): crash-safe orcad activation, rollback and recovery (#16741 T6-2) (#24423)"

This reverts commit 1a9ac0e.

* Revert "feat(runtime): SSH access links for paired servers in a downgrade-safe sidecar (#16741 T5-1+T5-2) (#24420)"

This reverts commit 99db2bf.

* Revert "feat(relay): capability-gated owner reset with a durable preparation journal (#16741 T3 R1) (#24418)"

This reverts commit 34a582b.

* Revert "feat(ssh): track connection-manager drains, test probes and provider continuations (#16741 T2 P3+P8a) (#24407)"

This reverts commit d53063d.

* Revert "feat(daemon): idle retirement, session census and recovery-only provider (#16741 T2 P4b) (#24409)"

This reverts commit ff212db.

* Revert "feat(ssh): add pty.resumeClient and split SSH PTY process listing (#16741 T2 P5+P6) (#24414)"

This reverts commit 92cb717.

* Revert "feat(relay): await owned watcher and agent children on shutdown (#16741 T2 P1) (#24400)"

This reverts commit 6b36e4f.

* Revert "feat(session): retry failed renderer session writes and verify local folder PTYs (#16741 T2 P9) (#24406)"

This reverts commit d23ecef.

* Revert "feat(ssh): remote orcad primitives on the pinned Node runtime (#16741 T6-1) (#24419)"

This reverts commit dd87ae5.

* Revert "fix(runtime): fence runtime-environment subscriptions and status probes by identity (#16741 T5-3) (#24421)"

This reverts commit ece9e4d.

* Revert "feat(orcad): migration manifest and dormant-state contracts (#16741 T6-7) (#24422)"

This reverts commit 3fbdaba.

* Revert "feat(ssh): wire SshConnection through the work and transport close ledgers (#16741 T2 P2) (#24401)"

This reverts commit 4e8edc8.

* Revert "feat(profiles): carry markdown frontmatter visibility in project transfers (#16741 T2 P7) (#24405)"

This reverts commit 60c9326.

* Revert "fix(runtime): project the PTY incarnation onto mobile session tabs (#24413)"

This reverts commit 99e0303.

* Revert "feat(daemon): tag daemon stream data with the PTY incarnation id (#16741 T2 P4a) (#24402)"

This reverts commit 817af76.

* Revert "feat(ssh): port the SSH connection work ledger and transport close ledger (#16741 T2) (#24210)"

This reverts commit c991893.

* Revert "feat(relay): fence and drain file and git response streams on shutdown (#24185)"

This reverts commit dc08ffe.

* Revert "refactor(runtime-rpc): extract the Node WebSocket lifecycle; opt-in pinned port (#24186)"

This reverts commit a789233.

* Revert "feat(relay): route relay handlers through work admission; producer publication drain (#24181)"

This reverts commit 0b812bd.

* Revert "feat(relay): land the #16741 T1 seam (work drain, publication drain, release gate) (#24156)"

This reverts commit 3aa2d3a.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
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.

1 participant