Skip to content

feat(realtime): cross-device realtime sync — protocol v1.0.0 + server/daemon/apple - #3

Closed
QuintinShaw wants to merge 72 commits into
mainfrom
integration/realtime
Closed

feat(realtime): cross-device realtime sync — protocol v1.0.0 + server/daemon/apple#3
QuintinShaw wants to merge 72 commits into
mainfrom
integration/realtime

Conversation

@QuintinShaw

Copy link
Copy Markdown
Owner

Summary

  • Realtime protocol v1.0.0 (proto/realtime/): transport-agnostic envelope, 14 message types, device_seq dedup, space_revision delta chain, snapshot chunking, interest leases, command channel. Frozen at main@a8ed3f4; 81 fixtures + scenario invariants (proto/realtime/tools).
  • Server: /v3/realtime on existing Worker, new SQLite-backed SpaceHub DO (Hibernatable WebSockets, serialized attachments, auto-response ping/pong), push_outbox only; /v2 untouched.
  • Daemon (Go): crash-safe SQLite outbox (seq alloc + enqueue in one tx), single-writer send loop preserving device_seq wire order, feature-flag opt-in (default off), HTTP fallback when flag off.
  • Apple (SwiftUI): RealtimeClient actor with generation-scoped teardown, resume gate state machine, atomic snapshot staging, phase-gated HTTP refresh.
  • Integration evidence: docs/design/realtime-integration.md (contract audit + real E2E via wrangler dev).

Validation

  • proto validator: 81/81
  • server: typecheck clean; 88 node:test + 33 vitest-pool-workers
  • daemon: go test ./... -race clean, 39 top-level tests
  • apple: SitrepKit 30/30 swift test; SitrepApp xcodebuild + SitrepMenuBar build green
  • Real E2E: wrangler dev + Go client lifecycle (hello→event→ack→reconnect→outbox replay) + node viewer smoke (snapshot→delta revision chain, metric fan-out)
  • Reviews: 3 per-line adversarial reviews, cross-line contract audit (6 interface points, zero mismatches), whole-system adversarial review (7 invariants verified; 2 MAJORs found and fixed: superseded security logging 6677acb, device_seq wire-order serialization 2e5cad7)

Risk

  • Draft: pre-merge external review findings in progress (capability negotiation realtime_enabled, outbox-full backpressure model, observability sampling config, DO migration version table, metricsCache LRU cap, superseded log ordering). Merge blocked until fixed + re-reviewed.
  • No production deploy, no old-DO deletion, no destructive migration in this PR. Daemon realtime defaults OFF.

Add a Swift mirror of proto/realtime/ (envelope + 14 message types),
SpaceState with the SPEC.md section 6.4 deterministic folding rules, a
RealtimeResumeGate implementing the section 6.2/6.3 resume/delta/snapshot
gating as a transport-agnostic state machine, and a RealtimeClient actor
that drives one WebSocket connection through hello -> subscribe -> resume,
interest-lease renewal, ping/pong, and reconnect with backoff. Viewer-role
only, matching SitrepApp's use as an observer rather than a task source.
Add fixture-driven SitrepKit tests against proto/realtime/fixtures/
(loaded directly from the frozen protocol directory, never copied): every
valid fixture and every scenario message decodes and round-trips; every
invalid fixture fails to decode or is rejected by the client authorization
matrix. RealtimeResumeGateScenarioTests drives the resume/delta/snapshot
gate with the four viewer-relevant scenario directories in file order.
SpaceStateFoldingTests asserts delta-by-delta replay and a direct snapshot
converge on identical task/automation state per SPEC.md section 6.4.
Wire SitrepApp's AppModel to SitrepKit's RealtimeClient: tasks, metrics,
messages, and automations now update live from SpaceState deltas while the
app is foreground, replacing the always-on 3s HTTP poll. The poll survives
only as a low-frequency (>=30s) fallback that engages after repeated
WebSocket backoff failures and disengages once the realtime connection
recovers. scenePhase drives connection lifecycle deterministically:
.active (re)connects and revives anything silently killed while suspended,
.background closes the connection and lets the interest lease lapse.

Add RealtimeUIBridge in SitrepKit adapting the realtime wire model to the
existing poll-era UI types (TaskState/MetricState/EventLogEntry/
AutomationInfo) so NowView/DashboardView/HistoryView need no changes.
Add public initializers to AutomationInfo/Executor/Schedule to support
that construction from outside SitrepKit.

MainTabView gains a subtle sync-status strip (reusing the presence-pill
dot+caption language, not a new visual system) that only appears while
not live, and a one-shot, tap-or-6s-dismiss banner for SPEC.md section 9.4
`superseded`.
Add internal/realtime/wire, a Go mirror of proto/realtime's frozen JSON
Schemas: the strict envelope (type/id/ts/body), one struct per message
body, and validation matching the schema's required fields, enums,
ranges, and conditional requirements. Round-trips every fixture under
proto/realtime/fixtures/valid and fixtures/scenarios through decode ->
re-encode and asserts semantic equality with the original, and asserts
every fixture under fixtures/invalid is rejected (including the
sender_role-wrapped authorization-matrix cases via a new Authorize
function mirroring SPEC.md section 10.1).

proto/realtime/ itself is untouched; this package only depends on it as
a read-only source of truth.
Add internal/realtime/outbox, a SQLite-backed (modernc.org/sqlite, pure
Go, no cgo) local queue for task.event/message.event delivery. It owns
the per-(device, space) device_seq counter from SPEC.md section 5.1:
allocating the next sequence number and durably persisting the event it
belongs to happen in one transaction, so a crash between the two is
impossible to observe as a gap or a double-allocation. Enqueue/Ack/
Pending give a source device exactly what section 5.3/5.4 need: keep
every unacked event until its ack arrives, and replay outstanding ones
oldest-first after a reconnect or a process restart.

Tests cover monotonic and concurrent-safe allocation, the crash-safety
property (an aborted transaction must not skip a sequence number),
ack-then-delete, oldest-first ordering, and full survival across a
simulated process restart (close and reopen the same database file).
Add the metric batcher for internal/realtime/client: merges multiple
pending updates to the same metric_id into a single latest sample
before emission (SPEC.md section 12), and gates emission at a bounded
cadence — no metric_id is re-included more than once per 500ms (the
protocol's 2 Hz per-metric ceiling), and the whole batcher only flushes
at its own current interval, switchable between a normal and a slower
"throttled" cadence for the command{throttle}/{resume_rate} pair from
SPEC.md section 7. A sample offered while nothing is due simply
overwrites the pending entry for its metric_id rather than queueing,
matching metric.frame's best-effort, never-persisted, never-retried
nature (section 12): only the latest value survives to be sent.

It is a pure, clock-driven data structure with no timer of its own, so
every merge/rate-limit/throttle-switch behavior is tested by calling
Flush with an explicit instant — no sleeping real durations.
Per protocol-owner ruling, the cross-implementation route convention for
the realtime WebSocket channel is /v3/realtime, matching the server
implementation — not the /v2/realtime path this client previously assumed
from the REST surface's prefix. Update the derived URL and the doc comment
accordingly.
Add internal/realtime/client, the reconnecting WebSocket source
connection (github.com/coder/websocket, pure Go): the mandatory
hello{offer}/hello{accept} sequence with role "source", exponential
backoff with jitter on any drop (doubling from a 1s base, capped at
60s, +/-20%; stretched to the cap instead of reconnecting aggressively
after a superseded error, since that means our own newer connection
already won or the credential is compromised), the ping/pong heartbeat
with silent-peer detection, and command handling: TTL validation
against the local clock with the +/-30s skew allowance from SPEC.md
section 8, command_id dedup, and dispatch of throttle/resume_rate into
the metric batcher (other actions forward to an optional callback for
future process-control wiring).

Every reliable send (SendTaskEvent/SendMessageEvent) goes through the
outbox first and is replayed oldest-first, in a fresh envelope but with
its original device_seq, immediately after each reconnect's hello
completes (SPEC.md section 5.4) — on top of the outbox's own
already-tested crash safety, this is where that guarantee actually
reaches the wire.

Add internal/realtime/rttest, a minimal scriptable mock WebSocket
server used only by this package's tests to drive hello/ack/drop/
command scenarios without a real server dependency; it intentionally
does not persist state or model space_revision and must never be
described as a self-hosted server implementation.

Backoff is a pure, unexported-state-free function tested without any
sleeping; the reconnect/replay/throttle/command behaviors are tested
end-to-end against rttest.
Wire internal/realtime/client into the existing HTTP uplink and the
resident `sitrep agent` process, off by default:

- config: SITREP_REALTIME (or config.json's realtime_enabled) opts in;
  SITREP_REALTIME_URL overrides the wss://<server>/v2/realtime endpoint
  this package otherwise derives from the existing server URL.
- uplink.Config gains an optional Realtime field. When set,
  Offer() routes every kind with a realtime-protocol equivalent (task
  lifecycle -> task.event, message.send -> message.event, metric.update
  -> metric.frame) to it instead of the HTTP /v2/ingest batch, so the
  same event is never sent both ways; task.log (no realtime equivalent)
  always continues over HTTP. Nil Realtime (the default) leaves this
  package's behavior byte-for-byte unchanged.
- cmdAgent builds one shared realtime client (and its SQLite outbox,
  next to the existing config.json) for the whole process when the
  flag is on, reused across every automation run. This mirrors the
  protocol's "at most one connection per device" model (SPEC.md section
  9.4); the one-shot `sitrep run` CLI is intentionally left on the
  existing HTTP-only path; see the handoff notes for why.

Covered by a new integration test asserting the flag routes task/
message/metric events to a mock realtime server and never double-sends
them over HTTP, and that the flag left off reproduces prior behavior
exactly.
The protocol owner settled the realtime WebSocket route on
/v3/realtime (the path the server implementation exposes; the Apple
client targets the same). Update RealtimeURLFor and its documentation
from the provisional /v2/realtime convention, and add a test pinning
the derived endpoint (scheme mapping, trailing-slash normalization,
and the explicit RealtimeURL override).
Implement SpaceHub, a per-space SQLite-backed Durable Object that
speaks the frozen proto/realtime/SPEC.md v1 protocol: hello/subscribe/
resume connection gating, per-(device,space) device_seq dedup for
task.event/message.event, deterministic event folding into tasks/
messages/automations tables, chunked snapshot and chained catch-up
delta replies, server-minted config.event, interest leases with lazy
1<->0 edge detection driving throttle/resume_rate, and in-memory-only
best-effort metric.frame relay. Uses ctx.acceptWebSocket with
per-connection attachments (never DO-instance memory) so identity and
handshake state survive hibernation/eviction, and
ctx.setWebSocketAutoResponse for the ping/pong heartbeat pair.

Also adds the hand-written TS protocol types and runtime guards
(server/src/realtime/types.ts, guards.ts) mirroring proto/realtime/
schemas field-for-field, plus pure chunking/row-mapping helpers.
Add the SPACE_HUB Durable Object binding (new_sqlite_classes migration
v2, alongside UserStore's v1) and wire a /v3/realtime WebSocket
upgrade route plus a minimal /v3/automations HTTP control-plane
companion (upsert/pause/remove, mints a config.event via
SpaceHub#mintConfigEvent) into the Workers adapter.

The Worker does exactly three things per request: reuse the existing
st2 token authenticate() middleware (extended to also cover /v3/*, no
behavior change for /v2/*) to resolve role/space/deviceId, reject an
invalid or unresolvable token with 401 before ever touching SpaceHub,
and forward the upgrade to that space's SpaceHub stub exactly once
with the resolved identity attached as trusted headers. /v2/* stays
untouched as the existing fallback.
Add the second test runway: @cloudflare/vitest-pool-workers running
inside local workerd (test:workers), alongside the existing node
--test suite (test:unit); `npm test` runs both. Lockfile updated and
committed.

Coverage: fixture round-trip decode/encode against every
proto/realtime/fixtures/valid and invalid fixture (including the
role-wrapped authorization-only ones) and pure chunking-boundary
tests under test:unit; connection identity recovery via fresh
connections/stub references, duplicate device_seq dedup (no double
apply, no double revision, still acks), the full resume decision
table (snapshot/current-empty-delta/revision_unavailable/retention-
miss-falls-back-to-snapshot), push_outbox idempotency, metric.frame's
zero-SQL-write guarantee and multi-viewer broadcast, chunked-snapshot
ordering under a racing live event, interest-lease throttle/
resume_rate edges, connection gating (hello_required, resume-before-
subscribe malformed, no live delta before a connection's own resume
reply), connection supersession, and the Worker-layer invalid-token/
reconnect-storm guard that never instantiates a SpaceHub under
test:workers.
Cap hot-path per-frame logging in SpaceHub to <=1% of messages
(injectable sampler, so tests can force full logging), while every
protocol_error and ws_error path continues to log unconditionally.
Avoids a console.log per inbound frame on a busy connection.
Add docs/design/realtime-server.md: a per-message-type table of SQL
writes / revision++ / outbox / broadcast aligned with
proto/realtime/SPEC.md section 14, a Durable Object billing-axis
analysis (requests/duration/storage), and the specific conditions
under which SpaceHub can hibernate (no timers/alarms anywhere; lazy
lease-edge detection; attachment-only connection identity).
Address three adversarial-review findings in the SitrepKit client layer:

- Terminal errors stop reconnecting (F2): a server error carrying
  fatal:true and retryable:false (superseded, unauthenticated,
  version_unsupported) now ends the run loop with the .failed phase
  preserved instead of feeding the backoff/reconnect cycle; a later
  start() — user action or the next foreground pass — begins a fresh
  cycle. Handshake- and subscribe-stage server errors route through
  handleError so their notices surface and their retryable/fatal
  semantics reach the loop. A run-generation token stops a stale loop
  from clobbering its successor's task handle.

- Chunked-snapshot interleaving is malformed (F3): per SPEC.md 6.2, any
  non-snapshot envelope arriving while snapshot chunks are being
  reassembled (ping/pong never reach dispatch) now discards the buffered
  chunks and closes the connection for a fresh resume, in both the gate
  and the client dispatch path.

- Malformed frames are contained, not fatal (F5): a frame that fails to
  decode is skipped per SPEC.md 13 (malformed is non-fatal) and only a
  run of 5 consecutive malformed frames trips a circuit breaker that
  reconnects; the DeltaEvent wrapper now ignores unknown sibling fields,
  matching the body-level tolerance of SPEC.md 15.

Also adds Phase.allowsReliableStateOverwrite, the policy the app layer
uses to keep HTTP snapshot results from clobbering live delta state
(applied in the follow-up commit).
MetricSample.Validate now enforces the metric_id pattern from
common.schema.json#/$defs/metric_id (^[a-z0-9_.-]{1,64}$), which also
makes an empty id invalid. Previously only the value/label caps and the
timestamp bound were checked, so a malformed id could slip into an
outbound metric.frame. Add a test covering valid ids and the empty/
uppercase/whitespace/non-ascii/over-length rejections.
Cap the outbox's total row count (default 5000, OpenWithMaxRows for an
explicit value). At the cap Enqueue fails with ErrOutboxFull inside the
same transaction as the insert — no device_seq is consumed, no row is
written — so the existing enqueue-failure fallback in the uplink routes
the event over the HTTP ingest path instead: reliability does not
degrade while the server is unreachable, and local disk usage stays
bounded. Once acks drain the backlog, capacity returns and subsequent
events take the realtime path again. Policy documented on ErrOutboxFull
and routeToRealtime.

Tests: outbox-level (ErrOutboxFull at cap, no seq consumed, capacity
restored by an ack) and an end-to-end uplink test (overflow event
travels HTTP while the outbox is full, realtime resumes after the
backlog drains, non-overflow events never appear on HTTP).
Two adversarial-review findings in the app wiring:

- HTTP refresh no longer bypasses realtime state (F1): while the
  WebSocket phase is .live, refresh() updates only REST-only concepts
  (presence, lastSyncAt) and leaves the four reliable collections
  (tasks/metrics/events/automations) to deltas. The liveness check runs
  AFTER the await returns, so an in-flight response — a foreground
  force-refresh, the post-updateMetric refresh, or a fallback-poll
  response landing after .recovered — can never clobber newer delta
  state. An HTTP failure while live no longer raises the error banner.

- Foreground cycles resume incrementally (F4): AppModel keeps the last
  SpaceState (with revision C) for the process lifetime and seeds the
  rebuilt RealtimeClient with it, so returning to the foreground resumes
  with last_revision C — an incremental delta or empty you-are-current
  reply — instead of pulling a full snapshot every time. The preserved
  state is dropped on disconnect and on credential change, where its
  revision sequence no longer applies.
Tighten and extend the SitrepKit suite per review (F6):

- Every schema-invalid fixture now asserts the SPECIFIC constraint it
  trips (substring of the thrown error), so a fixture failing for the
  wrong reason is a test failure; an unmapped new fixture fails loudly.
- New RealtimeRegressionTests cover the review fixes: only .live blocks
  HTTP overwrite of reliable state and the check re-runs after the await
  (F1, incl. the in-flight race); a delta or any other envelope
  interleaved during a chunked snapshot is a malformed sequence that
  discards the buffer (F3); a reconnect seeded with the preserved
  SpaceState resumes incrementally from C and RealtimeClient honors its
  initialState (F4); the delta event wrapper ignores unknown sibling
  fields (F5).
- New folding boundary test: an event without step/title leaves the
  previous values unchanged, while done still clears step (SPEC.md 6.4).
Two handshake outcomes previously fed the reconnect loop forever even
though a retry could only fail identically:

- an error reply that is fatal AND retryable:false (version_unsupported,
  unauthenticated, ... — SPEC.md section 13's flags are authoritative)
- a hello accept naming a protocol version the offer never contained,
  which section 9.2's intersection rule makes a failed negotiation

Both now surface a "giving up, not retryable" log and terminate the
reconnect loop; recovery is an explicit restart (a new Client). Tests
cover both paths by counting connections at the mock server: exactly
one attempt, no loop.
SPEC.md sections 5.3/13 require every pair in an ack to carry the
receiving connection's authenticated device_id, and a pair for any
other device makes the WHOLE envelope malformed. The client previously
skipped foreign pairs but still honored the matching ones; now it drops
the entire ack without retiring anything from the outbox (resend keeps
this safe — the events stay queued for a later well-formed ack),
reports the violation back with error{malformed, retryable, non-fatal},
and keeps the connection.

Extract the error-envelope write into a shared sendError helper (also
used by the command_expired path). Test: the mock server sends an ack
mixing one matching and one foreign pair — nothing is deleted — then a
well-formed ack on the same connection still retires the event.
Fix the agreed semantics in a regression test: a source that was told
to throttle keeps its throttled metric cadence across a connection drop
(it does not optimistically resume the fast rate on reconnect), and the
server-side remedy — re-sending the space's current interest state
after the new connection's hello completes — takes effect immediately:
a resume_rate on the second connection restores the normal cadence.
The hello offer schema declares uniqueItems on protocol_versions and
the subscribe/interest.renew schema declares it on topics; the
hand-written guards accepted duplicates, deviating from the frozen
proto/realtime schemas they claim to mirror. Enforce both.
Wrap SpaceHub's webSocketMessage dispatch (and webSocketClose) in a
top-level guard: an unexpected handler throw now logs in full and
answers the frame with error{internal_error, retryable, non-fatal}
whose message carries no stack traces or internal paths (SPEC.md
section 13), instead of surfacing as an uncaught DO exception. Also
remove reply()'s never-taken internal_error log exclusion so every
outbound protocol error is logged unconditionally. Covered by a test
that injects a handler fault and verifies the connection survives.
SPEC.md section 7's throttle/resume_rate are pure edge triggers, so a
source that was offline when the space's lease count crossed its 1<->0
edge would stay stuck at its last-known rate forever. Immediately
after a source's hello accept, SpaceHub now unicasts the CURRENT
interest state as command{origin:server} (throttle when no unexpired
lease exists, resume_rate otherwise) with a fresh command_id — no new
message type and zero protocol schema changes; flagged in-code as a
v1.1 clarification candidate per the protocol owner's ruling. Tests
cover both the initial throttle and the reconnect-after-missed-edge
resume_rate, and the shared helpers drain the new post-hello command.
SPEC.md section 12's discard-stale-samples rule was keyed off a
per-WebSocket rate-limiter map, so a reconnecting or second source
could replay an older sample past a fresher one. The baseline now
lives in the space-level metricsCache (keyed by metric_id alone, same
lifetime and best-effort semantics as the rest of that cache — reset
after eviction is acceptable per section 6.2). Test interleaves two
source connections on one metric_id and asserts the older ts is
dropped and a genuinely fresher one still flows.
Four rulings from the adversarial review, all on the /v3/automations
surface:

- Idempotency-Key is now bound to request content: mintConfigEvent
  stores a canonical-JSON fingerprint of (kind, automation_id,
  automation) and compares it on replay; the same key reused for a
  different operation returns 409 instead of silently replaying the
  first operation's result.
- http_idempotency growth is bounded lazily on its own write path (no
  alarm): entries older than 24h are dropped and the table is capped
  at the 500 most recent rows; documented in the cost model.
- Role matrix finalized per the protocol owner (aligned with "watcher
  schedules are editable from every device, creation only from
  trusted devices"): POST = owner/admin; PATCH = owner/admin/viewer
  (viewer editing schedules is intentional); DELETE now also allows
  viewer, matching /v2 canView.
- DELETE of a nonexistent automation returns 404 before minting, so a
  no-op removal cannot burn a revision (same pattern as PATCH); the
  /v3 HTTP handlers also gained the internal_error guard (500 with no
  internal details, full server-side log).

Tests cover the 409 conflict, viewer PATCH/DELETE success + POST 403,
and the 404-without-revision-burn invariant.
Note in the cost model why the 10 s handshake timeout (SPEC.md
section 9.1, SHOULD) is deliberately not enforced server-side (it
would require the per-connection timer machinery this design excludes
to stay hibernation-friendly, and the exposure of an offer-less
connection is bounded), and why the advisory sequence_gap error
(section 5.1, MAY) is not emitted.
The runGeneration token previously protected only the runTask handle: a
stale connectOnce() unwinding after a stop()/start() cycle on the same
RealtimeClient instance would run its deferred teardownConnection() and
cancel the NEWER generation's socket, lease-renewal task, and watchdog.
Unreachable in the app today (it rebuilds the client each cycle) but a
hole in the class's own public contract.

Teardown of the shared resources now requires the requesting generation
to still be current (ConnectionTeardownPolicy, pure and unit-tested); a
stale generation limits itself to closing the socket it created and
holds by reference. scheduleLeaseRenewal and startHeartbeat gain the
same guard so a stale dispatch cannot replace the current generation's
tasks either. Adds a stop-then-start same-instance regression test
against a refused local port (no real network).
"observability": { "enabled": true } alone doesn't bound per-invocation
telemetry, so the documented cost model wasn't real. Disable the
platform's automatic invocation logs and keep structured logs unsampled
(logs.head_sampling_rate: 1) so logAlways() security events (superseded,
protocol errors) are never head-sampled away — volume is bounded instead
by SpaceHub's existing in-code logSampler. Traces, which have no
logAlways equivalent, are head-sampled at 1%. Update the cost model doc
with the final config and revised estimate.
SpaceSnapshot gains realtime_enabled (decode-if-present, default false)
so old/undeployed servers that never send the field are treated as
"no realtime" rather than inferred from URL shape. RealtimeCapabilityGate
is pure decision logic (connect/disconnect/none) kept in SitrepKit so the
P0 gate is unit-testable without a live socket, and RealtimeClient itself
stays capability-agnostic.
The app previously derived /v3/realtime from the REST URL and connected
unconditionally once paired and foregrounded, so a server without
realtime enabled could serve a valid legacy /v2 snapshot and then have
the UI clobbered by an empty/failed connection state.

AppModel now owns a RealtimeCapabilityGate, fed by realtime_enabled on
every successful refresh(). startRealtime() is gated on it, so the
connection only ever opens once a refresh has confirmed capability;
cold start (before any refresh) and legacy/absent responses default to
off. If a later refresh reports false/absent while connected, the
existing generation-scoped teardown tears the connection down and the
app falls back to plain HTTP refresh with no reconnect attempts until a
later refresh says true again. Re-pairing (saveSettings) and
disconnect() reset the gate since a new identity must prove capability
again. MainTabView's sync strip needed no change: gating off keeps
connectionPhase at .idle, which already renders as plain HTTP mode.
DO constructors re-run on every hibernation wake, and migrate() was
unconditionally re-issuing 10+ CREATE TABLE/INDEX IF NOT EXISTS
statements each time — conflicting with the sparse-connection cost goals
in docs/design/realtime-server.md. Add a _schema_migrations version
table: migrate() now does one lightweight SELECT and only runs (and
writes) the DDL block when the store is behind, including the fresh-DO
case where the version table doesn't exist yet.
metricsCache had per-frame size/rate limits but no cap on distinct
metric_ids, so a compromised or misbehaving source rotating metric_ids
could grow DO memory without bound until the instance crashes. Add
METRIC_CACHE_MAX_METRICS (256, protocol.ts) and evict the
least-recently-updated metric_id when a new one would exceed it.
Eviction is safe per SPEC.md 6.2 (snapshot.metrics is a best-effort
cache); evicted metrics are simply absent from the next snapshot.
handlePreHello sent the superseded peer's error frame before calling
logAlways(), so a throwing send() would skip the security log entirely.
Reorder so the 100%-sampled logAlways fires first; the wire frame and
ordering toward the client are unchanged in the success path (still
error frame, then close).
… schema versioning, metric cache cap, log ordering)
Cloudflare dashboard variable overrides always arrive as strings at
runtime, even though wrangler.jsonc declares REALTIME_ENABLED as a
boolean. Boolean("false") is true, so an operator typing "false" in the
dashboard would silently re-enable the realtime kill switch. Add
parseRealtimeEnabledFlag() as a strict allow-list (true / "true" / "1",
case-insensitive, trimmed) and route the worker adapter through it;
widen the Secrets type to string | boolean to match the real runtime
shape. Covered by new unit tests in test/app.test.ts.
schemaVersion()'s bare catch treated any SQL failure — including
corruption or I/O errors — as "fresh DO, run migrations" (version 0).
The DDL is IF NOT EXISTS so a genuinely fresh DO re-running it is
benign, but misdiagnosing real corruption this way hides the failure.
Only swallow the specific "no such table" error that means the
migrations table doesn't exist yet; rethrow anything else.
…tinel

Enqueue's BuildBody closures call body.Validate() before the insert, but a
validation failure was indistinguishable from a transient BeginTx/COUNT/
INSERT/Commit error (SQLITE_BUSY, disk I/O) once it came back as a plain
error. Wrap it in ErrInvalidBody so callers can tell "this body can never
validate, drop it" apart from "this Enqueue attempt failed, retry it" via
errors.Is.
Two bugs in the outbox-backpressure path:

- BLOCKER: sendReliable always attempted Enqueue for a new event, even
  with older items already waiting in rtRetry. A freed outbox row between
  an older event's failed attempt and a newer event's Offer() let the
  newer one grab a lower device_seq, inverting wire order once the older
  one was retried. Fix: a dedicated rtMu now guards the whole
  check-then-act step in sendReliable (queue non-empty -> append behind
  it; queue empty -> attempt Enqueue) and retryRealtime's whole drain, so
  the two can never interleave.

- MAJOR: any non-ErrOutboxFull Enqueue failure was logged and dropped,
  which silently discarded a reliable task.done/message.send on a
  transient SQLite error (SQLITE_BUSY, disk I/O). isRetryable now treats
  only rtclient.ErrInvalidBody as permanent (retrying the same malformed
  body can't help); everything else, including unrecognized errors,
  defaults to retry.

Adds regression tests for both: a deterministic FIFO-interleaving test
that drives sendReliable/retryRealtime directly (no sleeps), a transient-
error test asserting eventual delivery, and a permanent-error test
asserting drop-not-retry.
REALTIME_ENABLED=false only suppressed realtime_enabled in /v2/snapshot;
the /v3/realtime upgrade route accepted connections regardless, so a
daemon with its local flag on kept writing to SpaceHub while viewers
read UserStore (dual authority). The route now rejects with 403
{"error":"realtime_disabled"} using the same parseRealtimeEnabledFlag
single source of truth, checked after authenticate() (so an
unauthenticated caller still gets its normal 401, not a 403 that would
leak flag state) and before the SpaceHub stub.fetch() call, so a
disabled deployment never wakes a DO.

/v3/automations POST/PATCH/DELETE get the identical gate: they mutate
the same SpaceHub DO via mintConfigEvent, so leaving them open would
just relocate the dual-authority bug to the automations control plane
instead of fixing it.

No active-connection kill is needed: flag changes ship via redeploy,
which evicts every DO instance and drops its live WebSockets as a side
effect (noted at the check site).
…/automations

Adds realtime-gate.workers.ts: flag off -> /v3/realtime and
/v3/automations POST/PATCH/DELETE all return 403 realtime_disabled
without waking a SpaceHub DO; flag on -> the upgrade still works
(explicit regression check); unauthenticated + flag off -> still 401,
not 403.

wrangler.jsonc's REALTIME_ENABLED stays false as the production
kill-switch default, but the rest of the /v3/realtime protocol suite
(handshake, broadcast, reliability, token-gate, ...) exercises the
enabled path and would otherwise all start failing with 403. Override
it to true for the test pool only via vitest.config.ts's miniflare
bindings; this file flips env.REALTIME_ENABLED back to false per-test
to reach the disabled branch.
…verflow table

The outbox's row cap used to reject Enqueue outright (ErrOutboxFull), pushing
backpressure onto an in-memory retry queue in the uplink that a short-lived
process (agent.go's Uplink.Close()) never drained past a single final flush.
Enqueue now durably persists an overflowing event into a new outbox_overflow
table in the same SQLite database instead, returning the informational
ErrOverflowed. PromoteOverflow moves the oldest overflow row into the
seq-bearing outbox (allocating device_seq only at that moment) whenever an
ack frees capacity or the store reopens after a restart; the FIFO guard
against a fresh event stealing a seq ahead of an older overflowed one is
enforced inside Enqueue's own transaction.
…flow-aware residual queue

rtRetry used to hold every event that hit outbox-full locally, in a slice
Close() never fully drained — the exact loss the no-fork rule was meant to
prevent for a short-lived per-automation Uplink. Now that outbox.Enqueue
persists synchronously (overflowing durably instead of failing), sendReliable
only needs a tiny residual queue for the genuinely rare case where Enqueue's
own bounded retries are exhausted (a persistent local-disk failure) — ordinary
outbox-full backpressure never reaches it.
The server can now reject /v3/realtime pre-upgrade with HTTP 403
{"error":"realtime_disabled"} when its own REALTIME_ENABLED is off.
coder/websocket's Dial keeps the rejected *http.Response on error, so a 403
there is recognized as the new sentinel ErrRealtimeDisabled rather than an
ordinary transient dial failure — it no longer feeds the tight reconnect
backoff, and instead waits a long, fixed, named interval before re-probing.

Uplink.pollRealtimeMode polls Client.ServerDisabled() once per flush tick and
switches the whole session to legacy /v2 HTTP routing while it's true —
draining every event already sitting durably in the outbox/overflow tables
to /v2, in FIFO order, so nothing already-enqueued gets stranded in a store
no viewer reads. Authority genuinely moves to UserStore for this case; it is
not the forked-reliable-path failure mode the earlier P0 fix guarded against.
When the client's periodic re-probe succeeds again, new events resume
routing to realtime immediately (legacy mode drained as it went, so there is
no backlog to replay).
…e Client

cmd/sitrep/agent.go builds one *rtclient.Client for the whole process but
constructs a fresh uplink.Uplink (and its own pollRealtimeMode loop) per
concurrently-running automation. Each Uplink's drainOutboxToLegacy read
the shared outbox's Pending()/OverflowPending() and sent the row before
either had a chance to Ack/DeleteOverflow it, so two automations racing
while the server rejected realtime could deliver the same reliable event
to /v2/ingest twice.

Move drain ownership onto rtclient.Client (already the single shared
object owning the outbox and the single-writer send loop): DrainToLegacy
takes a per-row LegacySink and serializes every row's read-sink-retire
sequence under a new legacyDrainMu, held one row at a time so a
reconnecting client is never blocked behind a whole backlog. The same
lock guards connectAndServe's post-hello replayPending, closing the
narrower switch-back race between a lagging legacy drain and the newly
reconnected client's own replay of the same outbox.

Uplink.drainOutboxToLegacy now just delegates translation/send to
rt.DrainToLegacy via a LegacySink closure; retiring rows is entirely the
Client's responsibility.
…drain

Add TestDrainToLegacyConcurrentCallersDeliverEachRowExactlyOnce: many
goroutines call Client.DrainToLegacy concurrently against one
Client/outbox pre-loaded with a deterministic backlog, simulating several
automations' Uplinks polling the same shared realtime Client at once (the
normal case commit 1114ff9's review flagged as racy). Asserts every row
reaches the sink exactly once, in device_seq order, across all callers
combined. Meaningful under -race; passes at -count=20.
…vents

SendTaskEvent/SendMessageEvent returned outbox.ErrOverflowed to the
caller like any other Enqueue failure, even though ErrOverflowed's own
doc comment calls it informational (the event is already durably
persisted into outbox_overflow) and says callers must not retry or
reroute on it. uplink.Uplink.sendReliable's doc comment already assumed
this never reached it ("ordinary outbox-full backpressure durably
overflows inside Enqueue and never reaches here") — but nothing enforced
it, so an overflowed event fell into sendReliable's persist-failure/
residual-retry branch and got re-Enqueued on every flush tick until
capacity freed, each attempt durably creating another outbox_overflow row
for the same logical event (Enqueue never deduplicates): unbounded
duplicate rows in overflow's uncapped table, only one of which is ever
promoted and delivered.

Swallow ErrOverflowed in both Send*Event methods so it never reaches the
caller as an error, matching the invariant both doc comments already
assumed.
Add TestOverflowSurvivesProcessRestartAndDeliversInOrder: fill a cap-1
outbox, overflow a second reliable event, shut the Uplink/Client/Store
down exactly as a process exit would, open a brand new Store handle on
the same file (never reusing the old one), ack the still-unacked older
event once reconnected, and assert the overflowed event is promoted in
its original order and delivered over realtime — with the /v2 HTTP spy
seeing nothing at all throughout.
…ontention

Add TestEnqueueRecoversFromRealTransientLockContention: a second, real
sql.DB connection to the same outbox file takes SQLite's write lock
(BEGIN IMMEDIATE) for a duration that lands inside Enqueue's second
internal attempt window, so enqueueAttempt observes a genuine
SQLITE_BUSY (not a faked send/build error) on its first attempt and
recovers via its own bounded retry (enqueueMaxAttempts/
enqueueRetryDelay) once the lock actually releases. Asserts the event
ends up durable and readable, never dropped.

Add the openWithBusyTimeoutMS test seam (OpenWithMaxRows now delegates
to it with the production 5000ms) so the test can use a short
busy_timeout and stay fast instead of waiting out the real 5s default.
@QuintinShaw
QuintinShaw deleted the integration/realtime branch July 20, 2026 03:25
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