Repository navigation
Phase 7b: server-sent events — the stream layer and the serde-boundary gate - #81
Merged
Merged
Conversation
Phase 7b, chapter 13 (SSE-1 through SSE-40; SSE-41 declined for v1). Nine new files under lib/dexpace/sse/ with sig/ mirrors: the namespace with its three limits (MAX_LINE_BYTES 1 MiB, MAX_EVENT_BYTES 8 MiB, MAX_RETRY_MS 2^31-1) and the two frozen Sentinel singletons SKIP and DONE; LineReader, the WHATWG line machine over BufferedSource#getbyte with a one-byte pushback, built there and not over #read_line_utf8 because IO-14 keeps a lone CR as content where SSE-2 terminates on it (P7-20); the immutable five-field Event (Data plus Model); Reader, the field machine whose one persistent state is the BOM flag; Stream, the resource-owning single-pass facade over Closeable's latch, built through .open/.owning/.borrowing with logger: on each; TypedStream over a caller-supplied mapper; and the two namespaced errors. The design's Signal is Sentinel: a bare Signal inside module Dexpace::SSE would shadow Ruby's ::Signal (P7-81). _ByteSource has four methods, close included, because the reader closes its peek view (P7-82). SSE-30's swallowed release failure is reported through Dexpace.close_quietly(self, logger:) as an http.instrumentation.close diagnostic (P7-83). gates:serde_boundary is the eighteenth gate: tools/serde_boundary.rb scans lib/dexpace/sse.rb, lib/dexpace/sse/** and their sig/ mirrors with a parsed scan (prism for every require spelling and every constant read or path, the RBS lexer for type names), asserts every guarded glob matches a file, and prints the pagination layer's globs as PENDING until 7c lands (P7-84). Wired into DEFAULT_GATES, gates:list, the default task's pin and CI's once-per-run gates job, with fixtures under test/fixtures/gates/serde_boundary/. Also: the smoke suite's SSE_LAYER and PhaseSevenLayers case, the surface manifest regenerated once (1137 to 1180 rows, every row read against the object model), and the #read_line_utf8 YARD in io/typed_reads.rb corrected (SSE-11 to SSE-19, and its false premise replaced).
…xits
Review round 0 of phase 7b, findings R0-1 and R0-2.
Reader#dispatch returned nil for a block with no field seen before
reset_block ran, so the block's byte total carried over every blank
line that closed a fieldless block. A run of unknown-field keep-alives,
NUL ids or rejected retries between real events accumulated into
SSE-19's event cap and tore the stream down with a spurious
LimitExceededError(kind: :event) once their sum crossed
max_event_bytes -- at the shipped 8 MiB, after 8 MiB of keep-alives.
SSE-1 makes a blank line the end of a block whether or not it
dispatched, so dispatch now resets the block either way; the Event is
built by a private build_event only when a field was seen, and the
attr_reader's YARD says "reset at every blank line, dispatching or not".
TypedStream#drive_values had the rescue Stream#drive has and not its
ensure, so an Enumerable method that stops early on #values -- first(n),
take, find, each { break } -- left the resource unreleased (closes 0)
where the same call on Stream#events released (closes 1): the raw
enumerator it drives is parked mid-#next and never reaches the Stream's
own ensure. The design names the two shapes as mirrors, and the plan's
own fence drove Stream#drive and inherited its ensure. drive_values now
closes in an ensure of its own: a loud release on the block-form exit,
a no-op on the clean end, a DONE and both failure paths, whose latch has
already flipped. No public name or signature changes; reader.rbs gains
the one private build_event line.
This was referenced Sep 20, 2026
This was referenced Sep 20, 2026
Closed
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.
Part of #27. First PR of phase 7b's three-PR stack — the code — cut from
mainatc53638b(phases 0–6). Phase 7b is one of four lanes built concurrently off that base (7a #26, 7c #28, 8a #30 are the others); it depends on none of them and none of them is on this branch. Its tests are the next PR up and the phase record the one after.What lands
The server-sent-events layer under
Dexpace::SSEand the workspace's eighteenth blocking gate — 55 files, +1,739 / −8 —SSE-1–SSE-40(SSE-41, MAY, declined for v1 perdocs/first-release.md).Dexpace::SSE::LineReader— the WHATWG line machine overDexpace::IO::BufferedSource#getbyteand never 3a's#read_line_utf8(P7-20): the three terminators (\n,\r,\r\nas one),MAX_LINE_BYTES(1 MiB) refused before the line is assembled, over the four-method_ByteSourceinterface (getbyte,skip,peek,close— the close is what lets the reader release its peek view under strict Steep, P7-82).Dexpace::SSE::Event— the immutable five-field value on the phase-1 pattern (include Model,.build,#withre-validating through.build);retrystays the member name (RBS accepts it; Ruby refusesretry = …andsuper(retry: retry), so#initializeforwards with a baresuperand validates the hint through its reader).Dexpace::SSE::Reader— the field machine: the BOM flag as its one persistent state (skipped once, and only as the first three bytes —xyzdata:and\xEF\xBBxdata:are not a BOM), theretry:screen (SSE-11's four ignore triggers plus a width bound of ten digits beforeInteger()ever runs,MAX_RETRY_MS= 2,147,483,647),MAX_EVENT_BYTES(8 MiB) counting every line of the block, comments and unknown fields included, checked before the line is applied and reset at every dispatch — dispatching or not (SSE-1; the fieldless-block reset is round 0's R0-1 repair, inafb9faf).Dexpace::SSE::Stream— the resource-owning single-pass facade:.open(response, logger:),.owning(source, resource:, logger:),.borrowing(source, resource:, logger:);Closeable's latch; the quiet-versus-loud release split — an automatic clean-terminal release failure (natural EOF, the typedDONE) is swallowed and reported out-of-band through the existingDexpace.close_quietly(self, logger:)(5b's route emitshttp.instrumentation.close; no second emission path, never the payload — P7-83), while an explicit#closepropagates (SSE-30); every block-form exit,Enumerable#first(n)included, closes loudly through#drive'sensure; the onerescue ::Exceptionlives in#driveand#advancehas none (P7-86).SSE-31's cross-thread close is proven overBufferedSource.wrapping(IO.pipe)with the reader parked, on every interpreter.Dexpace::SSE::TypedStream— the mapper adapter overStream#typed(its.newis private): the mapper's value is yielded,SKIPdrops the event,DONEends iteration and closes without yielding (SSE-34); the block form drivesStream#eachwith no fiber, the external form (#values) drives the raw enumerator — and releases onfirst(n)/take/find/each { break }exactly asStream#eventsdoes (round 0's R0-2 repair, also inafb9faf).Dexpace::SSE::Sentinel— the two singletons' type, renamed from the design'sSignal, which would have shadowed Ruby's core::Signalinside the namespace on every supported interpreter (P7-81; the plan asserted it "shadows nothing Ruby-owned")..new/.[]private, no public.build,#withrefuses,#pretty_printadded;SKIPandDONEare minted insse.rb.Dexpace::SSE::LimitExceededError(#kind:line/:event,#limit) andDexpace::SSE::StreamStateError(SSE-26's illegal-state error for a second view —< ::StandardError,include Dexpace::Error; 7c'sPAGE-14error takes the same shape by the manager's decision, closing the argument-family half of phase 10's inbound bullet on the two single-use latches).gates:serde_boundary—SSE-37's mechanised MUST as the eighteenth gate, in the tools/ shape:tools/serde_boundary.rbruns a parsed scan (Prism through phase 0'sRequireScanfor Ruby, the RBS lexer'stUIDENTtokens forsig/) overlib/dexpace/sse/**andsig/dexpace/sse/**, so theSSE-37YARD the requirement demands is not a false positive; GUARDED asserts at least one match;page/**is listed PENDING and printed (7c's tree is not on this base — the reconcile pass flips it to GUARDED). Wired into the rootRakefile'sDEFAULT_GATESaftergates:require_allowlist,test/gates/default_task_test.rb'sEXPECTED, and CI's once-per-rungatesjob (not the matrix); twenty-one fixtures undertest/fixtures/gates/serde_boundary/.SSE-38's "no inbound retry" is asserted by test.sig/mirrorslib/one file per file; the manifest regenerated once, 1,137 → 1,180 (+43), every row read against the object model (P7-85). Earlier-phase files changed:lib/dexpace.rb(one appended# Phase 7b:block of nine requires),io/typed_reads.rb(the#read_line_utf8YARD only —SSE-11→SSE-19, its false "phase 7's SSE machine is the caller" premise replaced),test/dexpace_test.rb's LAYERS table,test/gates/default_task_test.rb(eighteen for seventeen).Decisions taken in the open, against the plan's text
Ledger rows P7-81–P7-86 in the design's As-built addendum and the checklist's "Deviations from the plan" (36 items). The load-bearing ones beyond those above: the plan has thirteen tasks, not fourteen (the brief's count was wrong; nothing renumbered); its Task 1 would have run
mise use -gand was not; its five doubles collapse to three top-level ones (SSEFixtures,ScriptedChunked,FakeByteSource) beside the tree's ownFakeResponseBody,FakeChunkedand a realResponse; the at-scale cap tests run overFakeByteSource(0.3 µs/byte) because the#getbytepath costs ~0.9 µs/byte throughBufferedSource(routed to phase 10 with the numbers, not fixed here — the design fixes#getbyteas the primitive);Model.ownhands an already deep-frozen list back as it is, so a derivedEventmay share its parent's list (the observable clause — nothing mutable shared, validation on derivation — holds; the design'sSSE-20sentence is narrowed in the addendum).Layering
Each tip is green under every gate on its own tree. This branch is green on the SimpleCov floor too — 97.53% on 4.0.6 (2,711 runs, 0 failures) and 97.62% on 3.2.11; the tests PR takes the same tree to 99.98% with 2,931 runs / 68,817 assertions and 0 skips.
Verification
LimitExceededError;TypedStream#valuesnot releasing on an early exit) — fixed inafb9fafand each proven by the round-1 reviewer's own experiment and a control mutation; round 1 approve, 0 / 0 / 2 nits.--ignore-parent-exclusion, 513 files) clean; probe clean at the docs tip.Data#withcalling#initializeon 4.0.6 but not 3.2.11, where the same guard is red).data: a\xFFb→a�b, and the retag-less form that would yieldcaf��); the SSE-31 cross-thread close over a real pipe; the 8 MiB event cap at its real value with RSS measured; every ```ruby block ofdocs/sdk-documentation/sse.mdexecuted and its `# =>` compared (55/55 on 4.0.6, 54/55 on 3.2.11 — the one the page's preamble documents, the private-`new` message wording); `rbs validate` on the floor for the `retry` member and the recursive `_ByteSource`.Known follow-ups from the final review (not blocking a gate)
sse/reader.rb:216: the ten-digit width bound refuses an 11+-digit zero-paddedretry:whose magnitude is in range (00000000005→ unset;0000000005→ 5). WHATWG parses a digits-only run as base ten andSSE-11's four ignore triggers do not name it; no real server emits it and unset is the conservative outcome. A leading-zero byte scan before the width check closes it; recorded here rather than re-cutting the stack.sse.md:107says#with"copies [the data list] again"; as built a derivedEventshares its parent's frozen list (the checklist and the addendum say so correctly).47331d1) says "fourteen suites"; there are eleven undertest/dexpace/sse/plussse_test.rb— corrected in the follow-up commit's body, never amended.BufferedSource.over's enumerator restarts#eachafter a mid-stream failure (a second read re-delivers the body's first bytes rather thannilor a second raise — on every row; the facade is shielded bySSE-27's closed check); the#getbytethroughput above.