Skip to content

feat(web): ack a console stream connection with a ready frame - #80

Merged
htcom-code merged 1 commit into
mainfrom
feat/sse-ready-ack-frame
Sep 21, 2026
Merged

htcom-code merged 1 commit into
mainfrom
feat/sse-ready-ack-frame

Conversation

@htcom-code

Copy link
Copy Markdown
Owner

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 === 0 as "metrics are off" although that is equally true when
metrics 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 ready
frame goes out first, ahead of the initial trace replay, and repeats on every
EventSource reconnect so a client re-learns the connect-time facts rather than
carrying stale ones across the gap.

event: ready
data: {"platform":"protean","platformVersion":"0.1.0","tracesEnabled":true,
       "metricsEnabled":false,"buffered":5,"tickMs":1000,"capacity":200}

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:

  • buffered is the rows the following trace frame carries, not the rows the
    ring holds.
    The replay is capped at INITIAL_TRACES = 200 while
    protean.trace.capacity is a live key that can exceed it, so the two diverge.
    Announcing a count while withholding the rows would be worse than announcing
    neither. TraceStreamE2ETest asserts the equality so it cannot silently rot.
  • platformVersion is null without 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".

platform is carried alongside the version because several implementations
share this wire contract; a bare version number leaves a console unable to tell
whose 0.1.0 it holds. It is display and diagnostics only — the docs say not to
branch behaviour on it.

Every value is read at connect time: trace.enabled, trace.capacity and
trace.metrics.enabled are all live keys in ConfigRegistry, so a value cached
at startup would be a stale answer to a question the client only gets to ask
once.

No breaking change. EventSource drops 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 change
  • docs — documentation only
  • refactor / chore / test / ci — no behaviour change

Correctness & 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.

  • N/A — Unload path: not touched. No ClassLoader is created, held, or released
    here; open() only reads the trace store and config.
  • Added/updated exception-case tests — TraceStreamE2ETest gained two
    assertions that fail on the realistic regressions: the ack arriving out of
    order or repeating per tick instead of per connection, and buffered
    disagreeing with the rows actually delivered.
  • N/A — Promotion gates: not touched.
  • Config gate keys verified through the binding path — the ack reads
    trace.enabled, trace.metrics.enabled and trace.capacity off the bound
    ProteanProperties in a real @SpringBootTest, not by direct injection,
    and the test asserts the bound defaults (true / false / 200).

Verification

  • gradle clean test passes (full regression) — 596 tests, 0 failures, 16 skipped
  • gradle bootJar assembles — plain jar manifest carries
    Implementation-Version: 0.0.1-SNAPSHOT, which is what platformVersion
    reads, verified on the built artifact
  • Docs updated — docs/guide/04-rest-api.md + .ko.md (the ready field
    contract), README.md + .ko.md (endpoint table), CHANGELOG.md +
    .ko.md. The changelog's "no public API was added" line for Unreleased
    was no longer true and was corrected.
  • Docker-dependent tests ran — but not in the clean test run above.
    The 16 container/OS-isolation tests assume a bootJar exists
    (assumeTrue(bootJarExists())), and clean deletes it, so the documented
    order (clean test, then bootJar) skips every one of them. They were
    re-run against the assembled jar afterwards: 16 tests, 0 failures, 0
    skipped. Worth knowing when reading a green clean test — it is green
    over 580 tests, not 596.

Related

Closes #79

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

SSE stream: acknowledge a console connection with a ready frame

1 participant