feat(truapi-server): host-side wire debug tap and debugger - #295
feat(truapi-server): host-side wire debug tap and debugger#295decrypto21 wants to merge 6 commits into
Conversation
f752ca3 to
70c230f
Compare
6869ac4 to
0ac06b0
Compare
0ac06b0 to
65ac1ff
Compare
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
9ef0a44 to
6a6060f
Compare
…er wire-decode surface
6a6060f to
79d24d4
Compare
| }); | ||
| } | ||
|
|
||
| const server = Bun.serve({ |
There was a problem hiding this comment.
Bun.serve defaults hostname to 0.0.0.0, so the debugger (which holds every trace) listens on all interfaces, not loopback. Confirmed: lsof shows TCP *:PORT, and from the LAN IP /traces and /op-list return 200, a WebSocket connects (any subnet peer can inject frames), and with decode on /frame returns decoded values off-box. Sensitive frames stay redacted. This is the loopback confinement the design doc leans on and that native_debug.rs / worker-runtime.ts both enforce on their side.
Fix: add hostname: "127.0.0.1", the CLI already targets localhost:9231 and the inspector is same-origin, so nothing else changes. Verified: LAN fetches stop connecting, 112 tests + tsc -b still pass.
| stays here, since it is generated from this package's contract. The debugger app is payload-blind | ||
| today - it decodes only the wire envelope (`requestId`, frame id) via `decodeWireMessage`, not | ||
| payloads - so this table is unused for now; it is the decode source for a future typed-value view. | ||
|
|
There was a problem hiding this comment.
This says the debugger "is payload-blind today - it decodes only the wire envelope … not payloads - so this table is unused for now; it is the decode source for a future typed-value view."
Both halves are out of date as of this PR: decode.ts imports WIRE_DECODE_TABLE from @parity/truapi/wire-decode and uses it as the default decode table, and /frame returns {"kind":"decoded","value":…} with TRUAPI_DEBUGGER_DECODE_VALUES=1. The debugger's own README describes level-2 decode accurately, so the two READMEs in this PR currently contradict each other - and the inaccurate one
ships inside the published @parity/truapi.
Suggest: "payload decode is available in the debugger app behind TRUAPI_DEBUGGER_DECODE_VALUES, with sensitive frames excluded by the generated SENSITIVE_FRAME_IDS denylist" and link the debugger README. Same shape as the README staleness on the previous round — worth one pass over the prose asking what the last two commits invalidated.
| role: "unknown", | ||
| byteLength: payload.value.length, | ||
| timestamp: Date.now(), | ||
| ...(retainBytes ? { bytes: payload.value } : {}), |
There was a problem hiding this comment.
maxTraces (256) and maxFramesPerTrace (1024) both cap frame count, so up to 262k frames stay retained - each holding a sender-chosen payload once decode is on. Measured at saturation: 8KiB payloads -> +2.4 GiB RSS, reached in about a second at the ~211k frames/s this ingests. Payload-blind mode is unaffected (+23 MiB), since it keeps no bytes.
channelId is the same story: unbounded length, one copy per frame. 256 ids of 200k chars -> +521 MiB, and that needs no decode gate.
Fix: bound retention by bytes as well as count, and clamp channelId/requestId here (real values are myapp.dot and p:1).
|
|
||
| /// Number of frames dropped because the outbound queue was full (debugger | ||
| /// absent or slower than the observed session). Never affects the session. | ||
| pub fn dropped(&self) -> u64 { |
There was a problem hiding this comment.
This counter never reaches the operator - its only consumer in the repo is the test at :332. Two more drop paths have no counter at all: the JS worker link discards frames when its queue is full (worker-runtime.ts:284), and the per-trace cap evicts oldest silently (wire-debugger.ts:215).
So "the host never answered frame 23" can't be told apart from "the debugger dropped it" - and the queues fill exactly when the bug is interesting.
Fix: expose a drop/truncation count on /stats, show it in the summary strip and CLI ls, and mark truncated traces so the UI can say "older frames dropped".
| ]); | ||
|
|
||
| /** The text message a host sends per frame: the envelope with a base64 frame. */ | ||
| interface WireMessage { |
There was a problem hiding this comment.
No version or schema identity on the envelope. The debugger decodes with its own generated tables, not the host's, and frame ids are u8 discriminants that get reassigned as the API evolves - so an older host silently resolves against the wrong contract: wrong method names, wrong decoded values, and worst case a frame that is sensitive in the host's build is absent from the debugger's SENSITIVE_FRAME_IDS and decodes. The pre-rewrite relay envelope carried v; it was dropped.
Fix: add v plus a schema identity (wire-table hash, or the existing TRUAPI_CODEC_VERSION), and refuse or banner a mismatch instead of decoding through it.
| // because `frames` is a plain in-order array read directly by consumers, | ||
| // and this runs only on the dev-only observe path where the cost (a bounded | ||
| // memmove of <=maxFramesPerTrace references) is immaterial. | ||
| trace.frames.splice(0, trace.frames.length - maxFramesPerTrace); |
There was a problem hiding this comment.
Good fix for the unbounded-frames issue. Side effect: the oldest frame of a subscription is its start, so once the cap engages annotatePairing (trace-view.ts:260) sees receives with no opener and stamps the op orphaned - documented as "dropped or still-in-flight". The victim is the motivating example,
account.connectionStatus. Measured: 1023 receives -> badges=[]; 1024 -> [orphaned].
Fix: keep frames[0] and evict from index 1. Note retry-storm's signature also keys on frames[0].frameId (retry-storm.ts:43), so it shifts too.
| frameId: -1, | ||
| role: "malformed", | ||
| byteLength: envelope.frame.length, | ||
| timestamp: Date.now(), |
There was a problem hiding this comment.
This is the debugger's clock at envelope arrival, and latencyFromStartMs (trace-view.ts:179) and roundTripMs (:276) derive from it - so every duration shown includes WS transport and queueing delay, which is what grows under load. Fine for ordering and presence, not for "this call took 340 ms", which is how a Network-tab-shaped UI invites you to read it. There's no host timestamp in the envelope to fix it with, so labelling the column debugger-observed would do.
| /// is inert. Fire-and-forget by construction: [`DebugSink::emit`] must not block | ||
| /// the frame path and must not fail the operation that produced the event, so a | ||
| /// slow, absent, or crashed debugger only loses the trace, never a session. | ||
| pub trait DebugSink: Send + Sync { |
There was a problem hiding this comment.
The contract says emit must not panic or it unwinds into a live dispatch, but nothing enforces it: no catch_unwind in the crate, and DebugSink is pub, so an out-of-repo host with a panicking emit takes down a session. The TS side does enforce the equivalent - createWireDebugger wraps sink and forward in try/catch (wire-debugger.ts:226).
Suggested fix: catch_unwind around the two emit call sites (Vec + ChannelId are both UnwindSafe), or seal the trait.
| // terminal frontends read one computed signal rather than each recomputing | ||
| // (or, for the CLI, silently omitting) it. | ||
| const traces = session.traceEngine.traces(); | ||
| const storms = detectRetryStorms(traces); |
There was a problem hiding this comment.
This traces() -> detectRetryStorms -> wireTraceToView pipeline repeats five times (storms at :131/:195/:320/:435/:458, views at :133/:203/:335/:442/:461), each with its own trace fetch, and the client polls three of those endpoints every second, so the same aggregation runs ~3x/s. It's cheap - 4.3% of a core even at full saturation - so this is DRY, not speed.
Suggested fix: one viewsFor(traces) helper: compute storms once, map once.
| const channel = url.searchParams.get("channel") ?? undefined; | ||
| const reveal = url.searchParams.get("reveal") === "1"; | ||
| const index = Number(rawIndex); | ||
| if (id === null || rawIndex === null || !Number.isInteger(index)) { |
There was a problem hiding this comment.
[nit]: searchParams.get("i") returns "" for ?i=, and Number("") === 0 passes Number.isInteger — so /frame?id=p:1&i= returns frame 0 with a 200, where ?i=NaN correctly 400s. Same for ?i=%20. One extra rawIndex === "" check.
What
Implements the wire observability layer for TrUAPI: a payload-blind debug tap in the Rust host
(
truapi-server), correlated by the wirerequestId. The host streams every product↔host frame to adebugger app it dials out to over loopback, which groups the frames into per-operation traces and
renders them from either a web inspector or a terminal CLI. The tap carries the frame bytes
opaquely — the core never decodes them — so it leaks no application content or key material, and
@parity/truapi(the product package) is untouched. Dev-only: the tap is inert unless a host installsa sink, and value-decode is a separate, gated drill-down that is absent from every production bundle.
Design doc:
docs/design/wire-observability-debug-host.md(#315).
lsoverview (health badges: sensitive 🔒, orphaned, retry-storm, live subs) and ashow --revealdrill-down, over the same engine and denylist as the web inspector:What's in it
truapi-server) — theDebugSinktrait +DebugEventat the two frame chokepoints (
receive_frameinbound,sendoutbound). Outbound forwards to the product before it emits;inbound emits before dispatch so a corrupt frame is still observed;
emitis fire-and-forget andinert when no sink is installed (a lock-free
has_debugfast path). Direction is product-vantage(
out= left the product), pinned by a guard test;DebugEventis#[non_exhaustive]. Ships anative
WsDebugSink(loopback-only, gated behind thews-bridgeCargo feature — the sametransport deps the CLI host uses, so it compiles only for native dev builds) and the wasm
WasmDebugSink.@parity/truapi-debugger, private, in-repo) —createWireDebuggergroups framesinto per-
requestIdWireTraces (LRU-capped); envelope decode ({ channelId, dir, frame },level-1 payload-blind); a gated level-2 value decode; the type-driven sensitive denylist; and the
reveal escape hatch. A Bun WS server the host dials into serves the trace view.
/traces·/stats·/frame: aggregate strip, oplist with filter/sort/channel switch and 🔒 markers, per-frame drill-down with blur→decode,
Decode-all/Encode-all.
ls/stats/show/tail) and an interactive queryREPL, thin clients over the same engine and denylist as the web inspector (no forked logic).
#[wire(sensitive)]annotation on the Rust trait flows throughtruapi-macros→truapi-codegeninto a generatedSENSITIVE_FRAME_IDSset. Sensitivity is aproperty of the payload type, so a new method — e.g.
account.signVrf(RFC 0023) — is denylistedthe moment it is annotated, not by name-matching a string.
Verification
cargo test -p truapi-server --features ws-bridge(the tap covered in both directions, product-vantageconvention pinned) and
-p truapi-codegen(incl. golden) green;make wasmconfirms the nativews-bridgedeps stay out of the wasm graph; clippy + fmt clean.bun testgreen for@parity/truapiand the debugger (denylist leak-tests included)
Notes
/tracesand/statsserialize only shape, timing, and counts —never a raw byte or a decoded value, even with decode on (leak-tested).
TRUAPI_DEBUGGER_DECODE_VALUES), in the private in-repodebugger app, absent from every production bundle.
sign_vrf, entropy, key/proof/login,local-storage read+write, payment top-up, coin-payment cheque/deposit/listen, statement-store
subscribe/submit), enforced by the generated denylist plus a fail-closed content-guard backstop that
also catches secret-named fields.
(
TRUAPI_DEBUGGER_REVEAL_SENSITIVE, meaningful only with decode on), requires an explicit per-frameconfirmation, is danger-styled, and is structurally impossible in a shipped build.
emitnever blocks, fails, orpanics into dispatch — a slow, absent, or crashed debugger loses a trace, never a session. Sinks are
loopback-only. Residual exposure is documented: even payload-blind, frame shape + timing is
traffic-analysis metadata; loopback + unset-in-prod is confinement, not anonymity.