Repository navigation
feat(web): ack a console stream connection with a ready frame - #80
Merged
Merged
Conversation
On a platform with no traffic, "the stream is open" and "the server is
actually running" are indistinguishable to a console client: nothing
arrives on /platform/traces/stream until a request does, so an open
socket to a dead ticker looks exactly like an open socket to a healthy
idle one.
The stream now answers the connection before it answers with data. The
ready frame goes out first, ahead of the initial trace replay, and it
repeats on every EventSource reconnect so a client re-learns the
connect-time facts rather than carrying stale ones across the gap.
It is an ack, not a data frame, so it carries only what a client must
know once at connect time and cannot derive from the stream afterwards:
platform/platformVersion which implementation and which version is on
the other end. Three implementations share
this contract, so a bare version number would
leave the console guessing whose it is. The
version comes from the jar manifest and is
null without one -- a placeholder would be a
fabricated answer to "which version is this".
tracesEnabled separates "recording is off" from "recording
metricsEnabled is on but idle". An empty stream does not.
buffered the rows the following trace frame carries,
not the rows the ring holds. The two diverge
once trace.capacity passes the 200-row replay
cap, and announcing a count while withholding
the rows is worse than announcing neither.
tickMs the push period, so a silence watchdog sizes
itself against a contract, not an assumption.
capacity trace.capacity as of this connection, so a
client can say what is no longer on the
server. It is a live key, hence "as of".
Every value is read at connect time because all four config keys are
live; one cached at startup would be a stale answer to a question the
client only gets to ask once.
Existing clients are unaffected -- EventSource drops named events that
have no listener -- and later versions may add fields, which clients
must ignore.
Co-Authored-By: htjulia <htjulia1@gmail.com>
This was referenced Sep 21, 2026
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.
What & why
On a platform with no traffic, nothing arrives on
/platform/traces/stream— so"the stream is open and the server is healthy" and "the stream is open and the
ticker is dead" look identical to a client. The console was left inferring, and
the inferences were wrong in ways that reached the screen: it read
metrics.length === 0as "metrics are off" although that is equally true whenmetrics are on but idle, and its silence watchdog assumed a 1 Hz push that was
real (
TICK_MS = 1000) but never stated as a contract.The stream now answers the connection before it answers with data. A
readyframe goes out first, ahead of the initial
tracereplay, and repeats on everyEventSourcereconnect so a client re-learns the connect-time facts rather thancarrying stale ones across the gap.
It is an ack, not a data frame, so it carries only what a client must know once
at connect time and cannot derive from the stream afterwards — anything that
changes on a schedule stays in
metrics/modules/summary.Two definitions are worth calling out, because the obvious reading of each is
the wrong one:
bufferedis the rows the followingtraceframe carries, not the rows thering holds. The replay is capped at
INITIAL_TRACES = 200whileprotean.trace.capacityis a live key that can exceed it, so the two diverge.Announcing a count while withholding the rows would be worse than announcing
neither.
TraceStreamE2ETestasserts the equality so it cannot silently rot.platformVersionisnullwithout a jar manifest (exploded classes:tests, IDE runs) rather than a placeholder string — a placeholder would be a
fabricated answer to "which version am I talking to". The field is always
present, so a client never distinguishes "absent" from "unknown".
platformis carried alongside the version because several implementationsshare this wire contract; a bare version number leaves a console unable to tell
whose
0.1.0it holds. It is display and diagnostics only — the docs say not tobranch behaviour on it.
Every value is read at connect time:
trace.enabled,trace.capacityandtrace.metrics.enabledare all live keys inConfigRegistry, so a value cachedat startup would be a stale answer to a question the client only gets to ask
once.
No breaking change.
EventSourcedrops named events that have no listener,so existing clients are unaffected. Later versions may add fields to
ready;the docs state that clients must ignore ones they do not recognize.
Type of change
fix— bug fix (no new API)feat— new capability (dynamic loading, gates, isolation, MCP, debug, data access)perf— performance, no observable behaviour changedocs— documentation onlyrefactor/chore/test/ci— no behaviour changeCorrectness & safety
This change is confined to the SSE transport's connection path: one new record
and four extra lines in
TraceStreamService.open(). It touches no ClassLoader,lifecycle, or gate code, so most of this checklist is genuinely not applicable
rather than skipped — marked N/A with the reason instead of ticked.
here;
open()only reads the trace store and config.TraceStreamE2ETestgained twoassertions that fail on the realistic regressions: the ack arriving out of
order or repeating per tick instead of per connection, and
buffereddisagreeing with the rows actually delivered.
trace.enabled,trace.metrics.enabledandtrace.capacityoff the boundProteanPropertiesin a real@SpringBootTest, not by direct injection,and the test asserts the bound defaults (
true/false/200).Verification
gradle clean testpasses (full regression) — 596 tests, 0 failures, 16 skippedgradle bootJarassembles — plain jar manifest carriesImplementation-Version: 0.0.1-SNAPSHOT, which is whatplatformVersionreads, verified on the built artifact
docs/guide/04-rest-api.md+.ko.md(thereadyfieldcontract),
README.md+.ko.md(endpoint table),CHANGELOG.md+.ko.md. The changelog's "no public API was added" line for Unreleasedwas no longer true and was corrected.
clean testrun above.The 16 container/OS-isolation tests assume a bootJar exists
(
assumeTrue(bootJarExists())), andcleandeletes it, so the documentedorder (
clean test, thenbootJar) skips every one of them. They werere-run against the assembled jar afterwards: 16 tests, 0 failures, 0
skipped. Worth knowing when reading a green
clean test— it is greenover 580 tests, not 596.
Related
Closes #79