Skip to content

feat(truapi-server): host-side wire debug tap and debugger - #295

Open
decrypto21 wants to merge 6 commits into
mainfrom
nidish/debug-host-port
Open

feat(truapi-server): host-side wire debug tap and debugger#295
decrypto21 wants to merge 6 commits into
mainfrom
nidish/debug-host-port

Conversation

@decrypto21

@decrypto21 decrypto21 commented Jul 20, 2026

Copy link
Copy Markdown

What

Implements the wire observability layer for TrUAPI: a payload-blind debug tap in the Rust host
(truapi-server), correlated by the wire requestId. The host streams every product↔host frame to a
debugger 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 installs
a 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).

  • Web inspector — aggregate summary, op list with filter/sort/channel pills, per-frame blur→decode, and sensitive frames redacted by default with a danger-styled per-frame reveal:
web-inspector
  • Terminal CLIls overview (health badges: sensitive 🔒, orphaned, retry-storm, live subs) and a show --reveal drill-down, over the same engine and denylist as the web inspector:
cli

What's in it

  • Rust tap + sinks (truapi-server) — the DebugSink trait + DebugEvent at the two frame choke
    points (receive_frame inbound, send outbound). Outbound forwards to the product before it emits;
    inbound emits before dispatch so a corrupt frame is still observed; emit is fire-and-forget and
    inert when no sink is installed (a lock-free has_debug fast path). Direction is product-vantage
    (out = left the product), pinned by a guard test; DebugEvent is #[non_exhaustive]. Ships a
    native WsDebugSink (loopback-only, gated behind the ws-bridge Cargo feature — the same
    transport deps the CLI host uses, so it compiles only for native dev builds) and the wasm
    WasmDebugSink.
  • TS engine (@parity/truapi-debugger, private, in-repo) — createWireDebugger groups frames
    into per-requestId WireTraces (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.
  • Web inspector — a Network-tab-style UI over /traces·/stats·/frame: aggregate strip, op
    list with filter/sort/channel switch and 🔒 markers, per-frame drill-down with blur→decode,
    Decode-all/Encode-all.
  • Terminal CLI + REPL — one-shot commands (ls/stats/show/tail) and an interactive query
    REPL, thin clients over the same engine and denylist as the web inspector (no forked logic).
  • Type-driven denylist — a #[wire(sensitive)] annotation on the Rust trait flows through
    truapi-macrostruapi-codegen into a generated SENSITIVE_FRAME_IDS set. Sensitivity is a
    property of the payload type, so a new method — e.g. account.signVrf (RFC 0023) — is denylisted
    the 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-vantage
convention pinned) and -p truapi-codegen (incl. golden) green; make wasm confirms the native
ws-bridge deps stay out of the wasm graph; clippy + fmt clean. bun test green for @parity/truapi
and the debugger (denylist leak-tests included)

Notes

  • Payload-blind by default. /traces and /stats serialize only shape, timing, and counts —
    never a raw byte or a decoded value, even with decode on (leak-tested).
  • Level-2 decode is a real, dev-only gate (TRUAPI_DEBUGGER_DECODE_VALUES), in the private in-repo
    debugger app, absent from every production bundle.
  • Sensitive frames never decode (all signing incl. 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.
  • The sensitive-reveal escape hatch is redact-by-default even in dev: armed only by its own gate
    (TRUAPI_DEBUGGER_REVEAL_SENSITIVE, meaningful only with decode on), requires an explicit per-frame
    confirmation, is danger-styled, and is structurally impossible in a shipped build.
  • Off the critical path: the tap forwards before it emits, and emit never blocks, fails, or
    panics 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.

@decrypto21 decrypto21 changed the title feat(truapi): wire observe seam, wire debugger, and headless mock/forward debug host feat(truapi): wire observability - observe seam, wire debugger, debug host, and relay Jul 25, 2026
@decrypto21
decrypto21 force-pushed the nidish/debug-host-port branch from f752ca3 to 70c230f Compare July 25, 2026 06:30
@decrypto21
decrypto21 requested a review from TarikGul July 25, 2026 06:40
@decrypto21
decrypto21 force-pushed the nidish/debug-host-port branch 2 times, most recently from 6869ac4 to 0ac06b0 Compare July 25, 2026 09:42
@decrypto21
decrypto21 marked this pull request as ready for review July 25, 2026 09:43
@decrypto21
decrypto21 requested a review from a team July 25, 2026 09:43
Comment thread js/packages/truapi/README.md Outdated
Comment thread playground/src/components/WireTracePanel.tsx Outdated
Comment thread js/packages/truapi-debugger/src/wire-debugger.ts
Comment thread js/packages/truapi/src/sandbox.ts Outdated
Comment thread js/packages/truapi/package.json
@decrypto21
decrypto21 force-pushed the nidish/debug-host-port branch from 0ac06b0 to 65ac1ff Compare July 28, 2026 09:03
@socket-security

socket-security Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​parity/​truapi-debugger@​0.0.0N/AN/AN/AN/AN/A

View full report

@decrypto21 decrypto21 changed the title feat(truapi): wire observability - observe seam, wire debugger, debug host, and relay feat(truapi-server): host-side wire debug tap and debugger Jul 28, 2026
@decrypto21
decrypto21 force-pushed the nidish/debug-host-port branch 3 times, most recently from 9ef0a44 to 6a6060f Compare July 28, 2026 20:04
@decrypto21
decrypto21 requested a review from Imod7 July 28, 2026 20:07
@decrypto21
decrypto21 force-pushed the nidish/debug-host-port branch from 6a6060f to 79d24d4 Compare August 2, 2026 19:51
});
}

const server = Bun.serve({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +116 to +119
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 } : {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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.

2 participants