🤖 refactor: bridge oRPC subscription procedures through Effect Stream - #4039
Merged
Conversation
Adds streamBridge.ts (subscriptionIterable): an Effect Stream pipeline with per-resource acquireRelease listener teardown, Queue.offerUnsafe synchronous foreign-callsite emissions, latest-value coalescing via Queue.sliding(1), scope-tied heartbeat fibers, and AsyncGenerator wire adaptation via Stream.toAsyncIterable. Converts all 20 routerSubscriptions handlers.
This comment has been minimized.
This comment has been minimized.
Member
Author
|
@codex review |
Member
Author
|
@codex security review |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 9 of the progressive Effect migration: adds a single reusable Effect Stream bridge (
src/node/orpc/streamBridge.ts) for oRPC subscription procedures and converts all 20 event-subscription handlers inrouterSubscriptions.tsto it — including the two deferred by name from earlier phases (memory.onChangefrom #4025 and configonConfigChangedfrom #4036). Wire behavior (payload shapes, ordering, completion/error semantics) is unchanged; all existing tests pass unmodified.Background
Wave 2 (#4033, #4034, #4035, #4036, #4038) put every service-backed unary mutation/query on
handlerGen/Effect pipelines. What remained on the router was the subscription surface: ~20 procedures consuming EventEmitter-based sources viaon/off+ theasyncIterableFromSubscriptionpush seam. That seam hand-rolls lifecycle (abort listeners, queue end, unsubscribe-in-finally) per call. This phase replaces it on the router with a Scope-managed Effect Stream pipeline so listener teardown becomes structural instead of conventional.Bridge design (
streamBridge.ts)subscriptionIterable(options)builds a scopedStreamand adapts it back to theAsyncGeneratorwire shape oRPC event-iterator procedures expect:Queue(Queue.unboundedfor FIFO,Queue.sliding(1)for latest-value coalescing — exact replacement forcreateLatestValueQueuesnapshot semantics).emit.pushis a synchronousQueue.offerUnsafe: the value lands in the buffer beforepushreturns, with no fiber suspension between the producer's emit and the queue offer. This preserves emit-after-durable-write ordering (e.g. memory consolidation emitsstatusChangesynchronously post-write; subscribers observe events in exactly that order).Effect.acquireRelease(one acquireRelease per resource), tied to the stream's scope. The scope closes on client disconnect (AbortSignal → iterator close → fiber interruption), consumerreturn(), stream failure, and natural completion —off()/removeListener()runs on every exit path.emit.end()maps toQueue.endUnsafe, which drains buffered values before signalling Done (same drain-then-complete contract the old queues had);onEndthen runs and may fail the stream (bootstrap-error surfacing forsubscribeBackgroundBashes).initializeso heartbeats never interleave into history replay (replaceswithQueueHeartbeaton the router).initial: evaluated after attach, delivered before any buffered events — events firing while a snapshot is computed are neither lost nor reordered ahead of it.next()— the same observable contract as the old seam; nothing escapes as an unhandled rejection.validate) runs until the consumer's firstnext(), matching async-generator semantics.The wire adapter (
Stream.toAsyncIterable+ an abort-aware generator wrapper) closes the stream iterator on abort, which interrupts a pending pull immediately — slightly prompter teardown than the old seam (which detached only when the generator resumed), and wire-invisible.Conversion table
subscribeConfigChangesbuffer: "latest"subscribeMemoryChanges(memory.onChange)subscribeProviderConfig,subscribePolicyChangesbuffer: "latest"subscribeDevToolsinitial(async snapshot)subscribeLogsinitial(snapshot captured at attach)subscribeTimelineinitial+ pre-snapshot event bufferingsubscribeWorkspaceChatheartbeat,initialize(history replay)subscribeMetadata,subscribeWorkspaceActivityheartbeatsubscribeBackgroundBashesbuffer: "latest",initialize,emit.end+onEndsubscribeWorkspaceStatsbuffer: "latest",initializesubscribeTerminalOutput,attachTerminalinitial(screen state)subscribeTerminalExittake: 1subscribeTerminalActivityheartbeat,initialsubscribeUpdateStatus,subscribeOpenSettings,subscribeSshPromptsDeferred (with reasons):
createTickIterable(general.tick)WorkflowServiceinternal use ofasyncIterableFromSubscriptionasyncEventIterator.tsetc.) stays intact for it and for browser-side consumers.Validation
streamBridge.test.tspin the genuinely new invariants: listener-count-returns-to-baseline after abort (leak test), teardown on consumer break / stream error / hung-initializeabort /takecompletion (previously unpinned), synchronous-burst emit ordering, latest-value coalescing, initial-before-buffered ordering, drain-then-onEnd-error completion, heartbeat injection, and laziness.make static-checkgreen; fullbun test srcgreen.tests/ipcrun compared against a baseline worktree at the parent commit in the same environment: failure sets are identical (3 suites, all AI-gatewayForbidden/PTY environment failures);websocketHistoryReplayand all other subscription-exercising suites pass with the change.Risks
Medium-surface change: every UI subscription (chat, metadata, terminals, stats, logs, timeline, prompts) now flows through the new bridge. The main regression classes would be ordering (mitigated: synchronous
offerUnsafe, FIFO queues, tests), teardown leaks (mitigated: per-resource acquireRelease + leak tests), and completion/error semantics (mitigated: drain-then-Done queue closing, onEnd contract test, unchanged integration suites). Severity if wrong: stale UI panes or leaked listeners on long-lived sessions.Lessons for Phase 10 (streamManager seams 1–4)
Effect.acquireReleaseper resource composes cleanly underStream.unwrap/scoped effects; the temp-dir create/cleanup pair should become one acquireRelease rather than try/finally, and interruption during acquisition is already safe (release only registers after acquire succeeds).Effect.forkScopedheartbeat ticker here is the template — a debounce fiber owned by the stream/pipeline scope gets interrupted with its owner, removing manual timer bookkeeping. Start such fibers after replay/bootstrap phases if their output must not interleave (same reasoning as heartbeat-after-initialize).unsafeAPIs (Queue.offerUnsafepattern);Refis only worth it where fibers are the mutators.recordUsage-style callbacks are foreign callsites — theSubscriptionEmitpattern (stable function identities wrappingunsafequeue ops, no suspension between callback and buffer) transfers directly.Queue.sliding(1)is an exact latest-value-queue replacement;Queue.endUnsafedrains before Done (stateClosing);Stream.toAsyncIterable.return()memoizes its close promise, so double-close is safe; type the queue's error channel asCause.Doneat creation (Queue.unbounded<T, Cause.Done>()) orendwon't typecheck.Generated with
xum• Model:anthropic:claude-fable-5• Thinking:xhigh• Cost:$15.70