From fdfb8fb4c5fbad761e95e07a45aaea621102258f Mon Sep 17 00:00:00 2001 From: Nidish Date: Fri, 31 Jul 2026 19:59:18 +0530 Subject: [PATCH 01/17] feat(truapi-codegen): emit the debugger wire-decode surface --- js/packages/truapi/README.md | 18 +- js/packages/truapi/package.json | 4 + .../truapi/scripts/ensure-generated.sh | 1 + js/packages/truapi/src/client.ts | 2 + rust/crates/truapi-codegen/src/rust.rs | 2 + rust/crates/truapi-codegen/src/rustdoc.rs | 26 +- rust/crates/truapi-codegen/src/ts.rs | 256 ++++++++++++++++++ rust/crates/truapi-macros/src/lib.rs | 43 ++- rust/crates/truapi/src/api/account.rs | 8 +- rust/crates/truapi/src/api/coin_payment.rs | 6 +- rust/crates/truapi/src/api/entropy.rs | 2 +- rust/crates/truapi/src/api/local_storage.rs | 4 +- rust/crates/truapi/src/api/payment.rs | 2 +- rust/crates/truapi/src/api/signing.rs | 12 +- rust/crates/truapi/src/api/statement_store.rs | 8 +- 15 files changed, 357 insertions(+), 37 deletions(-) diff --git a/js/packages/truapi/README.md b/js/packages/truapi/README.md index 0981c3169..3bf3dc955 100644 --- a/js/packages/truapi/README.md +++ b/js/packages/truapi/README.md @@ -69,7 +69,7 @@ sub.unsubscribe(); - **Generated domain clients and types** produced from the Rust API contract. - **SCALE codec helpers** used by the generated code, also re-exported for direct use. - **Sandbox bootstrap** (`@parity/truapi/sandbox`) that detects the host environment, builds the - matching provider, and exposes a cached client — see below. + matching provider, and exposes a cached client - see below. ## Sandbox bootstrap @@ -101,6 +101,22 @@ const unsubscribe = subscribeConnectionStatus((status) => { | `getClientSync(): TrUApiClient \| null` | Cached client; `null` outside a host container. | | `subscribeConnectionStatus(cb): () => void` | Connected / disconnected status listener. | +## Observability / debugging + +The debugger does not live in this package, and the product transport carries no debug seam - +`@parity/truapi` is genuinely untouched by observability. The host taps every product↔host frame in +its Rust core (`truapi-server`'s `DebugSink`) and streams each one - as `{ channelId, dir, frame: +bytes }`, opaque bytes - to a separate debugger app, which decodes and groups them. + +- Architecture (the tap, the envelope, the host-dials-debugger topology, `wss`/cert setup): + `docs/design/wire-observability-debug-host.md`. +- The debugger app itself (trace + envelope-decode engines + the WS server): `@parity/truapi-debugger`. + +The generated `WIRE_DECODE_TABLE` on the `./wire-decode` subpath (raw SCALE bytes → typed value) +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. + ## Wire format Frames are SCALE encoded: diff --git a/js/packages/truapi/package.json b/js/packages/truapi/package.json index 357bc9cc6..625c05c34 100644 --- a/js/packages/truapi/package.json +++ b/js/packages/truapi/package.json @@ -39,6 +39,10 @@ "types": "./dist/generated/wire-table.d.ts", "import": "./dist/generated/wire-table.js" }, + "./wire-decode": { + "types": "./dist/generated/wire-decode.d.ts", + "import": "./dist/generated/wire-decode.js" + }, "./playground/services": { "types": "./dist/playground/codegen/services.d.ts", "import": "./dist/playground/codegen/services.js" diff --git a/js/packages/truapi/scripts/ensure-generated.sh b/js/packages/truapi/scripts/ensure-generated.sh index 807c07166..e32aa3561 100755 --- a/js/packages/truapi/scripts/ensure-generated.sh +++ b/js/packages/truapi/scripts/ensure-generated.sh @@ -9,6 +9,7 @@ codegen_required=( "js/packages/truapi/src/generated/client.ts" "js/packages/truapi/src/generated/types.ts" "js/packages/truapi/src/generated/wire-table.ts" + "js/packages/truapi/src/generated/wire-decode.ts" "js/packages/truapi/src/playground/codegen/services.ts" "js/packages/truapi/src/explorer/codegen/types.ts" "js/packages/truapi/src/explorer/versions.ts" diff --git a/js/packages/truapi/src/client.ts b/js/packages/truapi/src/client.ts index 8265b465b..ba66da83d 100644 --- a/js/packages/truapi/src/client.ts +++ b/js/packages/truapi/src/client.ts @@ -155,6 +155,7 @@ export function createTransport( ): TrUApiTransport { const codecVersion = options.codecVersion ?? TRUAPI_CODEC_VERSION; let idCounter = 0; + let closedError: Error | null = null; const pending = new Map< string, @@ -233,6 +234,7 @@ export function createTransport( const decoded = decodeWireMessage(message); if (decoded.isErr()) { + // A corrupt/truncated inbound frame tears the transport down. closeWithError(decoded.error); return; } diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index 7d757f98c..f3f156f76 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -158,6 +158,7 @@ mod tests { stop_id: None, interrupt_id: None, receive_id: None, + sensitive: false, }, docs: None, } @@ -180,6 +181,7 @@ mod tests { stop_id: None, interrupt_id: None, receive_id: None, + sensitive: false, }, docs: None, } diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index f84eaff20..a0c51210e 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -121,6 +121,10 @@ pub struct WireAttrs { pub interrupt_id: Option, /// Subscription item frame discriminant. pub receive_id: Option, + /// Whether the method's payloads carry key material or bearer secrets. + /// Marked by `#[wire(..., sensitive)]`; propagated into the generated + /// `SENSITIVE_FRAME_IDS` set so the wire debugger never decodes these frames. + pub sensitive: bool, } /// Wire-shape classification of a trait method. @@ -819,6 +823,14 @@ fn extract_wire_attrs(docs: &str) -> WireAttrs { if line.starts_with("@wire_host_initiated") { attrs.host_initiated = true; } + if line.starts_with("@wire_sensitive=") { + attrs.sensitive = line + .trim_end() + .strip_prefix("@wire_sensitive=") + .and_then(|value| value.parse::().ok()) + .unwrap_or(false); + continue; + } for (needle, target) in [ ("@wire_request_id=", &mut attrs.request_id), ("@wire_response_id=", &mut attrs.response_id), @@ -1484,7 +1496,7 @@ mod tests { #[test] fn clean_docs_strips_wire_markers() { - let docs = "Trait summary.\n\n@wire_request_id=7\n@service_required_execution=Chat\n"; + let docs = "Trait summary.\n\n@wire_request_id=7\n@wire_sensitive=true\n@service_required_execution=Chat\n"; assert_eq!(clean_docs(Some(docs)).as_deref(), Some("Trait summary.")); } @@ -1502,6 +1514,18 @@ mod tests { assert_eq!(trait_def.public_docs().as_deref(), Some("Chat operations.")); } + #[test] + fn extract_wire_attrs_reads_sensitive_flag() { + let sensitive = extract_wire_attrs("@wire_request_id=114\n@wire_sensitive=true"); + assert_eq!(sensitive.request_id, Some(114)); + assert!(sensitive.sensitive); + + // Absent marker ⇒ not sensitive (the default for every unmarked method). + let plain = extract_wire_attrs("@wire_request_id=22"); + assert_eq!(plain.request_id, Some(22)); + assert!(!plain.sensitive); + } + #[test] fn parse_accepts_tested_format_version() { let json = format!(r#"{{ "format_version": {MIN_FORMAT_VERSION}, "index": {{}} }}"#); diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index 3c84004c6..dfab261ba 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -489,6 +489,12 @@ pub fn generate( let wire_table_code = generate_wire_table(api, target_version)?; fs::write(Path::new(output_dir).join("wire-table.ts"), wire_table_code)?; + let decode_table_code = generate_decode_table(api, target_version)?; + fs::write( + Path::new(output_dir).join("wire-decode.ts"), + decode_table_code, + )?; + Ok(()) } @@ -585,6 +591,8 @@ fn generate_wire_table(api: &ApiDefinition, target_version: u32) -> Result = BTreeMap::new(); let mut constants: Vec<(String, ExpandedWireIds)> = Vec::new(); + // Every frame id (both legs) of a method marked `#[wire(..., sensitive)]`. + let mut sensitive_ids: BTreeSet = BTreeSet::new(); for trait_def in &api.traits { for method in &trait_def.methods { @@ -596,6 +604,9 @@ fn generate_wire_table(api: &ApiDefinition, target_version: u32) -> Result Result>() + .join(", "); + out.push('\n'); + out.push_str(&formatdoc! {" + // Wire frame ids whose payloads carry key material or bearer secrets, + // marked `#[wire(..., sensitive)]` on the Rust trait. The wire debugger + // treats this as the authoritative denylist and never decodes these + // frames (both request/response and start/receive legs are listed). + export const SENSITIVE_FRAME_IDS: ReadonlySet = new Set([{sensitive_list}]); + "}); + Ok(out) } @@ -1077,6 +1102,172 @@ fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) Ok(out) } +/// Generates the dev-only wire decode table (`wire-decode.ts`): a map from wire +/// `frameId` to a decoder that turns a frame's SCALE payload into a plain JS +/// value. It re-derives the exact request/response/subscription codec +/// expressions the client emitter builds (via [`emit_payload`], +/// [`emit_response`], [`emit_error_response`], and +/// [`versioned_result_codec_expr`]), so a debugger decodes wire frames against +/// the same generated codecs. Subscription `start` and `receive` frames are +/// covered; `stop`/`interrupt` frames are intentionally skipped. +fn generate_decode_table(api: &ApiDefinition, target_version: u32) -> Result { + let ctx = codec_context(&[]); + let wrappers = collect_versioned_wrappers(api); + let services = public_services(api)?; + + // (wire id, emitted table line) pairs, sorted by wire id for a stable, + // wire-ordered file that matches the wire-table layout. + let mut entries: Vec<(u8, String)> = Vec::new(); + + for service in &services { + let trait_def = service.trait_def; + for method in included_methods(trait_def, &wrappers, target_version)? { + let wire_const = wire_const_name(&trait_def.name, &method.name); + let wire_version = method_wire_version(method, &wrappers, target_version)?; + let payload = emit_payload(&method.params, &wrappers, &ctx, wire_version)?; + let wire_ids = wire_ids_for_method(trait_def, method)?; + + match (&method.kind, &method.return_type) { + (MethodKind::Request, ReturnType::Result { ok, err }) => { + let ExpandedWireIds::Request { + request_id, + response_id, + } = wire_ids + else { + unreachable!("request method resolved to subscription wire ids"); + }; + let response = emit_response(ok, &wrappers, &ctx, wire_version)?; + let error = emit_error_response(err, &wrappers, &ctx, wire_version)?; + let response_codec = match wire_version { + Some(version) => versioned_result_codec_expr( + version, + &response.inner_codec_expr, + &error.inner_codec_expr, + )?, + None => format!( + "S.Result({}, {})", + response.wire_codec_expr, error.wire_codec_expr + ), + }; + let value_suffix = if wire_version.is_some() { ".value" } else { "" }; + entries.push(( + request_id, + format!( + " [W.{wire_const}.request]: (payload) => {}.dec(payload),", + payload.wire_codec_expr + ), + )); + entries.push(( + response_id, + format!( + " [W.{wire_const}.response]: (payload) => {response_codec}.dec(payload){value_suffix}," + ), + )); + } + (MethodKind::Subscription, ReturnType::Subscription(ty)) => { + let response = emit_response(ty, &wrappers, &ctx, wire_version)?; + push_subscription_entries( + &mut entries, + &wire_const, + &payload, + &response, + wire_ids, + wire_version, + )?; + } + (MethodKind::ResultSubscription, ReturnType::ResultSubscription { item, .. }) => { + let response = emit_response(item, &wrappers, &ctx, wire_version)?; + push_subscription_entries( + &mut entries, + &wire_const, + &payload, + &response, + wire_ids, + wire_version, + )?; + } + (kind, return_type) => { + bail!( + "Generator internal mismatch for method `{}`: kind {:?} does not match return type {:?}", + method.name, + kind, + return_type + ); + } + } + } + } + + entries.sort_by_key(|(id, _)| *id); + + let mut out = String::new(); + writedoc!( + out, + r#" + // Auto-generated by truapi-codegen. Do not edit. + + import * as S from '../scale.js'; + import * as T from './types.js'; + import * as W from './wire-table.js'; + + /** Dev-only: decode a wire frame's SCALE payload to a plain JS value, keyed by frameId. + * Request/response/subscription frames only; unknown ids are absent (caller falls back to bytes). */ + export const WIRE_DECODE_TABLE: Record unknown> = {{ + "# + ) + .unwrap(); + for (_, line) in &entries { + out.push_str(line); + out.push('\n'); + } + out.push_str("};\n"); + + Ok(out) +} + +/// Emits the `.start` (start payload codec) and `.receive` (item codec) decode +/// entries for a subscription method, mirroring the client's `payload` +/// encoding and `decodeItem` expression. `stop`/`interrupt` frames are skipped. +fn push_subscription_entries( + entries: &mut Vec<(u8, String)>, + wire_const: &str, + payload: &PayloadEmission, + response: &ResponseEmission, + wire_ids: ExpandedWireIds, + wire_version: Option, +) -> Result<()> { + let ExpandedWireIds::Subscription { + start_id, + receive_id, + .. + } = wire_ids + else { + unreachable!("subscription method resolved to request wire ids"); + }; + let item_value = if let Some(version) = wire_version { + versioned_value_expr( + &format!("{}.dec(payload)", response.wire_codec_expr), + &response.wire_type_ts, + &response.inner_type_ts, + version, + ) + } else { + format!("{}.dec(payload)", response.wire_codec_expr) + }; + entries.push(( + start_id, + format!( + " [W.{wire_const}.start]: (payload) => {}.dec(payload),", + payload.wire_codec_expr + ), + )); + entries.push(( + receive_id, + format!(" [W.{wire_const}.receive]: (payload) => {item_value},"), + )); + Ok(()) +} + fn write_observable_helper(out: &mut String) { writedoc!( out, @@ -2819,6 +3010,71 @@ mod tests { ); } + #[test] + fn generate_wire_table_emits_sensitive_frame_ids() { + let mut sign = request_method("sign", Some(10)); + sign.wire.sensitive = true; + let mut stream = subscription_method("stream", Some(20)); + stream.wire.sensitive = true; + let safe = request_method("safe", Some(30)); + + let source = + generate_wire_table(&api(vec![sign, stream, safe]), 2).expect("generate wire table"); + + // Every leg of a sensitive method lands in the set: both legs of a + // request, all four frames of a subscription. + assert!(source.contains( + "export const SENSITIVE_FRAME_IDS: ReadonlySet = new Set([10, 11, 20, 21, 22, 23]);" + )); + + // A non-sensitive method contributes none of its ids to the set. + let set_line = source + .lines() + .find(|line| line.contains("SENSITIVE_FRAME_IDS")) + .expect("sensitive set line"); + assert!(!set_line.contains("30")); + assert!(!set_line.contains("31")); + } + + #[test] + fn generate_wire_table_emits_empty_sensitive_set_when_none_marked() { + let source = generate_wire_table(&api(vec![request_method("safe", Some(10))]), 2) + .expect("generate wire table"); + assert!( + source.contains("export const SENSITIVE_FRAME_IDS: ReadonlySet = new Set([]);") + ); + } + + #[test] + fn generate_decode_table_emits_frame_keyed_decoders() { + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Example".to_string(), + module_path: Vec::new(), + methods: vec![ + request_method("feature_supported", Some(2)), + subscription_method("stream", Some(10)), + ], + docs: None, + }], + public_trait_order: vec!["Example".to_string()], + types: Vec::new(), + }; + + let source = generate_decode_table(&api, 2).expect("generate decode table"); + + assert!(source.contains("export const WIRE_DECODE_TABLE")); + assert!(source.contains("(payload: Uint8Array) => unknown")); + assert!(source.contains("[W.EXAMPLE_FEATURE_SUPPORTED.request]")); + assert!(source.contains("[W.EXAMPLE_FEATURE_SUPPORTED.response]")); + assert!(source.contains("[W.EXAMPLE_STREAM.start]")); + assert!(source.contains("[W.EXAMPLE_STREAM.receive]")); + assert!(source.contains(".dec(payload)")); + // stop/interrupt subscription frames are intentionally skipped. + assert!(!source.contains(".stop]")); + assert!(!source.contains(".interrupt]")); + } + #[test] fn generate_wire_table_rejects_duplicate_ids() { let err = generate_wire_table( diff --git a/rust/crates/truapi-macros/src/lib.rs b/rust/crates/truapi-macros/src/lib.rs index 8e27efc4d..f56528b51 100644 --- a/rust/crates/truapi-macros/src/lib.rs +++ b/rust/crates/truapi-macros/src/lib.rs @@ -38,6 +38,7 @@ struct WireArgs { stop_id: Option, interrupt_id: Option, receive_id: Option, + sensitive: bool, } struct ServiceArgs { @@ -77,24 +78,29 @@ impl Parse for WireArgs { while !input.is_empty() { let key: Ident = input.parse()?; + if key == "host_initiated" { if args.host_initiated { return Err(syn::Error::new(key.span(), "duplicate `host_initiated`")); } args.host_initiated = true; - if input.is_empty() { - break; + } else if key == "sensitive" { + // `sensitive` is a bare flag with no `= N` value: it marks the + // method's payloads as carrying key material or bearer secrets, + // so the wire debugger never decodes them. + if args.sensitive { + return Err(syn::Error::new(key.span(), "duplicate `sensitive`")); } - input.parse::()?; - continue; - } - input.parse::()?; - let lit: LitInt = input.parse()?; - let value = lit.base10_parse().map_err(|err| { - syn::Error::new(lit.span(), format!("wire id must fit in a u8: {err}")) - })?; + args.sensitive = true; + } else { + input.parse::()?; + let lit: LitInt = input.parse()?; + let value = lit.base10_parse().map_err(|err| { + syn::Error::new(lit.span(), format!("wire id must fit in a u8: {err}")) + })?; - set_id(&mut args, &key, value)?; + set_id(&mut args, &key, value)?; + } if input.is_empty() { break; @@ -126,7 +132,7 @@ fn set_id(args: &mut WireArgs, key: &Ident, value: u8) -> syn::Result<()> { } else { return Err(syn::Error::new( key.span(), - "expected one of `request_id`, `response_id`, `start_id`, `stop_id`, `interrupt_id`, `receive_id`", + "expected one of `request_id`, `response_id`, `start_id`, `stop_id`, `interrupt_id`, `receive_id`, `host_initiated`, `sensitive`", )); }; @@ -145,6 +151,12 @@ fn set_id(args: &mut WireArgs, key: &Ident, value: u8) -> syn::Result<()> { /// /// #[wire(start_id = 42)] /// async fn host_account_connection_status_subscribe(...) -> ...; +/// +/// // Mark a method whose payloads carry key material or bearer secrets. Its +/// // frame ids land in the generated `SENSITIVE_FRAME_IDS` set and are never +/// // decoded by the wire debugger. +/// #[wire(request_id = 114, sensitive)] +/// async fn sign_raw(...) -> ...; /// ``` /// /// Expands to the original method plus hidden doc tags that `truapi-codegen` @@ -177,7 +189,7 @@ pub fn wire(args: TokenStream, item: TokenStream) -> TokenStream { } fn wire_tags(args: &WireArgs) -> Vec { - let mut tags = [ + let mut tags: Vec = [ ("request_id", args.request_id), ("response_id", args.response_id), ("start_id", args.start_id), @@ -187,10 +199,13 @@ fn wire_tags(args: &WireArgs) -> Vec { ] .into_iter() .filter_map(|(name, value)| value.map(|id| format!("@wire_{name}={id}"))) - .collect::>(); + .collect(); if args.host_initiated { tags.push("@wire_host_initiated".to_string()); } + if args.sensitive { + tags.push("@wire_sensitive=true".to_string()); + } tags } diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index e06597b5d..48d60f450 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -142,7 +142,7 @@ pub trait Account: Send + Sync { /// ); /// console.log("foreign account proof refused without prompting"); /// ``` - #[wire(request_id = 26)] + #[wire(request_id = 26, sensitive)] async fn create_account_proof( &self, _cx: &CallContext, @@ -173,7 +173,7 @@ pub trait Account: Send + Sync { /// assert(result.isOk(), "signVrf failed:", result); /// console.log("vrf signature:", result.value); /// ``` - #[wire(request_id = 164)] + #[wire(request_id = 164, sensitive)] async fn sign_vrf( &self, _cx: &CallContext, @@ -280,7 +280,7 @@ pub trait Account: Send + Sync { /// assert(result.isOk(), "getUserId failed:", result); /// console.log("user id:", result.value); /// ``` - #[wire(request_id = 110)] + #[wire(request_id = 110, sensitive)] async fn get_user_id( &self, _cx: &CallContext, @@ -301,7 +301,7 @@ pub trait Account: Send + Sync { /// assert(result.isOk(), "requestLogin failed:", result); /// console.log("login completed:", result.value); /// ``` - #[wire(request_id = 112)] + #[wire(request_id = 112, sensitive)] async fn request_login( &self, _cx: &CallContext, diff --git a/rust/crates/truapi/src/api/coin_payment.rs b/rust/crates/truapi/src/api/coin_payment.rs index 5839b8e3c..90baf8417 100644 --- a/rust/crates/truapi/src/api/coin_payment.rs +++ b/rust/crates/truapi/src/api/coin_payment.rs @@ -141,7 +141,7 @@ pub trait CoinPayment: Send + Sync { /// assert(result.isOk(), "createCheque failed:", result); /// console.log("cheque created:", result.value.cheque); /// ``` - #[wire(request_id = 150)] + #[wire(request_id = 150, sensitive)] async fn create_cheque( &self, _cx: &CallContext, @@ -168,7 +168,7 @@ pub trait CoinPayment: Send + Sync { /// ); /// console.log("deposit status:", status); /// ``` - #[wire(start_id = 152)] + #[wire(start_id = 152, sensitive)] async fn deposit( &self, _cx: &CallContext, @@ -222,7 +222,7 @@ pub trait CoinPayment: Send + Sync { /// ); /// console.log("payment received:", item); /// ``` - #[wire(start_id = 160)] + #[wire(start_id = 160, sensitive)] async fn listen_for_payment( &self, _cx: &CallContext, diff --git a/rust/crates/truapi/src/api/entropy.rs b/rust/crates/truapi/src/api/entropy.rs index 32f510b9b..36176db6c 100644 --- a/rust/crates/truapi/src/api/entropy.rs +++ b/rust/crates/truapi/src/api/entropy.rs @@ -18,7 +18,7 @@ pub trait Entropy: Send + Sync { /// assert(result.isOk(), "derive failed:", result); /// console.log("entropy derived:", result.value); /// ``` - #[wire(request_id = 108)] + #[wire(request_id = 108, sensitive)] async fn derive( &self, _cx: &CallContext, diff --git a/rust/crates/truapi/src/api/local_storage.rs b/rust/crates/truapi/src/api/local_storage.rs index ec0bc6343..5c2057858 100644 --- a/rust/crates/truapi/src/api/local_storage.rs +++ b/rust/crates/truapi/src/api/local_storage.rs @@ -18,7 +18,7 @@ pub trait LocalStorage: Send + Sync { /// assert(result.isOk(), "read failed:", result); /// console.log("storage value read:", result.value.value); /// ``` - #[wire(request_id = 12)] + #[wire(request_id = 12, sensitive)] async fn read( &self, cx: &CallContext, @@ -35,7 +35,7 @@ pub trait LocalStorage: Send + Sync { /// assert(result.isOk(), "write failed:", result); /// console.log("storage write succeeded"); /// ``` - #[wire(request_id = 14)] + #[wire(request_id = 14, sensitive)] async fn write( &self, cx: &CallContext, diff --git a/rust/crates/truapi/src/api/payment.rs b/rust/crates/truapi/src/api/payment.rs index eab781c5f..f1740cc59 100644 --- a/rust/crates/truapi/src/api/payment.rs +++ b/rust/crates/truapi/src/api/payment.rs @@ -112,7 +112,7 @@ pub trait Payment: Send + Sync { /// assert(result.isOk(), "topUp failed:", result); /// console.log("balance topped up"); /// ``` - #[wire(request_id = 122)] + #[wire(request_id = 122, sensitive)] async fn top_up( &self, _cx: &CallContext, diff --git a/rust/crates/truapi/src/api/signing.rs b/rust/crates/truapi/src/api/signing.rs index 86e19e3ee..1b64e7e9b 100644 --- a/rust/crates/truapi/src/api/signing.rs +++ b/rust/crates/truapi/src/api/signing.rs @@ -58,7 +58,7 @@ pub trait Signing: Send + Sync { /// console.log(`${version} transaction created:`, result.value); /// } /// ``` - #[wire(request_id = 30)] + #[wire(request_id = 30, sensitive)] async fn create_transaction( &self, _cx: &CallContext, @@ -112,7 +112,7 @@ pub trait Signing: Send + Sync { /// assert(result.isOk(), "createTransactionWithLegacyAccount failed:", result); /// console.log("transaction created:", result.value); /// ``` - #[wire(request_id = 32)] + #[wire(request_id = 32, sensitive)] async fn create_transaction_with_legacy_account( &self, _cx: &CallContext, @@ -144,7 +144,7 @@ pub trait Signing: Send + Sync { /// assert(result.isOk(), "signRawWithLegacyAccount failed:", result); /// console.log("raw bytes signed:", result.value); /// ``` - #[wire(request_id = 34)] + #[wire(request_id = 34, sensitive)] async fn sign_raw_with_legacy_account( &self, _cx: &CallContext, @@ -187,7 +187,7 @@ pub trait Signing: Send + Sync { /// assert(result.isOk(), "signPayloadWithLegacyAccount failed:", result); /// console.log("payload signed:", result.value); /// ``` - #[wire(request_id = 36)] + #[wire(request_id = 36, sensitive)] async fn sign_payload_with_legacy_account( &self, _cx: &CallContext, @@ -214,7 +214,7 @@ pub trait Signing: Send + Sync { /// assert(result.isOk(), "signRaw failed:", result); /// console.log("raw bytes signed:", result.value); /// ``` - #[wire(request_id = 114)] + #[wire(request_id = 114, sensitive)] async fn sign_raw( &self, _cx: &CallContext, @@ -248,7 +248,7 @@ pub trait Signing: Send + Sync { /// assert(result.isOk(), "signPayload failed:", result); /// console.log("payload signed:", result.value); /// ``` - #[wire(request_id = 116)] + #[wire(request_id = 116, sensitive)] async fn sign_payload( &self, _cx: &CallContext, diff --git a/rust/crates/truapi/src/api/statement_store.rs b/rust/crates/truapi/src/api/statement_store.rs index 92756b097..f3c5c3d76 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -57,7 +57,7 @@ pub trait StatementStore: Send + Sync { /// const page = await waitForStatement(); /// console.log("subscribe received", page); /// ``` - #[wire(start_id = 56)] + #[wire(start_id = 56, sensitive)] async fn subscribe( &self, _cx: &CallContext, @@ -96,7 +96,7 @@ pub trait StatementStore: Send + Sync { /// console.log("proof created:", result.value); /// } /// ``` - #[wire(request_id = 60)] + #[wire(request_id = 60, sensitive)] async fn create_proof( &self, _cx: &CallContext, @@ -123,7 +123,7 @@ pub trait StatementStore: Send + Sync { /// assert(result.isOk(), "createProof failed:", result); /// console.log("proof created:", result.value); /// ``` - #[wire(request_id = 132)] + #[wire(request_id = 132, sensitive)] async fn create_proof_authorized( &self, _cx: &CallContext, @@ -155,7 +155,7 @@ pub trait StatementStore: Send + Sync { /// assert(result.isOk(), "submit failed:", result); /// console.log("statement submitted"); /// ``` - #[wire(request_id = 62)] + #[wire(request_id = 62, sensitive)] async fn submit( &self, _cx: &CallContext, From 41be440d4657b84bce9eae515ba4b818de222f45 Mon Sep 17 00:00:00 2001 From: Nidish Date: Fri, 31 Jul 2026 19:59:18 +0530 Subject: [PATCH 02/17] feat(truapi-server): payload-blind wire-debug tap and sinks --- Cargo.lock | 1 + rust/crates/truapi-server/Cargo.toml | 7 +- rust/crates/truapi-server/src/host_core.rs | 241 ++++++++++++- rust/crates/truapi-server/src/lib.rs | 11 +- rust/crates/truapi-server/src/native_debug.rs | 336 ++++++++++++++++++ rust/crates/truapi-server/src/wasm.rs | 41 ++- 6 files changed, 629 insertions(+), 8 deletions(-) create mode 100644 rust/crates/truapi-server/src/native_debug.rs diff --git a/Cargo.lock b/Cargo.lock index 3fd3b5a09..7339c74a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5197,6 +5197,7 @@ name = "truapi-server" version = "0.1.0" dependencies = [ "async-trait", + "base64", "blake2b_simd", "chacha20poly1305", "console_error_panic_hook", diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index e941f6c27..390556255 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -28,7 +28,7 @@ dwarf-debug-info = false [features] default = ["wasm-signing-host"] wasm-signing-host = [] -ws-bridge = ["dep:tokio", "dep:tokio-tungstenite", "dep:rand"] +ws-bridge = ["dep:tokio", "dep:tokio-tungstenite", "dep:rand", "dep:base64"] [dependencies] truapi = { path = "../truapi" } @@ -71,13 +71,14 @@ truapi = { path = "../truapi", features = ["uniffi"] } truapi-platform = { path = "../truapi-platform", features = ["uniffi"] } futures = { version = "0.3", features = ["thread-pool"] } rand = { version = "0.8", optional = true } -tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "io-util"], optional = true } +tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "io-util", "time"], optional = true } tokio-tungstenite = { version = "0.21", default-features = false, features = ["handshake"], optional = true } uniffi.workspace = true subxt = { version = "0.50.3", default-features = false, features = ["native"] } subxt-rpcs = { version = "0.50.3", default-features = false, features = ["jsonrpsee", "native"] } frame-metadata = { version = "23", default-features = false, features = ["std", "current", "decode"] } scale-info = { version = "2.11", default-features = false, features = ["decode"] } +base64 = { version = "0.22", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] futures-timer = { version = "3", features = ["wasm-bindgen"] } @@ -99,7 +100,7 @@ wasm-bindgen-test = "0.3" [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "io-util", "time"] } -tokio-tungstenite = { version = "0.21", default-features = false, features = ["connect"] } +tokio-tungstenite = { version = "0.21", default-features = false, features = ["connect", "handshake"] } [lints] workspace = true diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index f474b4059..6b60f5351 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -45,6 +45,68 @@ pub trait FrameSink: Send + Sync { fn emit_frame(&self, frame: Vec); } +/// Dev-only sink that observes host debug events at the core's two frame choke +/// points. A host that does not enable the debugger leaves it unset and the tap +/// 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 { + /// Hand one event to the sink. + /// + /// Must not block, and must not panic: `emit` is called from inside the + /// inbound and outbound frame paths, so a panic here would unwind into a + /// live dispatch. Serialize and enqueue only; never do fallible work that + /// can `unwrap`/panic on the caller's thread. + fn emit(&self, event: DebugEvent); +} + +/// Identifies which product channel on a host a debug event belongs to, so one +/// debugger app can demultiplex several channels. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelId(pub String); + +/// Direction of a tapped frame relative to the host core. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameDirection { + /// Product to core (inbound to the host). + In, + /// Core to product (outbound from the host). + Out, +} + +impl FrameDirection { + /// The wire direction string, from the **product's** vantage - the vantage + /// the debugger app and the design doc use: `"out"` = the frame left the + /// product, `"in"` = it arrived at the product. This is the inverse of the + /// enum's host-vantage variants (`In` = product to core, i.e. it *left* the + /// product), so every sink serializes the same product-vantage string + /// instead of re-deriving (and risking inverting) it. + pub fn wire_str(self) -> &'static str { + match self { + FrameDirection::In => "out", + FrameDirection::Out => "in", + } + } +} + +/// One observable host debug event. Frame bytes are the untouched +/// `ProtocolMessage`; the debugger decodes them, so the core never does. The +/// enum leaves room for host-internal events (e.g. SSO) that have no wire frame, +/// so it is `#[non_exhaustive]`: adding a variant is not a breaking change. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum DebugEvent { + /// A SCALE wire frame crossing a product channel. + Frame { + /// Which product channel on this host. + channel_id: ChannelId, + /// Product to core, or core to product. + dir: FrameDirection, + /// Untouched encoded `ProtocolMessage` bytes. + bytes: Vec, + }, +} + /// Errors returned while routing work through a product runtime. #[derive(Debug, Clone, Error)] #[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Error))] @@ -851,6 +913,8 @@ impl ProductRuntime { let transport = Arc::new(SinkTransport { sink, disposed: disposed.clone(), + has_debug: AtomicBool::new(false), + debug: Mutex::new(None), }); let host_subscriptions = Arc::new(HostInitiatedSubscriptionManager::new()); Self { @@ -879,6 +943,15 @@ impl ProductRuntime { return Ok(()); } + // Tap inbound before decode, so a corrupt frame is still observed. + if let Some((channel_id, debug)) = self.transport.debug() { + debug.emit(DebugEvent::Frame { + channel_id, + dir: FrameDirection::In, + bytes: frame.clone(), + }); + } + let message = ProtocolMessage::decode(&mut frame.as_slice()).map_err(|err| { ProductRuntimeError::InvalidFrame { reason: err.to_string(), @@ -956,6 +1029,13 @@ impl ProductRuntime { .await } + /// Install a dev-only [`DebugSink`] that observes every product frame in + /// both directions for `channel_id`. Absent by default and inert in + /// production; fire-and-forget, so it can never stall or fail a dispatch. + pub fn set_debug_sink(&self, channel_id: ChannelId, sink: Arc) { + self.transport.set_debug_sink(channel_id, sink); + } + /// Dispose this host core. Idempotent. /// /// Disposal suppresses future outgoing frames, aborts in-flight dispatch @@ -982,6 +1062,33 @@ impl ProductRuntime { struct SinkTransport { sink: Arc, disposed: Arc, + /// Fast-path flag: `false` (the production default) lets the per-frame + /// `debug()` return without touching the mutex. Set once when a sink is + /// installed; a reader that races the install just misses one frame. + has_debug: AtomicBool, + debug: Mutex)>>, +} + +impl SinkTransport { + /// The installed debug sink and its channel, if any. Lock-free `None` on the + /// production path (no sink installed); only locks once one is. + fn debug(&self) -> Option<(ChannelId, Arc)> { + if !self.has_debug.load(Ordering::Relaxed) { + return None; + } + self.debug + .lock() + .expect("host core debug sink mutex poisoned") + .clone() + } + + fn set_debug_sink(&self, channel_id: ChannelId, sink: Arc) { + *self + .debug + .lock() + .expect("host core debug sink mutex poisoned") = Some((channel_id, sink)); + self.has_debug.store(true, Ordering::Relaxed); + } } impl Transport for SinkTransport { @@ -989,7 +1096,20 @@ impl Transport for SinkTransport { if self.disposed.load(Ordering::Acquire) { return; } - self.sink.emit_frame(message.encode()); + let encoded = message.encode(); + // Forward to the product first, then tap: the debugger is in the path + // but never in the critical path. + match self.debug() { + Some((channel_id, debug)) => { + self.sink.emit_frame(encoded.clone()); + debug.emit(DebugEvent::Frame { + channel_id, + dir: FrameDirection::Out, + bytes: encoded, + }); + } + None => self.sink.emit_frame(encoded), + } } fn on_message( @@ -1041,6 +1161,125 @@ mod tests { assert_send(runtime.receive_frame(Vec::new())); } + #[derive(Default)] + struct RecordingDebugSink { + events: Mutex)>>, + } + + impl DebugSink for RecordingDebugSink { + fn emit(&self, event: DebugEvent) { + match event { + DebugEvent::Frame { + channel_id, + dir, + bytes, + } => self + .events + .lock() + .expect("debug events mutex poisoned") + .push((channel_id, dir, bytes)), + } + } + } + + #[test] + fn debug_sink_taps_frames_in_both_directions() { + let platform = Arc::new(StubPlatform::default()); + let sink = Arc::new(RecordingSink::default()); + let debug = Arc::new(RecordingDebugSink::default()); + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + platform, + host_config, + product, + test_spawner(), + sink.clone(), + ); + runtime.set_debug_sink(ChannelId("myapp.dot".to_string()), debug.clone()); + + let ids = subscription_ids("theme_subscribe").expect("known subscription"); + let frame = ProtocolMessage { + request_id: "theme:1".to_string(), + payload: Payload { + id: ids.start_id, + value: Vec::new(), + }, + }; + let raw = frame.encode(); + futures::executor::block_on(runtime.receive_frame(raw.clone())).unwrap(); + + // The subscription's first item is emitted asynchronously; wait for it, + // then let the tap (which runs right after delivery in `send`) settle. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while sink + .frames + .lock() + .expect("recording sink mutex poisoned") + .is_empty() + && std::time::Instant::now() < deadline + { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + + // Snapshot into owned vecs (never hold a lock across an assertion). + let (inbound, outbound, channels): (Vec>, Vec>, Vec) = { + let events = debug.events.lock().expect("debug events mutex poisoned"); + ( + events + .iter() + .filter(|(_, dir, _)| *dir == FrameDirection::In) + .map(|(_, _, bytes)| bytes.clone()) + .collect(), + events + .iter() + .filter(|(_, dir, _)| *dir == FrameDirection::Out) + .map(|(_, _, bytes)| bytes.clone()) + .collect(), + events.iter().map(|(cid, _, _)| cid.clone()).collect(), + ) + }; + let delivered = sink + .frames + .lock() + .expect("recording sink mutex poisoned") + .clone(); + + // Every event carries the installed channel id. + assert!( + channels + .iter() + .all(|c| *c == ChannelId("myapp.dot".to_string())), + "every event carries its channel id" + ); + // Inbound tapped once, untouched, before decode. + assert_eq!( + inbound, + vec![raw], + "inbound frame tapped exactly once, untouched" + ); + // Both directions fire, and every delivered outbound frame is tapped in + // order: the tap is in the path, not a fabricated side channel. + assert!( + !outbound.is_empty(), + "at least one outbound frame is tapped" + ); + assert_eq!( + outbound, delivered, + "every delivered outbound frame is tapped, in order" + ); + } + + #[test] + fn frame_direction_wire_str_is_product_vantage() { + // The wire string is product-vantage (what the debugger and design doc + // use), the inverse of the enum's host-vantage names: a frame the host + // tapped as `In` (product to core) *left the product*, so it serializes + // as `"out"`. This pins the convention so a sink can't re-invert it. + assert_eq!(FrameDirection::In.wire_str(), "out"); + assert_eq!(FrameDirection::Out.wire_str(), "in"); + } + #[test] fn spa_connection_rejects_native_custom_rendering() { let (host_config, product) = runtime_config("myapp.dot"); diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index d9ba7b79b..9d593e6f8 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -15,6 +15,8 @@ //! native WebView hosts (Android/iOS). //! - [`native`]: UniFFI surface exposing the native host runtime + callbacks. //! - `wasm` (wasm32 only): wasm-bindgen surface exposing `WasmProductRuntime`. +//! - `native_debug` (non-wasm32 only): a loopback WebSocket [`DebugSink`] that +//! streams tapped frames to the `@parity/truapi-debugger` app. pub(crate) mod chain_runtime; pub mod core; @@ -46,13 +48,18 @@ pub mod native_renderer; #[cfg(target_arch = "wasm32")] pub mod wasm; +#[cfg(all(not(target_arch = "wasm32"), feature = "ws-bridge"))] +pub mod native_debug; + pub use host_core::{ - FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeControl, - ProductRuntimeError, SigningHostRuntime, + ChannelId, DebugEvent, DebugSink, FrameDirection, FrameSink, HostAdmin, PairingHostRuntime, + ProductRuntime, ProductRuntimeControl, ProductRuntimeError, SigningHostRuntime, }; pub use host_logic::session::{ ExternalPairedSession, SsoSessionInfo, decode_persisted_session, encode_external_paired_session, }; +#[cfg(all(not(target_arch = "wasm32"), feature = "ws-bridge"))] +pub use native_debug::{DebugSinkError, WsDebugSink}; pub use runtime::ResponderExit; #[cfg(not(target_arch = "wasm32"))] pub use runtime::StatementRenewalTarget; diff --git a/rust/crates/truapi-server/src/native_debug.rs b/rust/crates/truapi-server/src/native_debug.rs new file mode 100644 index 000000000..89b1b1b4b --- /dev/null +++ b/rust/crates/truapi-server/src/native_debug.rs @@ -0,0 +1,336 @@ +//! Native (non-wasm) [`DebugSink`]: streams tapped frames to a loopback +//! `@parity/truapi-debugger` over a WebSocket. +//! +//! The native counterpart of the wasm [`crate::wasm`] `WasmDebugSink`: a dumb, +//! payload-blind byte-forwarder. Each [`DebugEvent::Frame`] is serialized to the +//! debugger's wire envelope - `{channelId, dir, frame}`, where `frame` is the +//! base64 of the untouched SCALE `ProtocolMessage` bytes - and sent as one WS +//! text message. Decoding and the sensitive-frame denylist live in the debugger +//! app, never here. +//! +//! Fire-and-forget by construction, per the [`DebugSink`] contract: +//! [`WsDebugSink::emit`] never blocks and never fails a dispatch. It only +//! serializes and pushes onto a bounded queue; a background task owns the socket, +//! reconnects with capped backoff, and drops frames (counted) when the queue is +//! full. A slow, absent, or crashed debugger loses traces, never a session. +//! +//! Localhost only: the target URL must be `ws://` on a loopback host. No `wss`, +//! no certificates, no LAN. Construct via [`WsDebugSink::connect`] from within a +//! Tokio runtime and install with [`crate::ProductRuntime::set_debug_sink`]; +//! constructing one is a dev-only opt-in, so a host that never calls it leaves +//! the tap inert. + +use core::net::SocketAddr; +use core::sync::atomic::{AtomicU64, Ordering}; +use core::time::Duration; +use std::sync::Arc; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use futures::{SinkExt, StreamExt}; +use serde::Serialize; +use thiserror::Error; +use tokio::net::TcpStream; +use tokio::runtime::Handle; +use tokio::sync::mpsc; +use tokio_tungstenite::client_async; +use tokio_tungstenite::tungstenite::Message; +use tracing::debug; + +use crate::host_core::{DebugEvent, DebugSink}; + +/// Bounded so a stalled or absent debugger applies backpressure as counted +/// drops, never unbounded memory growth on the observed session. +const QUEUE_CAPACITY: usize = 4096; + +/// Initial reconnect delay; doubles on each failed dial up to [`MAX_BACKOFF`]. +const INITIAL_BACKOFF: Duration = Duration::from_millis(200); + +/// Cap on the reconnect backoff. +const MAX_BACKOFF: Duration = Duration::from_secs(5); + +/// Cap on a single dial + WS handshake; a port that accepts TCP but never +/// completes the upgrade must not park the writer task forever. +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Failure building a [`WsDebugSink`]. +#[derive(Debug, Error)] +pub enum DebugSinkError { + /// The debug URL did not parse. + #[error("invalid debug url: {0}")] + Url(#[from] url::ParseError), + /// The debug URL was not `ws://` on a loopback host. + #[error("debug url must be ws:// on a loopback host, got {0}")] + NotLoopback(String), + /// The debug URL host could not be resolved. + #[error("could not resolve debug url host: {0}")] + Resolve(#[from] std::io::Error), + /// `connect` was called outside a Tokio runtime. + #[error("WsDebugSink::connect must be called from within a Tokio runtime")] + NoRuntime, +} + +/// A dev-only [`DebugSink`] that forwards tapped frames to a loopback debugger +/// over a WebSocket, using the same `{channelId, dir, frame: base64}` envelope +/// the browser host sends. +pub struct WsDebugSink { + outbound: mpsc::Sender, + dropped: Arc, +} + +/// The wire envelope, matching the debugger's `parseWireMessage` / ingest +/// `DebugFrameEnvelope`: `dir` is product-vantage, `frame` is base64 SCALE bytes. +#[derive(Serialize)] +struct WireMessage<'a> { + #[serde(rename = "channelId")] + channel_id: &'a str, + dir: &'a str, + frame: String, +} + +impl WsDebugSink { + /// Build a sink targeting `url` and spawn its writer task. + /// + /// `url` must be `ws://` on `127.0.0.1`, `localhost`, or `[::1]`. Returns + /// immediately even if the debugger is not yet listening; the writer task + /// dials lazily and reconnects. Must be called from within a Tokio runtime. + pub fn connect(url: &str) -> Result, DebugSinkError> { + // Require ws://, then RESOLVE the host and require every resolved + // address to be loopback. Resolving (rather than string-matching the + // host) accepts all genuine loopback forms - 127.0.0.0/8, ::1, and a + // `localhost` that resolves to them - and rejects anything resolving + // off-loopback, closing the "validate one string, dial another" gap. + let parsed = url::Url::parse(url)?; + if parsed.scheme() != "ws" { + return Err(DebugSinkError::NotLoopback(url.to_string())); + } + // `Url::socket_addrs` resolves the host (IP literal or DNS) and handles + // IPv6 bracket-stripping and the default port; requiring every resolved + // address to be loopback accepts all genuine loopback forms (127.0.0.0/8, + // ::1, a `localhost` that resolves to them) and rejects anything that + // resolves off-loopback. + let addrs = parsed.socket_addrs(|| Some(80))?; + if !addrs.iter().all(|addr| addr.ip().is_loopback()) { + return Err(DebugSinkError::NotLoopback(url.to_string())); + } + // Capture the resolved loopback address and dial *it* directly (in + // `writer_loop`), rather than re-resolving the URL string on every dial. + // The WS handshake is therefore only ever sent to this checked loopback + // peer - closing the resolve-then-dial gap where a mid-session resolver + // change could send the handshake off-box. + let Some(addr) = addrs.first().copied() else { + return Err(DebugSinkError::NotLoopback(url.to_string())); + }; + + // Return a Result rather than panicking inside tokio::spawn when called + // outside a runtime. + if Handle::try_current().is_err() { + return Err(DebugSinkError::NoRuntime); + } + + let (outbound, inbox) = mpsc::channel::(QUEUE_CAPACITY); + let dropped = Arc::new(AtomicU64::new(0)); + tokio::spawn(writer_loop( + url.to_string(), + addr, + inbox, + Arc::clone(&dropped), + )); + Ok(Arc::new(Self { outbound, dropped })) + } + + /// 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 { + self.dropped.load(Ordering::Relaxed) + } +} + +impl DebugSink for WsDebugSink { + fn emit(&self, event: DebugEvent) { + let DebugEvent::Frame { + channel_id, + dir, + bytes, + } = event; + let message = WireMessage { + channel_id: &channel_id.0, + // Product-vantage string; never hand-mapped, so it cannot invert. + dir: dir.wire_str(), + frame: BASE64.encode(&bytes), + }; + let Ok(line) = serde_json::to_string(&message) else { + self.dropped.fetch_add(1, Ordering::Relaxed); + return; + }; + if self.outbound.try_send(line).is_err() { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } +} + +/// Own the socket for the sink's lifetime: dial with capped backoff, then drain +/// the queue to the wire until the sink is dropped. +async fn writer_loop( + url: String, + addr: SocketAddr, + mut inbox: mpsc::Receiver, + dropped: Arc, +) { + let mut backoff = INITIAL_BACKOFF; + loop { + // Dial the pre-validated loopback address directly, then run the WS + // handshake over that socket. The address is not re-resolved, so the + // handshake can never reach an off-box peer. The whole dial+handshake is + // bounded so a TCP-accepting but non-upgrading port can't park the task. + let dialed = tokio::time::timeout(HANDSHAKE_TIMEOUT, async { + let tcp = TcpStream::connect(addr).await.ok()?; + client_async(url.as_str(), tcp).await.ok() + }) + .await; + let stream = match dialed { + Ok(Some((stream, _response))) => Some(stream), + Ok(None) => { + debug!("truapi debug sink: dial/handshake failed, retrying"); + None + } + Err(_) => { + debug!("truapi debug sink: handshake timed out, retrying"); + None + } + }; + let Some(stream) = stream else { + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(MAX_BACKOFF); + // The sink was dropped while we were retrying: give up. + if inbox.is_closed() { + return; + } + continue; + }; + let (mut write, mut read) = stream.split(); + // Drain queued frames to the wire, and also poll the read half so + // tokio-tungstenite answers server pings and observes a Close; being + // forward-only, any inbound message is ignored. Reset backoff only on a + // *delivered* frame, so an accept-then-close server still backs off + // instead of spinning on zero-delay reconnects. + loop { + tokio::select! { + queued = inbox.recv() => match queued { + Some(line) => match write.send(Message::Text(line)).await { + Ok(()) => backoff = INITIAL_BACKOFF, + Err(_) => { + debug!("truapi debug sink: socket closed, reconnecting"); + // The in-flight line is lost across this reconnect. + dropped.fetch_add(1, Ordering::Relaxed); + break; + } + }, + // All senders dropped: the sink is gone, so is the host. Done. + None => return, + }, + inbound = read.next() => match inbound { + Some(Ok(_)) => {} // forward-only: ignore any inbound message + Some(Err(_)) | None => { + debug!("truapi debug sink: read side closed, reconnecting"); + break; + } + }, + } + } + // Reconnect after an established socket dropped: back off here too. + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(MAX_BACKOFF); + if inbox.is_closed() { + return; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_core::{ChannelId, FrameDirection}; + + use tokio::net::TcpListener; + use tokio::sync::oneshot; + use tokio_tungstenite::accept_async; + + #[tokio::test] + async fn emits_base64_envelope_with_product_vantage_dir() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + // Server side: accept one connection, capture the first text message. + let (tx, rx) = oneshot::channel::(); + tokio::spawn(async move { + let (stream, _peer) = listener.accept().await.unwrap(); + let ws = accept_async(stream).await.unwrap(); + let (_write, mut read) = ws.split(); + let message = read.next().await.unwrap().unwrap(); + tx.send(message.into_text().unwrap()).unwrap(); + }); + + let sink = WsDebugSink::connect(&format!("ws://127.0.0.1:{port}")).unwrap(); + // `In` = product→core, i.e. the frame *left* the product → product-vantage "out". + sink.emit(DebugEvent::Frame { + channel_id: ChannelId("myapp.dot".to_string()), + dir: FrameDirection::In, + bytes: vec![1, 2, 3, 4], + }); + + let text = tokio::time::timeout(Duration::from_secs(5), rx) + .await + .expect("debugger did not receive a frame") + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + + assert_eq!(value["channelId"], "myapp.dot"); + // Guard against re-inversion: In must serialize as product-vantage "out". + assert_eq!(value["dir"], FrameDirection::In.wire_str()); + assert_eq!(value["dir"], "out"); + assert_eq!(value["frame"], BASE64.encode([1, 2, 3, 4])); + } + + #[test] + fn rejects_non_loopback_and_non_ws_urls() { + // 192.0.2.1 (TEST-NET-1) is a non-loopback IP literal, so no DNS is hit. + assert!(WsDebugSink::connect("wss://127.0.0.1:9231").is_err()); + assert!(WsDebugSink::connect("ws://192.0.2.1:9231").is_err()); + assert!(WsDebugSink::connect("http://127.0.0.1:9231").is_err()); + assert!(WsDebugSink::connect("not a url").is_err()); + } + + #[tokio::test] + async fn accepts_loopback_forms() { + for url in [ + "ws://127.0.0.1:9231", + "ws://localhost:9231", + "ws://[::1]:9231", + ] { + assert!(WsDebugSink::connect(url).is_ok(), "should accept {url}"); + } + } + + #[tokio::test] + async fn emit_is_nonblocking_and_counts_drops_when_debugger_absent() { + // A loopback port with nothing listening: dials never succeed, so the + // bounded queue fills and further frames are dropped, never blocking emit. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); // free the port; nothing is listening now + + let sink = WsDebugSink::connect(&format!("ws://127.0.0.1:{port}")).unwrap(); + for _ in 0..(QUEUE_CAPACITY + 50) { + sink.emit(DebugEvent::Frame { + channel_id: ChannelId("myapp.dot".to_string()), + dir: FrameDirection::Out, + bytes: vec![1], + }); + } + assert!( + sink.dropped() > 0, + "a full queue must count drops, not block" + ); + } +} diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index 08a04fe2e..de639be17 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -35,8 +35,8 @@ use wasm_bindgen::prelude::*; use crate::SigningHostRuntime; use crate::subscription::Spawner; use crate::{ - FrameSink, PairingHostRuntime, PermissionAuthorizationRequest, PermissionAuthorizationStatus, - ProductRuntime, + ChannelId, DebugEvent, DebugSink, FrameSink, PairingHostRuntime, + PermissionAuthorizationRequest, PermissionAuthorizationStatus, ProductRuntime, }; mod generated_bridge; @@ -71,6 +71,33 @@ impl FrameSink for WasmFrameSink { } } +/// Streams tapped debug frames out to a JS `debugEmit(channelId, dir, frame)` +/// callback so the host worker can forward them to the debugger it dials. +/// Dev-only: installed only when the host provides the callback, and +/// fire-and-forget - a failing callback is logged, never propagated. +struct WasmDebugSink { + emit: SendWrapper, +} + +impl DebugSink for WasmDebugSink { + fn emit(&self, event: DebugEvent) { + let DebugEvent::Frame { + channel_id, + dir, + bytes, + } = event; + let frame = Uint8Array::from(bytes.as_slice()); + if let Err(err) = self.emit.call3( + &JsValue::NULL, + &JsValue::from_str(&channel_id.0), + &JsValue::from_str(dir.wire_str()), + &frame, + ) { + web_sys::console::error_1(&err); + } + } +} + struct WasmPlatform { bridge: SendWrapper>, } @@ -757,10 +784,20 @@ impl WasmPairingHostRuntime { ) -> Result { let product = product_context_from_js(&product)?; let channel = CoreChannel::from_js(&core_callbacks)?; + let debug_emit = get_optional_function(&core_callbacks, "debugEmit")?; + let channel_id = product.product_id.clone(); let sink = Arc::new(WasmFrameSink { emit_frame: SendWrapper::new(channel.emit_frame), }); let runtime = self.runtime.product_runtime(product, sink); + if let Some(debug_emit) = debug_emit { + runtime.set_debug_sink( + ChannelId(channel_id), + Arc::new(WasmDebugSink { + emit: SendWrapper::new(debug_emit), + }), + ); + } Ok(WasmProductRuntime::from_parts(runtime, channel.dispose)) } From 3862942c207a1f3c118b0e91cac122dc438ef359 Mon Sep 17 00:00:00 2001 From: Nidish Date: Mon, 3 Aug 2026 01:16:50 +0530 Subject: [PATCH 03/17] feat(truapi-host): dev-gated worker dial to the debugger --- js/packages/truapi-host/README.md | 16 +++ .../src/web/create-worker-host-runtime.ts | 12 ++ .../src/web/worker-provider.test.ts | 1 + .../truapi-host/src/worker-protocol.ts | 9 +- js/packages/truapi-host/src/worker-runtime.ts | 116 +++++++++++++++++- playground/tests/e2e/helpers.ts | 6 +- 6 files changed, 156 insertions(+), 4 deletions(-) diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index b3c600828..75da308f8 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -157,6 +157,22 @@ await runtime.activateStoredSession().catch(() => {}); const provider = await runtime.createProvider({ productId: "first.dot" }); ``` +## Debugging (dev-only) + +The worker can stream every product↔core wire frame to the wire debugger. It is +off by default and enabled purely from the host page — the product needs no +changes. Set a debugger URL in the host origin's `localStorage`, then run the +debugger (`@parity/truapi-debugger`, `npm run serve`, `:9231`): + +```js +localStorage.setItem("truapi:debugger", "ws://localhost:9231"); +``` + +On the next runtime boot the worker reads that URL, dials the debugger, and (via +the Rust core's `DebugSink` tap) sends each frame as `{ channelId, dir, frame }`. +Unset in production, so nothing dials and the core installs no tap. Design: +`docs/design/wire-observability-debug-host.md`. + ## Publishing This package is published by the root `Release` workflow through diff --git a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts index 505accc22..4bcd3c9de 100644 --- a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -161,6 +161,17 @@ function readPersistedLogLevel(): LogLevel | null { return globalThis.localStorage?.getItem(DEV_LOG_LEVEL_KEY) ?? null; } +// Dev-only, host-agnostic enablement for the wire debugger: set +// `localStorage["truapi:debugger"] = "ws://:9231"` in the browser and the +// host worker dials that debugger and streams frames to it. Unset in production, +// so nothing dials and the Rust host tap stays inert. Read here (host page) and +// forwarded to the worker in `init`; no cooperation from the embedding shell. +const DEV_DEBUGGER_URL_KEY = "truapi:debugger"; + +function readPersistedDebuggerUrl(): string | null { + return globalThis.localStorage?.getItem(DEV_DEBUGGER_URL_KEY) ?? null; +} + function persistLogLevel(level: LogLevel): void { globalThis.localStorage?.setItem(DEV_LOG_LEVEL_KEY, level); } @@ -724,6 +735,7 @@ export function createWebWorkerPairingHostRuntime( kind: "init", logLevel: devLogLevelOverride ?? options.logLevel ?? "off", hostConfig: options.hostConfig, + debuggerUrl: readPersistedDebuggerUrl(), } satisfies MainToWorker); } else if (msg.kind === "ready") { cleanupInit(); diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index 840579cf5..dc77e18ad 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -215,6 +215,7 @@ describe("createWebWorkerPairingHostRuntime", () => { kind: "init", logLevel: "debug", hostConfig: hostConfigFromRuntimeConfig(config), + debuggerUrl: null, }); worker.emit({ kind: "ready" }); diff --git a/js/packages/truapi-host/src/worker-protocol.ts b/js/packages/truapi-host/src/worker-protocol.ts index 1ae9c478e..576262d2f 100644 --- a/js/packages/truapi-host/src/worker-protocol.ts +++ b/js/packages/truapi-host/src/worker-protocol.ts @@ -55,7 +55,14 @@ export type CallbackArgs = readonly unknown[]; * host callback/subscription/chain responses requested by the worker. */ export type MainToWorker = - | { kind: "init"; logLevel: LogLevel; hostConfig: unknown } + | { + kind: "init"; + logLevel: LogLevel; + hostConfig: unknown; + // Dev-only: when set, the worker dials this debugger and streams tapped + // frames to it. Null in production, so the host tap stays inert. + debuggerUrl: string | null; + } | { kind: "createCore"; coreId: number; product: unknown } | { kind: "disposeCore"; coreId: number } | { kind: "setLogLevel"; level: LogLevel } diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index ff1aac10a..790e2833c 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -159,8 +159,110 @@ function buildRawCallbacks() { }); } -function buildCoreCallbacks(coreId: number) { +/** Encode raw frame bytes as base64 (JSON can't carry binary over the WS). */ +function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); +} + +/** + * Dev-only link to the debugger the host dials. Fire-and-forget by construction: + * it opens lazily, buffers a bounded backlog until the socket is up, retries a + * dropped connection, and swallows every error - a slow, absent, or crashed + * debugger only loses the trace, it can never throw into the frame path. + */ +/** + * Is `url` a WebSocket URL on a loopback host? The debug tap forwards raw frames + * (including sensitive payloads, before the debugger's denylist runs), so it is + * loopback-only: refuse to stream them off the local machine. + */ +function isLoopbackWsUrl(url: string): boolean { + try { + const u = new URL(url); + if (u.protocol !== "ws:" && u.protocol !== "wss:") return false; + const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return ( + host === "localhost" || + host === "::1" || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host) || + // IPv4-mapped loopback: WHATWG serializes ::ffff:127.x.y.z as ::ffff:7fxx:yyyy. + /^::ffff:7f[0-9a-f]{2}:/.test(host) + ); + } catch { + return false; + } +} + +function createDebuggerLink(url: string): { + emit(channelId: string, dir: string, frame: Uint8Array): void; +} { + // Loopback-only, dev-only: a non-loopback debugger URL yields an inert link + // rather than streaming frames across the network. + if (!isLoopbackWsUrl(url)) return { emit() {} }; + let socket: WebSocket | null = null; + let open = false; + const queue: string[] = []; + const MAX_QUEUE = 1000; + + function connect(): void { + try { + socket = new WebSocket(url); + } catch { + socket = null; + return; + } + socket.addEventListener("open", () => { + open = true; + for (const message of queue.splice(0)) send(message); + }); + socket.addEventListener("close", () => { + open = false; + socket = null; + }); + socket.addEventListener("error", () => { + // A socket that fired `error` is dead: close it explicitly (tidiness), then + // null it so `emit`'s `if (!socket) connect()` reconnects. Without the null, + // a runtime that fires `error` without a following `close` would leave + // `socket` non-null and frames would buffer then drop. + open = false; + const dead = socket; + socket = null; + try { + dead?.close(); + } catch { + // already closed / closing + } + }); + } + + function send(message: string): void { + try { + socket?.send(message); + } catch { + // A dead socket must never break the frame path. + } + } + + connect(); + return { + emit(channelId, dir, frame) { + const message = JSON.stringify({ channelId, dir, frame: toBase64(frame) }); + if (open && socket) { + send(message); + return; + } + if (queue.length < MAX_QUEUE) queue.push(message); + if (!socket) connect(); + }, + }; +} + +let debuggerLink: ReturnType | null = null; + +function buildCoreCallbacks(coreId: number) { + const callbacks = { emitFrame(frame: Uint8Array): void { postToMain({ kind: "frame", coreId, bytes: frame }); }, @@ -168,6 +270,15 @@ function buildCoreCallbacks(coreId: number) { // Main thread owns lifecycle and disposes explicitly. }, }; + if (!debuggerLink) return callbacks; + // Adding `debugEmit` is what makes the Rust host install its debug sink; when + // no debugger is configured it is absent and the tap stays inert. + return { + ...callbacks, + debugEmit(channelId: string, dir: string, frame: Uint8Array): void { + debuggerLink?.emit(channelId, dir, frame); + }, + }; } let runtime: WorkerPairingHostRuntime | null = null; @@ -203,6 +314,9 @@ ctx.addEventListener("message", (ev: MessageEvent) => { break; } wasm.setLogLevel?.(msg.logLevel); + if (msg.debuggerUrl && !debuggerLink) { + debuggerLink = createDebuggerLink(msg.debuggerUrl); + } try { runtime = new wasm.WasmPairingHostRuntime( buildRawCallbacks(), diff --git a/playground/tests/e2e/helpers.ts b/playground/tests/e2e/helpers.ts index c61550efe..9417f4e42 100644 --- a/playground/tests/e2e/helpers.ts +++ b/playground/tests/e2e/helpers.ts @@ -9,7 +9,9 @@ import { expect, type FrameLocator, type Page } from "@playwright/test"; * We hand back the FrameLocator scoped to that iframe so individual specs only * need to know about playground selectors. */ -export async function openPlaygroundInDotli(page: Page): Promise { +export async function openPlaygroundInDotli( + page: Page, +): Promise { await page.addInitScript(() => { localStorage.setItem("dotli:mode", "gateway"); localStorage.setItem("dotli:chain-backend", "rpc"); @@ -24,7 +26,7 @@ export async function openPlaygroundInDotli(page: Page): Promise { window as typeof window & { __TRUAPI_PLAYGROUND_E2E__?: boolean } ).__TRUAPI_PLAYGROUND_E2E__ = true; }); - await page.goto("/localhost:3000?dotliProductId=truapi-playground.dot"); + await page.goto(`/localhost:3000?dotliProductId=truapi-playground.dot`); // dotli renders an additional hidden iframe (host.localhost:5173?mode=direct) // alongside the proxied playground; scope to the playground src so the // FrameLocator is unique under Playwright strict mode. From 7ed852793d2ff77b1c57a0bd1957b1a5d34a5fc3 Mon Sep 17 00:00:00 2001 From: Nidish Date: Mon, 3 Aug 2026 01:16:51 +0530 Subject: [PATCH 04/17] feat(truapi-debugger): wire trace, decode, and render engine --- .github/workflows/ci.yml | 38 ++ CLAUDE.md | 7 + js/packages/truapi-debugger/.gitignore | 3 + js/packages/truapi-debugger/README.md | 95 +++++ js/packages/truapi-debugger/package.json | 26 ++ .../truapi-debugger/src/decode.test.ts | 396 ++++++++++++++++++ js/packages/truapi-debugger/src/decode.ts | 238 +++++++++++ js/packages/truapi-debugger/src/index.ts | 43 ++ js/packages/truapi-debugger/src/ingest.ts | 93 ++++ .../truapi-debugger/src/observed-frame.ts | 68 +++ .../truapi-debugger/src/operation-row.test.ts | 129 ++++++ .../truapi-debugger/src/retry-storm.test.ts | 150 +++++++ .../truapi-debugger/src/retry-storm.ts | 94 +++++ js/packages/truapi-debugger/src/session.ts | 142 +++++++ .../truapi-debugger/src/trace-render.test.ts | 106 +++++ .../truapi-debugger/src/trace-render.ts | 390 +++++++++++++++++ .../truapi-debugger/src/trace-styles.ts | 199 +++++++++ .../truapi-debugger/src/trace-view.test.ts | 138 ++++++ js/packages/truapi-debugger/src/trace-view.ts | 315 ++++++++++++++ .../truapi-debugger/src/wire-debugger.test.ts | 86 ++++ .../truapi-debugger/src/wire-debugger.ts | 255 +++++++++++ js/packages/truapi-debugger/tsconfig.json | 20 + package-lock.json | 30 ++ 23 files changed, 3061 insertions(+) create mode 100644 js/packages/truapi-debugger/.gitignore create mode 100644 js/packages/truapi-debugger/README.md create mode 100644 js/packages/truapi-debugger/package.json create mode 100644 js/packages/truapi-debugger/src/decode.test.ts create mode 100644 js/packages/truapi-debugger/src/decode.ts create mode 100644 js/packages/truapi-debugger/src/index.ts create mode 100644 js/packages/truapi-debugger/src/ingest.ts create mode 100644 js/packages/truapi-debugger/src/observed-frame.ts create mode 100644 js/packages/truapi-debugger/src/operation-row.test.ts create mode 100644 js/packages/truapi-debugger/src/retry-storm.test.ts create mode 100644 js/packages/truapi-debugger/src/retry-storm.ts create mode 100644 js/packages/truapi-debugger/src/session.ts create mode 100644 js/packages/truapi-debugger/src/trace-render.test.ts create mode 100644 js/packages/truapi-debugger/src/trace-render.ts create mode 100644 js/packages/truapi-debugger/src/trace-styles.ts create mode 100644 js/packages/truapi-debugger/src/trace-view.test.ts create mode 100644 js/packages/truapi-debugger/src/trace-view.ts create mode 100644 js/packages/truapi-debugger/src/wire-debugger.test.ts create mode 100644 js/packages/truapi-debugger/src/wire-debugger.ts create mode 100644 js/packages/truapi-debugger/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2db2aa193..303bd27f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -319,6 +319,42 @@ jobs: - name: Test run: npm test --prefix js/packages/truapi-host + ts-debugger: + name: "@parity/truapi-debugger" + runs-on: ubuntu-latest + needs: codegen + env: + TRUAPI_REQUIRE_GENERATED: 1 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Download codegen output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: codegen-output + + - name: Install + run: npm ci --ignore-scripts + + - name: Build @parity/truapi (workspace dependency) + run: npm run build --prefix js/packages/truapi + + - name: Build + run: npm run build --prefix js/packages/truapi-debugger + + - name: Test + run: npm test --prefix js/packages/truapi-debugger + playground: name: Playground (build + lint + unit) runs-on: ubuntu-latest @@ -481,6 +517,7 @@ jobs: ios-swift, ts-client, ts-host, + ts-debugger, playground, explorer, e2e, @@ -500,6 +537,7 @@ jobs: "${{ needs.ios-swift.result }}" "${{ needs.ts-client.result }}" "${{ needs.ts-host.result }}" + "${{ needs.ts-debugger.result }}" "${{ needs.playground.result }}" "${{ needs.explorer.result }}" "${{ needs.e2e.result }}" diff --git a/CLAUDE.md b/CLAUDE.md index 3b4fc0c41..d272a4e32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,13 @@ js/packages/ `.` (shared host types), `/web` (iframe + Web Worker), `/worker-runtime` (Worker entry). WASM bundle (gitignored) under dist/wasm/web/, built via `make wasm` + truapi-debugger/ @parity/truapi-debugger (private, in-repo): the debugger. + Decodes + groups the wire frames the Rust host tap + (truapi-server's DebugSink) streams out. Holds the + trace + envelope-decode engines + a runnable WS server the host + dials into (`npm run serve`, :9231) with a minimal trace + view. @parity/truapi has no debug seam. Where the app + ultimately lives is still an open decision. js/container/ TS lockdown container for the iOS host web view; `npm run build` bundles it into ios/truapi-host/Sources/TrUAPIHost/Resources/ ios/truapi-provider/ TrUAPIProvider Swift package (chain transport over UniFFI); diff --git a/js/packages/truapi-debugger/.gitignore b/js/packages/truapi-debugger/.gitignore new file mode 100644 index 000000000..f4e2c6d6b --- /dev/null +++ b/js/packages/truapi-debugger/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/js/packages/truapi-debugger/README.md b/js/packages/truapi-debugger/README.md new file mode 100644 index 000000000..ade73a1c4 --- /dev/null +++ b/js/packages/truapi-debugger/README.md @@ -0,0 +1,95 @@ +# @parity/truapi-debugger + +The debugger-side consumer for TrUAPI wire frames. **Private, in-repo, not published.** + +The host taps every product↔host wire frame in its Rust core (`truapi-server`'s +`DebugSink`) and streams each one outward as a `{ channelId, dir, frame: bytes }` +envelope. This package is the other end: it decodes the wire *envelope* (the +`requestId` and frame id, via `decodeWireMessage`) and groups frames into +per-operation traces. The trace view stays payload-blind — it never decodes the +frame payload. Envelope decoding lives here, in the debugger, never in the host +core, which treats frames as opaque bytes. + +This keeps `@parity/truapi` (the product package) genuinely untouched: the tap is +in the Rust host, and the debugger's decode/trace logic lives here instead +of in the product transport. + +> **Scope note.** This package holds both the debugger *library* (the +> trace + envelope-decode engines + the ingest that turns a wire envelope into a +> decoded frame) and a minimal *runnable app* (`server.ts`: the WS server a host +> dials into, plus a tiny trace view). It lives in-repo because the debugger is +> coupled to the protocol this repo owns — it decodes wire frames with +> `@parity/truapi`, tracking the generated wire surface. *Where the app +> ultimately lives* (stays a truapi tool / +> own repo / a desktop app) is still an open decision for the host-protocol +> owner; in-repo now is the low-regret default and moving it later is cheap. See +> `docs/design/wire-observability-debug-host.md`. + +## What's here + +- **`createDebugSession()`** — the trace engine wired to the ingest. Feed it + envelopes with `handleEnvelope(...)`; read grouped traces from `traceEngine`. +- **`createDebugIngest(sink)`** — decodes a `DebugFrameEnvelope` into an + `ObservedFrame` and forwards it. The layer that turns raw wire bytes into + something the trace engine can group. +- **`createWireDebugger(...)`** — accumulates observed frames into per-`requestId` + traces (correlates with product-sdk telemetry spans on the same id). +- **`createFrameDecoder(...)`** — the level-2 value decoder (see below): a gated, + per-frame decode of a payload to a plain JS value, reusing `@parity/truapi`'s + generated `WIRE_DECODE_TABLE` behind a dev-only opt-in and a sensitive-method + denylist. +- **`startDebugServer(...)`** (`server.ts`) — the runnable app: a Bun WS+HTTP + server. A host dials the WS and sends one text message per frame, + `{ channelId, dir, frame }` with `frame` base64-encoded; `GET /traces` returns + the grouped traces (payload-blind), `GET /frame?id=&i=` is the per-frame + drill-down (see below), `GET /` serves the view. + +## Value decode (level 2 — dev-only, off by default) + +By default the debugger is **payload-blind**: it groups frames and shows byte +lengths, never their contents. A separate, opt-in **level-2** capability can +decode a single frame's payload to a plain JS value in the drill-down detail +path. Its contract: + +- **Off by default.** The server enables it only when + `TRUAPI_DEBUGGER_DECODE_VALUES` is truthy (`startDebugServer({ decodeValues })` + in code). With it off, every frame reports byte length only, and no bytes are + even retained. +- **Reuses the generated table.** Decoding is `WIRE_DECODE_TABLE[frameId]?.(bytes)` + from `@parity/truapi/wire-decode` — the same dev-only codecs the client uses. + The debugger writes none of its own. +- **Sensitive denylist.** The generated table decodes *every* frame, including + signing and login. The security of this feature is the denylist layered on + top: the generated `SENSITIVE_FRAME_IDS` set in `@parity/truapi/wire-table`, + emitted from every method marked `#[wire(..., sensitive)]` on the Rust trait — + so sensitivity is a property of the payload type, and a codegen rename cannot + silently drop a family. It covers **signing/\*** (create-transaction, sign-raw, + sign-payload, and their legacy variants), **\*create\*proof\*** (account + + statement-store, incl. authorized), **entropy/derive**, **SSO/login + + get-user-id**, **local-storage read/write** (`clear` carries only a key name, + so it stays decodable), **payment/top-up**, + **coin-payment create-cheque/deposit/listen-for-payment**, and + **statement-store subscribe/submit**. A sensitive frame is never decoded — it + reports its byte length labelled `redacted: sensitive method`, even with the + toggle on. A fail-closed content check (any secret-named field in a decoded + value) backs it up for any secret-bearing method that was never annotated. +- **Never over the wire, never in `/traces`.** The host still emits opaque bytes + only; nothing about decode changes what it sends. `/traces` never serializes + raw bytes or decoded values. Decode happens only in the debugger, only in the + `/frame` drill-down. + +## Run + +```bash +npm install # links @parity/truapi via the workspace +npm run build # tsc -b +npm run serve # bun run src/server.ts — listens on :9231 + +# opt into level-2 value decode (dev machines only) +TRUAPI_DEBUGGER_DECODE_VALUES=1 npm run serve +``` + +Point a host's debugger URL at `ws://:9231` (the host dials out), +open `http://localhost:9231/` for the trace view; click a frame for its +drill-down detail. The exact host↔debugger framing is provisional (envelope +spec, track T3); base64-in-JSON is what the server accepts today. diff --git a/js/packages/truapi-debugger/package.json b/js/packages/truapi-debugger/package.json new file mode 100644 index 000000000..f6e800693 --- /dev/null +++ b/js/packages/truapi-debugger/package.json @@ -0,0 +1,26 @@ +{ + "name": "@parity/truapi-debugger", + "version": "0.0.0", + "private": true, + "description": "In-repo debugger consumer for TrUAPI wire frames: decodes and groups the frames the truapi-server host tap streams out", + "license": "MIT", + "author": "Parity Technologies ", + "type": "module", + "sideEffects": false, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -b", + "typecheck": "tsc -b", + "serve": "bun run src/server.ts", + "view": "bun run src/cli.ts", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "typescript": "^6.0" + }, + "dependencies": { + "@parity/truapi": "file:../truapi" + } +} diff --git a/js/packages/truapi-debugger/src/decode.test.ts b/js/packages/truapi-debugger/src/decode.test.ts new file mode 100644 index 000000000..3c7d78c38 --- /dev/null +++ b/js/packages/truapi-debugger/src/decode.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, test } from "bun:test"; + +import * as W from "@parity/truapi/wire-table"; +import { WIRE_DECODE_TABLE } from "@parity/truapi/wire-decode"; + +import { + createFrameDecoder, + SENSITIVE_FRAME_IDS, + type FrameValueDetail, +} from "./decode.js"; +import type { ObservedFrame } from "./observed-frame.js"; + +/** A minimal observed frame for a given id/bytes; the fields decode ignores are stubbed. */ +function frame(frameId: number, bytes?: Uint8Array): ObservedFrame { + return { + channelId: "myapp.dot", + direction: "out", + requestId: "p:1", + frameId, + role: "unknown", + byteLength: bytes?.length ?? 0, + timestamp: 0, + ...(bytes ? { bytes } : {}), + }; +} + +describe("sensitive denylist from the generated wire-table", () => { + // Authoritative denylist: the generated SENSITIVE_FRAME_IDS set, emitted by + // truapi-codegen from every `#[wire(..., sensitive)]` method on the Rust trait. + const sensitive = SENSITIVE_FRAME_IDS; + + test("re-exports the generated SENSITIVE_FRAME_IDS set verbatim", () => { + expect(sensitive).toBe(W.SENSITIVE_FRAME_IDS); + }); + + // Every id of each sensitive family must be present (both request/response, + // both start/receive), so neither leg of a sensitive op can be decoded. + const mustExclude: Record> = { + "signing/create-transaction": Object.values(W.SIGNING_CREATE_TRANSACTION), + "signing/sign-raw": Object.values(W.SIGNING_SIGN_RAW), + "signing/sign-payload": Object.values(W.SIGNING_SIGN_PAYLOAD), + "signing/sign-raw-legacy": Object.values( + W.SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT, + ), + "account/create-proof": Object.values(W.ACCOUNT_CREATE_ACCOUNT_PROOF), + "statement-store/create-proof": Object.values(W.STATEMENT_STORE_CREATE_PROOF), + "statement-store/create-proof-authorized": Object.values( + W.STATEMENT_STORE_CREATE_PROOF_AUTHORIZED, + ), + "entropy/derive": Object.values(W.ENTROPY_DERIVE), + "account/request-login": Object.values(W.ACCOUNT_REQUEST_LOGIN), + "account/get-user-id": Object.values(W.ACCOUNT_GET_USER_ID), + "account/sign-vrf": Object.values(W.ACCOUNT_SIGN_VRF), + "local-storage/read": Object.values(W.LOCAL_STORAGE_READ), + "local-storage/write": Object.values(W.LOCAL_STORAGE_WRITE), + // Payment payloads carrying key material / bearer secrets (C1/M2). + "payment/top-up": Object.values(W.PAYMENT_TOP_UP), + "coin-payment/create-cheque": Object.values(W.COIN_PAYMENT_CREATE_CHEQUE), + "coin-payment/deposit": Object.values(W.COIN_PAYMENT_DEPOSIT), + "coin-payment/listen-for-payment": Object.values( + W.COIN_PAYMENT_LISTEN_FOR_PAYMENT, + ), + // Statement-store subscribe/submit carry SignedStatement.decryptionKey. + "statement-store/subscribe": Object.values(W.STATEMENT_STORE_SUBSCRIBE), + "statement-store/submit": Object.values(W.STATEMENT_STORE_SUBMIT), + }; + for (const [name, ids] of Object.entries(mustExclude)) { + test(`excludes ${name}`, () => { + for (const id of ids) expect(sensitive.has(id)).toBe(true); + }); + } + + // Non-sensitive families stay decodable: chain reads, account reads, payments. + // local-storage/clear is deliberately decodable — its request is just a key + // name and its response is empty, so unlike read/write it carries no secret. + const mustAllow: Record> = { + "local-storage/clear": Object.values(W.LOCAL_STORAGE_CLEAR), + "account/get-account": Object.values(W.ACCOUNT_GET_ACCOUNT), + "account/connection-status": Object.values( + W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE, + ), + "chain/call-head": Object.values(W.CHAIN_CALL_HEAD), + "chain/broadcast-transaction": Object.values(W.CHAIN_BROADCAST_TRANSACTION), + "payment/request": Object.values(W.PAYMENT_REQUEST), + }; + for (const [name, ids] of Object.entries(mustAllow)) { + test(`allows ${name}`, () => { + for (const id of ids) expect(sensitive.has(id)).toBe(false); + }); + } +}); + +describe("gated frame decoder (real table + denylist)", () => { + test("a signing frame does NOT decode even with the toggle on", () => { + const decoder = createFrameDecoder({ enabled: true }); + const detail = decoder.detail( + frame(W.SIGNING_SIGN_RAW.request, new Uint8Array([1, 2, 3, 4])), + ); + expect(detail.kind).toBe("redacted"); + if (detail.kind === "redacted") { + expect(detail.reason).toBe("sensitive method"); + expect(detail.byteLength).toBe(4); + } + }); + + test("every signing family id redacts, never decodes", () => { + const decoder = createFrameDecoder({ enabled: true }); + for (const id of [ + ...Object.values(W.SIGNING_CREATE_TRANSACTION), + ...Object.values(W.SIGNING_SIGN_PAYLOAD), + ...Object.values(W.ACCOUNT_CREATE_ACCOUNT_PROOF), + ...Object.values(W.ENTROPY_DERIVE), + ...Object.values(W.ACCOUNT_REQUEST_LOGIN), + ]) { + const detail = decoder.detail(frame(id, new Uint8Array([0, 0]))); + expect(detail.kind).toBe("redacted"); + } + }); + + test("payment.topUp redacts (never decodes a raw private key) with toggle on (C1)", () => { + const decoder = createFrameDecoder({ enabled: true }); + for (const id of Object.values(W.PAYMENT_TOP_UP)) { + expect(decoder.detail(frame(id, new Uint8Array([0, 0]))).kind).toBe( + "redacted", + ); + } + }); + + test("a non-sensitive frame decodes only with the toggle on", () => { + // `connection-status.subscribe` start payload is `V1(void)` = a single 0x00 + // index byte: a real, non-sensitive frame the generated table can decode. + const id = W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start; + const bytes = new Uint8Array([0]); + + const off = createFrameDecoder({ enabled: false }); + const offDetail = off.detail(frame(id, bytes)); + expect(offDetail.kind).toBe("bytes"); + if (offDetail.kind === "bytes") expect(offDetail.byteLength).toBe(1); + + const on = createFrameDecoder({ enabled: true }); + const onDetail = on.detail(frame(id, bytes)); + expect(onDetail.kind).toBe("decoded"); + // Sanity: the id really is in the generated decode table. + expect(typeof WIRE_DECODE_TABLE[id]).toBe("function"); + }); + + test("disabled decoder is bytes-only for every frame", () => { + const decoder = createFrameDecoder({ enabled: false }); + for (const id of [ + W.ACCOUNT_GET_ACCOUNT.request, + W.SIGNING_SIGN_RAW.request, + W.CHAIN_CALL_HEAD.request, + ]) { + expect(decoder.detail(frame(id, new Uint8Array([9]))).kind).toBe("bytes"); + } + }); +}); + +describe("gated frame decoder (injected table for gating isolation)", () => { + const table = { 999: (b: Uint8Array) => ({ ok: Array.from(b) }) }; + const sensitiveIds = new Set([7]); + + test("decodes a non-sensitive id when enabled and bytes present", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: table, + sensitiveIds, + }); + const detail = decoder.detail(frame(999, new Uint8Array([1, 2]))); + expect(detail).toEqual({ + kind: "decoded", + value: { ok: [1, 2] }, + } satisfies FrameValueDetail); + }); + + test("redacts a sensitive id before ever touching the table", () => { + let called = false; + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { 7: () => ((called = true), "leaked") }, + sensitiveIds, + }); + const detail = decoder.detail(frame(7, new Uint8Array([1, 2, 3]))); + expect(detail.kind).toBe("redacted"); + expect(called).toBe(false); + }); + + test("falls back to bytes when the frame retained no bytes", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: table, + sensitiveIds, + }); + expect(decoder.detail(frame(999)).kind).toBe("bytes"); + }); + + test("falls back to bytes when the codec throws", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { + 999: () => { + throw new Error("bad payload"); + }, + }, + sensitiveIds, + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe("bytes"); + }); + + test("content guard redacts a decoded value carrying a secret-named field", () => { + // A non-denylisted id whose decoded payload nonetheless carries key material + // (the C1/H1 class): the fail-closed content check must redact it. + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { + 999: () => ({ source: { PrivateKey: { sr25519SecretKey: "0xdead" } } }), + }, + sensitiveIds: new Set(), + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "redacted", + ); + }); + + test("content guard redacts encryptedSecrets (cheque bearer material)", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { 999: () => ({ cheque: { encryptedSecrets: "0xbeef" } }) }, + sensitiveIds: new Set(), + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "redacted", + ); + }); + + test("content guard redacts a decryptionKey (statement key material)", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { + 999: () => ({ statements: [{ decryptionKey: "0xc0ffee" }] }), + }, + sensitiveIds: new Set(), + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "redacted", + ); + }); + + test("content guard redacts a generically-named credential field", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { 999: () => ({ auth: { sessionToken: "0xabc" } }) }, + sensitiveIds: new Set(), + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "redacted", + ); + }); + + test("content guard still decodes a public identifier (publicKey)", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { 999: () => ({ account: { publicKey: "0x01" } }) }, + sensitiveIds: new Set(), + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "decoded", + ); + }); + + test("content guard allows a benign value with no secret-named field", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { 999: () => ({ account: { address: "0x01" }, amount: 5 }) }, + sensitiveIds: new Set(), + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "decoded", + ); + }); + + test("content guard terminates on a cyclic / shared-DAG value (no blowup)", () => { + // The pre-visited-set guard hung on exactly this shape (a cycle with two + // back-edges + shared substructure). If it regresses to exponential, this + // test hangs instead of passing - which is the signal we want. + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { + 999: () => { + const a: Record = {}; + const b: Record = { a }; + a.b = b; + a.self = a; + return { a, b, both: [a, b, a, b] }; + }, + }, + sensitiveIds: new Set(), + }); + // Benign field names ⇒ decodes (and, crucially, returns promptly). + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "decoded", + ); + }); + + test("content guard still redacts a secret nested inside a cyclic value", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { + 999: () => { + const a: Record = { secretKey: "0xdead" }; + const b: Record = { a }; + a.b = b; + return { a, b }; + }, + }, + sensitiveIds: new Set(), + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( + "redacted", + ); + }); +}); + +describe("sensitive reveal escape hatch (dev-only, safe by default)", () => { + const table = { 7: (b: Uint8Array) => ({ secretKey: Array.from(b) }) }; + const sensitiveIds = new Set([7]); + + test("with reveal capability OFF, an explicit reveal request is ignored", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: table, + sensitiveIds, + // revealSensitive omitted → off + }); + const detail = decoder.detail(frame(7, new Uint8Array([1, 2])), { + reveal: true, + }); + expect(detail.kind).toBe("redacted"); + }); + + test("with reveal capability ON but no explicit request, sensitive still redacts", () => { + const decoder = createFrameDecoder({ + enabled: true, + revealSensitive: true, + decodeTable: table, + sensitiveIds, + }); + // Default call (no reveal) — the safe default must still hold. + expect(decoder.detail(frame(7, new Uint8Array([1, 2]))).kind).toBe( + "redacted", + ); + }); + + test("with reveal capability ON and an explicit request, a sensitive frame decodes and is marked", () => { + const decoder = createFrameDecoder({ + enabled: true, + revealSensitive: true, + decodeTable: table, + sensitiveIds, + }); + const detail = decoder.detail(frame(7, new Uint8Array([1, 2])), { + reveal: true, + }); + expect(detail).toEqual({ + kind: "decoded", + value: { secretKey: [1, 2] }, + sensitive: true, + } satisfies FrameValueDetail); + }); + + test("an explicit reveal also bypasses the content guard for a non-denylisted frame", () => { + const decoder = createFrameDecoder({ + enabled: true, + revealSensitive: true, + decodeTable: { 999: () => ({ auth: { sessionToken: "0xabc" } }) }, + sensitiveIds: new Set(), + }); + const detail = decoder.detail(frame(999, new Uint8Array([1])), { + reveal: true, + }); + expect(detail.kind).toBe("decoded"); + if (detail.kind === "decoded") expect(detail.sensitive).toBe(true); + }); + + test("the master gate still wins: reveal armed but decode disabled ⇒ bytes only", () => { + const decoder = createFrameDecoder({ + enabled: false, + revealSensitive: true, + decodeTable: table, + sensitiveIds, + }); + expect(decoder.detail(frame(7, new Uint8Array([1, 2])), { reveal: true }).kind).toBe( + "bytes", + ); + }); +}); diff --git a/js/packages/truapi-debugger/src/decode.ts b/js/packages/truapi-debugger/src/decode.ts new file mode 100644 index 000000000..0110493f9 --- /dev/null +++ b/js/packages/truapi-debugger/src/decode.ts @@ -0,0 +1,238 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Level-2 decode: turn a frame's raw SCALE payload into a plain JS value, in the + * drill-down detail path only, behind a dev-only opt-in. + * + * This is the one place the debugger looks *inside* a frame. Everything else - + * the trace engine, `/traces`, the host tap - is payload-blind and stays that + * way. The rules that make that safe live here: + * + * - **Off by default.** With the decoder disabled every frame reports its byte + * length and nothing else; no payload is ever inspected. + * - **Reuse, don't reinvent.** Decoding is `WIRE_DECODE_TABLE[frameId]?.(bytes)` + * from `@parity/truapi/wire-decode` - the same generated, dev-only codecs the + * client uses. The debugger writes no codecs of its own. + * - **Sensitive denylist.** The generated table decodes *every* frame, including + * signing and login. The security of this feature is the denylist layered on + * top: a sensitive frame is never decoded, even with the toggle on - it + * reports its byte length labelled `"sensitive method"`. The denylist is + * itself generated: `SENSITIVE_FRAME_IDS` in `@parity/truapi/wire-table` + * carries every frame id of a method marked `#[wire(..., sensitive)]` on the + * Rust trait, so sensitivity is a property of the payload type, not a name + * the debugger pattern-matches. + * + * Nothing here is ever serialized into `/traces`; the detail it produces is + * returned only from the explicit per-frame drill-down. + * + * @module + */ + +import { WIRE_DECODE_TABLE } from "@parity/truapi/wire-decode"; +import * as W from "@parity/truapi/wire-table"; +import type { ObservedFrame } from "./observed-frame.js"; + +/** + * Per-frame decode result for the drill-down detail path. + * + * `"bytes"` is the safe default returned whenever the decoder is off, the frame + * carries no retained bytes, the id has no codec, or decoding throws. + * `"redacted"` is returned for a sensitive frame even when the decoder is on. + * `"decoded"` carries the plain JS value and is reachable only with the decoder + * on, for a non-sensitive frame whose id is in the table. + */ +export type FrameValueDetail = + | { kind: "decoded"; value: unknown; sensitive?: boolean } + | { kind: "redacted"; reason: "sensitive method"; byteLength: number } + | { kind: "bytes"; byteLength: number }; + +/** + * The set of wire `frameId`s that must never be decoded, sourced directly from + * the generated {@link W.SENSITIVE_FRAME_IDS}. That set is emitted by + * `truapi-codegen` from every method marked `#[wire(..., sensitive)]` on the + * Rust trait and carries all of the method's frame ids (request/response and + * start/stop/interrupt/receive), so both legs of a sensitive op are redacted. + * + * Sensitivity therefore lives on the Rust payload type, not on a name the + * debugger pattern-matches: a codegen rename cannot silently drop a family, and + * a newly annotated method is denylisted the moment the client is regenerated. + * The families it covers today: + * + * - signing — every method (create-transaction(+legacy), sign-raw(+legacy), + * sign-payload(+legacy)): payloads to be signed and the resulting signatures. + * - account/statement-store proof creation: cryptographic proofs bound to a + * key/identity. + * - entropy/derive: key-derivation material. + * - account request-login / get-user-id: SSO/login and the user id it resolves. + * - local-storage read/write: a read response or a write request can carry + * tokens, session state, or PII. (`clear` carries only a key name and an + * empty response, so it is intentionally *not* sensitive.) + * - payment top-up: can carry a raw sr25519 secret key (PaymentTopUpSource). + * - coin-payment create-cheque/deposit/listen-for-payment: redeemable + * `encryptedSecrets` on a CoinPaymentCheque. + * - statement-store subscribe/submit: a SignedStatement's `decryptionKey`. + * + * Deliberately decodable, because they hold no key material: chain calls + * (`CHAIN_*`) carry public on-chain data — headers, bodies, storage, runtime + * calls, and the broadcast of already-public signed transactions — and are the + * primary useful decode surface; chat, notifications, permissions, theme, + * resource-allocation, and preimage likewise carry no credentials. + * + * Because sensitivity is a property of the payload *type*, the decoder also + * applies a fail-closed content check (see {@link createFrameDecoder}) that + * redacts any decoded value carrying a secret-named field — so a secret-bearing + * method that was never annotated is still caught. + */ +export const SENSITIVE_FRAME_IDS: ReadonlySet = W.SENSITIVE_FRAME_IDS; + +/** + * Field-name pattern for the fail-closed content check: keys whose name implies + * key material or a bearer secret (`sr25519SecretKey`, `encryptedSecrets`, + * `decryptionKey`, a mnemonic, a token/credential/passphrase, …). Deliberately + * omits a bare `key` so public identifiers like `publicKey` still decode. This + * is only a backstop — the authoritative guarantee is the generated + * {@link SENSITIVE_FRAME_IDS} denylist (type-driven via `#[wire(sensitive)]`); + * the content check catches any secret-bearing method that was never annotated. + */ +const SECRET_FIELD_RE = + /secret|mnemonic|entropy|private|decrypt|token|credential|passphrase|password|apikey|bearer|seed/i; + +/** + * Does a decoded value carry a secret-named field anywhere in its structure? + * + * Sensitivity ultimately lives in the payload type, so this backs up + * {@link SENSITIVE_FRAME_IDS}: a decoded value with a secret-named key is + * redacted even if its method was not on the denylist. The `seen` set makes it + * O(nodes) - each object is visited once - so it terminates in linear time on + * cycles and shared-substructure DAGs, not just trees. Safe on arrays, tagged + * unions, and nested structs. + */ +function containsSecretField( + value: unknown, + seen: WeakSet = new WeakSet(), + depth = 0, +): boolean { + // Depth cap is generous headroom; the `seen` set is what bounds work, by + // never revisiting an object even when the graph re-references it. + if (depth > 64 || value === null || typeof value !== "object") return false; + if (seen.has(value)) return false; + seen.add(value); + for (const [key, nested] of Object.entries(value as Record)) { + if (SECRET_FIELD_RE.test(key)) return true; + if (containsSecretField(nested, seen, depth + 1)) return true; + } + return false; +} + +/** Options for {@link createFrameDecoder}. */ +export interface FrameDecoderOptions { + /** + * Master gate. `false` (the default) means the decoder never inspects a + * payload: every frame reports bytes only. This is the dev-only opt-in. + */ + enabled?: boolean; + /** + * Frame-id → decoder map. Defaults to the generated + * {@link WIRE_DECODE_TABLE}; overridable for tests. + */ + decodeTable?: Record unknown>; + /** + * Frame ids that must never be decoded. Defaults to the generated + * {@link SENSITIVE_FRAME_IDS} denylist. + */ + sensitiveIds?: ReadonlySet; + /** + * Second, independent gate that *allows* a sensitive frame to be decoded - but + * only on an explicit per-frame `reveal` request (see {@link FrameDecoder.detail}), + * never by default. Off by default and only meaningful when {@link enabled} is + * also on. This is the dev-only "reveal sensitive" escape hatch: it is wired + * from its own env gate (`TRUAPI_DEBUGGER_REVEAL_SENSITIVE`) so it is + * structurally impossible to turn on in a shipped build, and even with it on + * the safe default (redact) still holds until the operator confirms a reveal. + */ + revealSensitive?: boolean; +} + +/** Options for a single {@link FrameDecoder.detail} call. */ +export interface FrameDetailOptions { + /** + * Explicit operator request to reveal a sensitive frame's value. Honored only + * when the decoder was built with {@link FrameDecoderOptions.revealSensitive} + * (and {@link FrameDecoderOptions.enabled}); otherwise ignored and the frame + * redacts as usual. A reveal bypasses both the denylist and the content guard + * for that one frame - it is the "show me everything" dev path. + */ + reveal?: boolean; +} + +/** A gated per-frame value decoder for the drill-down detail path. */ +export interface FrameDecoder { + /** Whether decoding is on. `false` ⇒ every `detail` is bytes-only. */ + readonly enabled: boolean; + /** Whether the sensitive-reveal escape hatch is armed (still off by default per call). */ + readonly revealSensitive: boolean; + /** The sensitive-frame denylist in effect (redacted unless explicitly revealed). */ + readonly sensitiveIds: ReadonlySet; + /** Resolve one frame to its {@link FrameValueDetail}. */ + detail(frame: ObservedFrame, options?: FrameDetailOptions): FrameValueDetail; +} + +/** + * Build a {@link FrameDecoder}. Off by default: pass `enabled: true` to opt in. + * Even then, sensitive frames (see {@link SENSITIVE_FRAME_IDS}) are reported + * as `"redacted"`, never decoded. + */ +export function createFrameDecoder( + options: FrameDecoderOptions = {}, +): FrameDecoder { + const enabled = options.enabled ?? false; + const revealSensitive = options.revealSensitive ?? false; + const decodeTable = options.decodeTable ?? WIRE_DECODE_TABLE; + const sensitiveIds = options.sensitiveIds ?? SENSITIVE_FRAME_IDS; + + const detail = ( + frame: ObservedFrame, + detailOptions: FrameDetailOptions = {}, + ): FrameValueDetail => { + if (!enabled) return { kind: "bytes", byteLength: frame.byteLength }; + // The reveal escape hatch fires only when the capability is armed AND the + // operator explicitly asked for this frame. Absent either, the safe default + // (redact sensitive / content-guard) stands - so the guarantee "sensitive + // never decodes" holds by default even in a reveal-armed session. + const reveal = revealSensitive && detailOptions.reveal === true; + if (sensitiveIds.has(frame.frameId) && !reveal) { + return { + kind: "redacted", + reason: "sensitive method", + byteLength: frame.byteLength, + }; + } + const decode = decodeTable[frame.frameId]; + if (!decode || !frame.bytes) { + return { kind: "bytes", byteLength: frame.byteLength }; + } + try { + const value = decode(frame.bytes); + // Fail-closed net: redact if the decoded payload carries a secret-named + // field, even though the method itself was not on the denylist - unless + // this is an explicit reveal, which is the "show me everything" path. + if (!reveal && containsSecretField(value)) { + return { + kind: "redacted", + reason: "sensitive method", + byteLength: frame.byteLength, + }; + } + // Mark a revealed value so the UI can style it as the danger it is. + return reveal + ? { kind: "decoded", value, sensitive: true } + : { kind: "decoded", value }; + } catch { + // A malformed or version-skewed payload must not break the drill-down; + // fall back to the byte-length view. + return { kind: "bytes", byteLength: frame.byteLength }; + } + }; + + return { enabled, revealSensitive, sensitiveIds, detail }; +} diff --git a/js/packages/truapi-debugger/src/index.ts b/js/packages/truapi-debugger/src/index.ts new file mode 100644 index 000000000..643651ed3 --- /dev/null +++ b/js/packages/truapi-debugger/src/index.ts @@ -0,0 +1,43 @@ +export type { + FrameDirection, + FrameRole, + ObservedFrame, + TransportObserver, +} from "./observed-frame.js"; +export { createDebugIngest } from "./ingest.js"; +export type { DebugFrameEnvelope, DebugIngestOptions } from "./ingest.js"; +export { createDebugSession } from "./session.js"; +export type { DebugSession, DebugSessionOptions } from "./session.js"; +export { createFrameDecoder, SENSITIVE_FRAME_IDS } from "./decode.js"; +export type { + FrameDecoder, + FrameDecoderOptions, + FrameValueDetail, +} from "./decode.js"; +export { createWireDebugger, createMethodNameMap } from "./wire-debugger.js"; +export type { + WireDebugger, + WireDebuggerOptions, + WireDebugSink, + WireFrameKind, + WireMethodInfo, + WireTrace, +} from "./wire-debugger.js"; +export { buildTraceView, wireTraceToView } from "./trace-view.js"; +export type { + TraceBadge, + TraceFrameBadge, + TraceFrameInput, + TraceFrameView, + TraceView, + TraceViewInput, +} from "./trace-view.js"; +export { + renderTraceDetail, + renderFrameValueDetail, + renderOperationRow, +} from "./trace-render.js"; +export type { RenderTraceDetailOptions } from "./trace-render.js"; +export { detectRetryStorms } from "./retry-storm.js"; +export type { RetryStormOptions } from "./retry-storm.js"; +export { TRACE_DETAIL_CSS } from "./trace-styles.js"; diff --git a/js/packages/truapi-debugger/src/ingest.ts b/js/packages/truapi-debugger/src/ingest.ts new file mode 100644 index 000000000..220ac61f0 --- /dev/null +++ b/js/packages/truapi-debugger/src/ingest.ts @@ -0,0 +1,93 @@ +/** + * Ingest: turn the host tap's wire envelopes into {@link ObservedFrame}s. + * + * The Rust host tap (`truapi-server`'s `DebugSink`) emits one envelope per + * frame - `{ channelId, dir, frame: bytes }`, raw SCALE, opaque to the core. + * The debugger decodes here: {@link decodeWireMessage} recovers the correlation + * `requestId` and the wire discriminant, which is everything the trace engine + * needs to group an op. This is the layer PG's design puts "in the debugger, not + * the core". + * + * @module + */ + +import { decodeWireMessage } from "@parity/truapi"; +import type { ObservedFrame, TransportObserver } from "./observed-frame.js"; + +/** + * One wire frame as it crosses the host tap, matching the Rust + * `DebugEvent::Frame { channel_id, dir, bytes }`. `frame` is the untouched + * `ProtocolMessage` bytes; the debugger owns all decoding. + */ +export interface DebugFrameEnvelope { + /** Product channel the frame belongs to, e.g. `"myapp.dot"`. */ + channelId: string; + /** + * Product-vantage: `out` left the product, `in` arrived at it. The Rust host + * tap names directions host-vantage internally and flips to this convention + * on the wire (`FrameDirection::wire_str`), so both ends agree here. + */ + dir: "in" | "out"; + /** Raw SCALE `ProtocolMessage` bytes. */ + frame: Uint8Array; +} + +/** Options for {@link createDebugIngest}. */ +export interface DebugIngestOptions { + /** + * Retain each frame's raw SCALE bytes on the {@link ObservedFrame}. Off by + * default: byte length is always recorded, but the bytes themselves are the + * dev-only opt-in that level-2 decode needs. `/traces` never serializes them + * either way; retaining them only makes the drill-down decoder able to run. + */ + retainBytes?: boolean; +} + +/** + * Ingest that decodes each {@link DebugFrameEnvelope} and forwards the resulting + * {@link ObservedFrame} to `sink` (typically a {@link WireDebugger}'s `observe`). + * + * `role` is left `"unknown"`: lifecycle roles (request/response/receive/…) are + * derived from request/subscription correlation state, which lived in the client + * transport and is not carried on the wire. Reconstructing it from the observed + * request/response ordering is a follow-up; grouping by `requestId` does not need + * it. An undecodable frame is surfaced as a `"malformed"` sentinel rather than + * dropped, so the trace records the failure instead of going dark. + * + * Raw payload bytes are attached only when `retainBytes` is set - the dev-only + * byte-exposure opt-in that the level-2 decoder consumes; otherwise a frame + * carries its byte length and no payload. + */ +export function createDebugIngest( + sink: TransportObserver, + options: DebugIngestOptions = {}, +): (envelope: DebugFrameEnvelope) => void { + const retainBytes = options.retainBytes ?? false; + return (envelope) => { + const decoded = decodeWireMessage(envelope.frame); + if (decoded.isErr()) { + sink({ + channelId: envelope.channelId, + direction: envelope.dir, + requestId: "malformed", + frameId: -1, + role: "malformed", + byteLength: envelope.frame.length, + timestamp: Date.now(), + }); + return; + } + const { requestId, payload } = decoded.value; + const frame: ObservedFrame = { + channelId: envelope.channelId, + direction: envelope.dir, + requestId, + frameId: payload.id, + role: "unknown", + byteLength: payload.value.length, + timestamp: Date.now(), + ...(retainBytes ? { bytes: payload.value } : {}), + }; + sink(frame); + }; +} diff --git a/js/packages/truapi-debugger/src/observed-frame.ts b/js/packages/truapi-debugger/src/observed-frame.ts new file mode 100644 index 000000000..cea816c21 --- /dev/null +++ b/js/packages/truapi-debugger/src/observed-frame.ts @@ -0,0 +1,68 @@ +/** + * The frame model the debugger works in. + * + * A host tap streams raw wire frames as `{ channelId, dir, frame: bytes }` + * envelopes; {@link createDebugIngest} decodes each one into an + * {@link ObservedFrame} - correlation id, wire discriminant, byte length, and + * (dev-only) the raw bytes - which the trace and host engines consume. The core + * never decodes; decoding happens here, in the debugger. + * + * @module + */ + +/** + * Direction of an observed wire frame relative to the product: `out` left the + * product, `in` arrived at it. + */ +export type FrameDirection = "out" | "in"; + +/** + * Role of an observed frame within the request/subscription lifecycle, derived + * from its wire discriminant against the method's frame ids. + */ +export type FrameRole = + | "request" + | "response" + | "start" + | "stop" + | "receive" + | "interrupt" + | "handshake" + | "malformed" + | "unknown"; + +/** + * A single decoded wire frame. Carries the correlation `requestId`, the wire + * discriminant, a best-effort lifecycle `role`, and the encoded byte length. + * The raw `bytes` are present only when byte exposure is enabled - a dev-only + * opt-in, since the raw wire can carry key material. + */ +export interface ObservedFrame { + /** + * Product channel the frame crossed, e.g. `"myapp.dot"`. Carried from the + * host tap envelope. Because `requestId` is minted per transport (each host + * mints `p:1`, `p:2`, …), it is unique only *within* a channel; grouping and + * lookups key on `(channelId, requestId)` so two hosts' ops never merge. + */ + channelId: string; + /** Whether the frame was sent by the product (`out`) or received by it (`in`). */ + direction: FrameDirection; + /** Correlation id shared by every frame of one request/subscription, within a channel. */ + requestId: string; + /** Wire-table numeric discriminant of the frame's payload. */ + frameId: number; + /** Best-effort lifecycle role inferred from the frame id. */ + role: FrameRole; + /** Encoded SCALE payload length in bytes. */ + byteLength: number; + /** Epoch ms at which the frame was observed. */ + timestamp: number; + /** The raw SCALE payload bytes, present only when byte exposure is enabled. */ + bytes?: Uint8Array; +} + +/** + * Emit-only consumer of observed frames. The trace engine's + * {@link WireDebugger.observe} is one; a host relay is another. + */ +export type TransportObserver = (frame: ObservedFrame) => void; diff --git a/js/packages/truapi-debugger/src/operation-row.test.ts b/js/packages/truapi-debugger/src/operation-row.test.ts new file mode 100644 index 000000000..75b7bf66b --- /dev/null +++ b/js/packages/truapi-debugger/src/operation-row.test.ts @@ -0,0 +1,129 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT + +import { describe, expect, test } from "bun:test"; +import type { ObservedFrame, FrameRole } from "./observed-frame.js"; +import type { WireMethodInfo, WireTrace } from "./wire-debugger.js"; +import { wireTraceToView } from "./trace-view.js"; +import { renderOperationRow } from "./trace-render.js"; + +function frame( + role: FrameRole, + frameId: number, + timestamp: number, +): ObservedFrame { + return { + direction: role === "response" || role === "receive" ? "in" : "out", + requestId: "p:1", + frameId, + role, + byteLength: 8, + timestamp, + }; +} + +function traceOf(frames: ObservedFrame[]): WireTrace { + return { + channelId: "host-a.dot", + requestId: "p:1", + frames, + startedAt: frames[0]?.timestamp ?? 0, + lastAt: frames[frames.length - 1]?.timestamp ?? 0, + }; +} + +const methodNames: ReadonlyMap = new Map([ + [22, { method: "account.getAccount", kind: "request" }], + [23, { method: "account.getAccount", kind: "response" }], + [40, { method: "account.connectionStatus", kind: "start" }], + [41, { method: "account.connectionStatus", kind: "receive" }], + [42, { method: "account.connectionStatus", kind: "stop" }], +]); + +describe("renderOperationRow", () => { + test("request/response op: method, frame count, duration, request glyph", () => { + const view = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1120)]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).toContain("account.getAccount"); + expect(html).toContain("2 frames"); + expect(html).toContain("120ms"); + expect(html).toContain("td-op-req"); + expect(html).toContain('data-request-id="p:1"'); + expect(html).not.toContain("td-op-live"); + }); + + test("subscription with no stop is marked live", () => { + const view = wireTraceToView( + traceOf([ + frame("start", 40, 1000), + frame("receive", 41, 1100), + frame("receive", 41, 1200), + ]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).toContain("td-op-sub"); + expect(html).toContain("td-op-live"); + expect(html).toContain("live"); + }); + + test("subscription with a stop is not live", () => { + const view = wireTraceToView( + traceOf([ + frame("start", 40, 1000), + frame("receive", 41, 1100), + frame("stop", 42, 1300), + ]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).toContain("td-op-sub"); + expect(html).not.toContain("td-op-live"); + }); + + test("op badges render as chips (orphaned request)", () => { + const view = wireTraceToView(traceOf([frame("request", 22, 1000)]), methodNames); + const html = renderOperationRow(view); + expect(html).toContain("td-badge-orphaned"); + }); + + test("carries channelId as a data attribute when present", () => { + const base = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + const view = { ...base, channelId: "host-a.dot" }; + const html = renderOperationRow(view); + expect(html).toContain('data-channel-id="host-a.dot"'); + }); + + test("omits data-channel-id when the vantage has no channel", () => { + const base = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + const view = { ...base, channelId: undefined }; + expect(renderOperationRow(view)).not.toContain("data-channel-id"); + }); + + test("payload-blind: never emits a decoded value", () => { + const view = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).not.toContain("decode"); + expect(html).not.toContain(" { + const base = wireTraceToView(traceOf([frame("request", 22, 1000)])); + const view = { ...base, requestId: '">' }; + const html = renderOperationRow(view); + expect(html).not.toContain(", +): string[] { + return [...map.keys()].map((t) => t.requestId).sort(); +} + +describe("detectRetryStorms", () => { + test("flags a burst of like ops in a short window", () => { + const traces = [ + trace("a", 30, 0), + trace("b", 30, 200), + trace("c", 30, 400), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["a", "b", "c"]); + expect(storms.get(traces[0])).toEqual(["retry-storm"]); + }); + + test("does not flag a burst below the threshold", () => { + const storms = detectRetryStorms([trace("a", 30, 0), trace("b", 30, 100)]); + expect(storms.size).toBe(0); + }); + + test("does not flag like ops spread wider than the window", () => { + const storms = detectRetryStorms([ + trace("a", 30, 0), + trace("b", 30, 1500), + trace("c", 30, 3000), + ]); + expect(storms.size).toBe(0); + }); + + test("groups by op signature — only the bursting method storms", () => { + // Three createTransaction (id 30) inside 400ms = a storm; two getAccount + // (id 22) far apart are not, even interleaved in time. + const traces = [ + trace("sign-1", 30, 0), + trace("get-1", 22, 50), + trace("sign-2", 30, 150), + trace("get-2", 22, 5000), + trace("sign-3", 30, 300), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["sign-1", "sign-2", "sign-3"]); + }); + + test("flags only the dense sub-window within a longer sparse run", () => { + // Two early, far-apart ops then a tight burst of three: only the burst. + const traces = [ + trace("x", 30, 0), + trace("y", 30, 4000), + trace("b1", 30, 8000), + trace("b2", 30, 8300), + trace("b3", 30, 8600), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["b1", "b2", "b3"]); + }); + + test("honors custom window and burst thresholds", () => { + const traces = [trace("a", 30, 0), trace("b", 30, 300)]; + // Default (minBurst 3) → nothing; minBurst 2 within 500ms → both. + expect(detectRetryStorms(traces).size).toBe(0); + const storms = detectRetryStorms(traces, { windowMs: 500, minBurst: 2 }); + expect(stormedIds(storms)).toEqual(["a", "b"]); + }); + + test("minBurst below 2 detects nothing", () => { + const traces = [trace("a", 30, 0), trace("b", 30, 10)]; + expect(detectRetryStorms(traces, { minBurst: 1 }).size).toBe(0); + }); + + test("tolerates a frameless trace without throwing", () => { + const empty: WireTrace = { + channelId: "c", + requestId: "empty", + frames: [], + startedAt: 0, + lastAt: 0, + }; + const traces = [ + empty, + trace("a", 30, 0), + trace("b", 30, 100), + trace("c", 30, 200), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["a", "b", "c"]); + expect(storms.has(empty)).toBe(false); + }); + + test("is per-channel — two hosts each firing once is not a storm", () => { + // Same requestId and frameId across two channels, all within the window, + // but each channel fires the op only twice (< minBurst 3): no storm, and + // the two channels are never merged into one burst. + const traces = [ + trace("p:1", 30, 0, "hostA"), + trace("p:1", 30, 50, "hostB"), + trace("p:2", 30, 100, "hostA"), + trace("p:2", 30, 150, "hostB"), + ]; + expect(detectRetryStorms(traces).size).toBe(0); + }); + + test("flags a per-channel burst without pulling in the other channel", () => { + // hostA hammers the op 3x in-window (storm); hostB fires it once (calm). + const traces = [ + trace("p:1", 30, 0, "hostA"), + trace("p:2", 30, 200, "hostA"), + trace("p:1", 30, 250, "hostB"), + trace("p:3", 30, 400, "hostA"), + ]; + const storms = detectRetryStorms(traces); + // Only hostA's three ops storm; hostB's p:1 does not, even though it shares + // requestId "p:1" with a stormed hostA op. + expect(storms.size).toBe(3); + const stormedChannels = new Set([...storms.keys()].map((t) => t.channelId)); + expect([...stormedChannels]).toEqual(["hostA"]); + }); +}); diff --git a/js/packages/truapi-debugger/src/retry-storm.ts b/js/packages/truapi-debugger/src/retry-storm.ts new file mode 100644 index 000000000..ca7e64893 --- /dev/null +++ b/js/packages/truapi-debugger/src/retry-storm.ts @@ -0,0 +1,94 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Retry-storm detection: a *cross-op* signal the single-trace renderer cannot + * see on its own. + * + * A retry storm is a burst of like ops in a short window — a product hammering + * `signing.createTransaction` five times in 400ms because each attempt failed, + * say. Whether any one op is part of a storm depends on the *other* traces, so + * it belongs in the engine/list layer, not the per-trace renderer. This module + * computes it over the whole trace set and hands each stormed trace a + * `retry-storm` {@link TraceBadge}, which the mount feeds to `wireTraceToView`'s + * `extraBadges`. The renderer stays display-only. + * + * @module + */ + +import type { TraceBadge } from "./trace-view.js"; +import type { WireTrace } from "./wire-debugger.js"; + +/** Tuning for {@link detectRetryStorms}. */ +export interface RetryStormOptions { + /** + * The window, in ms, within which like ops count as one burst. Default 1000. + */ + windowMs?: number; + /** + * How many like ops within `windowMs` make a storm. Default 3. Values below 2 + * are meaningless (a single op is never a storm) and detect nothing. + */ + minBurst?: number; +} + +/** + * The op signature two traces must share to count as "like". A storm is one host + * hammering one method, so the signature is scoped to the channel: `channelId` + * plus the opener frame's wire `frameId` (the first frame is the `request`/`start`, + * so its id identifies the method). Same channel + same op id = the same op being + * repeated; two different hosts each firing the op once is not a storm. A trace + * with no frames has no signature and never storms. + */ +function signature(trace: WireTrace): string | undefined { + const frameId = trace.frames[0]?.frameId; + return frameId === undefined ? undefined : `${trace.channelId}\u0000${frameId}`; +} + +/** + * Find every trace that is part of a retry storm and map it to its badge. + * + * Traces are grouped by op {@link signature}; within each group, a sliding + * window over `startedAt` flags any trace that sits in a span of `minBurst` or + * more ops no wider than `windowMs`. The result is keyed by the {@link WireTrace} + * object itself (not `requestId`, which is not unique across channels): only + * stormed traces appear, each mapped to `["retry-storm"]`. Feed + * `result.get(trace) ?? []` into `wireTraceToView`'s `extraBadges`. + */ +export function detectRetryStorms( + traces: readonly WireTrace[], + options: RetryStormOptions = {}, +): ReadonlyMap { + const windowMs = options.windowMs ?? 1000; + const minBurst = options.minBurst ?? 3; + const result = new Map(); + if (minBurst < 2) return result; + + const groups = new Map(); + for (const trace of traces) { + const sig = signature(trace); + if (sig === undefined) continue; + const group = groups.get(sig); + if (group) group.push(trace); + else groups.set(sig, [trace]); + } + + for (const group of groups.values()) { + if (group.length < minBurst) continue; + const sorted = [...group].sort((a, b) => a.startedAt - b.startedAt); + let left = 0; + for (let right = 0; right < sorted.length; right++) { + while (sorted[right].startedAt - sorted[left].startedAt > windowMs) { + left++; + } + // [left, right] now spans <= windowMs, so every trace in it is within + // windowMs of every other. If that's a full burst, they all storm. + if (right - left + 1 >= minBurst) { + for (let k = left; k <= right; k++) { + result.set(sorted[k], ["retry-storm"]); + } + } + } + } + + return result; +} diff --git a/js/packages/truapi-debugger/src/session.ts b/js/packages/truapi-debugger/src/session.ts new file mode 100644 index 000000000..466c409ce --- /dev/null +++ b/js/packages/truapi-debugger/src/session.ts @@ -0,0 +1,142 @@ +/** + * A debug session: the trace engine wired to the ingest. + * + * A host dials the debugger and streams {@link DebugFrameEnvelope}s over a + * socket; each is handed to {@link DebugSession.handleEnvelope}, decoded, and + * grouped into per-`requestId` traces readable via {@link DebugSession.traces}. + * + * The socket itself is deliberately not here. The debugger app is a WS server + * (hosts dial outward to it), but binding the socket is a thin edge: accept a + * connection, JSON/CBOR-decode each message into a {@link DebugFrameEnvelope}, + * and call `handleEnvelope`. Keeping that edge out of this module lets the + * session compile and unit-test without a socket transport or Node types. + * + * @module + */ + +import { + createWireDebugger, + createMethodNameMap, + type WireDebugger, + type WireMethodInfo, +} from "./wire-debugger.js"; +import { createDebugIngest, type DebugFrameEnvelope } from "./ingest.js"; +import { createFrameDecoder, type FrameValueDetail } from "./decode.js"; +import * as W from "@parity/truapi/wire-table"; +import { createClient, createTransport } from "@parity/truapi"; + +/** A provider that sends and receives nothing; used only to enumerate service names. */ +const NOOP_PROVIDER = { + postMessage() {}, + subscribe() { + return () => {}; + }, + dispose() {}, +}; + +/** Options for {@link createDebugSession}. */ +export interface DebugSessionOptions { + /** + * Turn on level-2 value decode in the drill-down detail path. Off by default. + * When on, the session retains raw frame bytes so {@link DebugSession.frameDetail} + * can decode non-sensitive frames; `/traces` stays payload-blind regardless + * (it never reads bytes or decoded values), and sensitive frames are never + * decoded even here. When off, `frameDetail` reports byte length only. + */ + decodeValues?: boolean; + /** + * Arm the dev-only sensitive-reveal escape hatch. Off by default and only + * meaningful when {@link decodeValues} is also on. Even armed, a sensitive + * frame still redacts unless {@link DebugSession.frameDetail} is called with an + * explicit `reveal` (the operator confirms per frame). Wired from + * `TRUAPI_DEBUGGER_REVEAL_SENSITIVE`, so it cannot be set in a shipped build. + */ + revealSensitive?: boolean; +} + +/** Live debug session: feed it envelopes, read back grouped traces. */ +export interface DebugSession { + /** Handle one wire envelope from the host tap. */ + handleEnvelope(envelope: DebugFrameEnvelope): void; + /** The underlying trace engine (traces, per-id lookup, clear). */ + readonly traceEngine: WireDebugger; + /** Reverse map from wire `frameId` to method, for labelling frames in a view. */ + readonly methodNames: ReadonlyMap; + /** Whether level-2 value decode is enabled for this session. */ + readonly decodeValues: boolean; + /** Whether the dev-only sensitive-reveal escape hatch is armed for this session. */ + readonly revealSensitive: boolean; + /** + * Frame ids that are never decoded (the sensitive denylist). Exposed so a view + * can mark a frame/op as carrying redacted material *before* any decode - the + * marker is payload-blind (it reveals nothing the method name doesn't) and + * holds regardless of {@link DebugSessionOptions.decodeValues}. + */ + readonly sensitiveIds: ReadonlySet; + /** + * Drill-down: resolve one frame (by its trace `requestId` and index within + * that trace) to a {@link FrameValueDetail}. Pass `channelId` to disambiguate + * when more than one host is connected (each mints the same `p:N` ids). + * Returns `undefined` if no such frame exists. This is the *only* path that can + * surface a decoded value, and only when {@link DebugSessionOptions.decodeValues} + * is on and the frame is not sensitive; otherwise it reports byte length only. + */ + frameDetail( + requestId: string, + index: number, + channelId?: string, + reveal?: boolean, + ): FrameValueDetail | undefined; +} + +/** + * Build a {@link DebugSession}. The `frameId → method` map is derived from the + * generated wire table and client service names, so traces show + * `account.getAccount` rather than a bare `id=22`. + */ +export function createDebugSession( + options: DebugSessionOptions = {}, +): DebugSession { + const decodeValues = options.decodeValues ?? false; + // Reveal is meaningless without decode; fold the master gate in so the + // reported capability can never claim more than the session can actually do. + const revealSensitive = decodeValues && (options.revealSensitive ?? false); + const serviceNames = Object.keys(createClient(createTransport(NOOP_PROVIDER))); + const methodNames = createMethodNameMap( + W as unknown as Record, + serviceNames, + ); + // No `sink`: a session accumulates traces for the view/`/traces`; it must not + // spam the server console with a line per frame (the sink default is + // `console.debug`). Consumers read `traceEngine`, not stdout. + const wireDebugger = createWireDebugger({ methodNames, sink: () => {} }); + // Raw bytes are retained only when decode is on - they exist solely to feed + // the drill-down decoder, and `/traces` never serializes them. + const handleEnvelope = createDebugIngest(wireDebugger.observe, { + retainBytes: decodeValues, + }); + const decoder = createFrameDecoder({ + enabled: decodeValues, + revealSensitive, + }); + + const frameDetail = ( + requestId: string, + index: number, + channelId?: string, + reveal?: boolean, + ): FrameValueDetail | undefined => { + const frame = wireDebugger.trace(requestId, channelId)?.frames[index]; + return frame ? decoder.detail(frame, { reveal }) : undefined; + }; + + return { + handleEnvelope, + traceEngine: wireDebugger, + methodNames, + decodeValues, + revealSensitive: decoder.revealSensitive, + sensitiveIds: decoder.sensitiveIds, + frameDetail, + }; +} diff --git a/js/packages/truapi-debugger/src/trace-render.test.ts b/js/packages/truapi-debugger/src/trace-render.test.ts new file mode 100644 index 000000000..28c1f829b --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-render.test.ts @@ -0,0 +1,106 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT + +import { describe, expect, test } from "bun:test"; +import type { FrameValueDetail } from "./decode.js"; +import type { TraceView } from "./trace-view.js"; +import { renderFrameValueDetail, renderTraceDetail } from "./trace-render.js"; + +const view: TraceView = { + requestId: "req-1", + startedAt: 1000, + lastAt: 1150, + durationMs: 150, + frames: [ + { + seq: 0, + direction: "out", + role: "request", + method: "account.getAccount", + frameId: 22, + byteLength: 8, + timestamp: 1000, + latencyFromStartMs: 0, + badges: [], + decodable: true, + }, + { + seq: 1, + direction: "in", + role: "response", + method: "account.getAccount", + frameId: 23, + byteLength: 40, + timestamp: 1150, + latencyFromStartMs: 150, + roundTripMs: 150, + badges: [], + decodable: true, + }, + ], + badges: [], +}; + +describe("renderTraceDetail", () => { + test("renders the frame sequence with method, bytes, and round-trip", () => { + const html = renderTraceDetail(view); + expect(html).toContain("account.getAccount"); + expect(html).toContain("40B"); + expect(html).toContain("150ms"); + expect(html).toContain('data-seq="1"'); + }); + + test("is payload-blind by default: no decode control", () => { + const html = renderTraceDetail(view); + expect(html).not.toContain("decode payload"); + }); + + test("offers a decode control per decodable frame when opted in", () => { + const html = renderTraceDetail(view, { offerDecode: true }); + expect(html).toContain("td-frame-decode-btn"); + expect(html).toContain("decode payload"); + }); + + test("renders a resolved decoded value in place of the control", () => { + const decoded = new Map([ + [1, { kind: "decoded", value: { free: 42 } }], + ]); + const html = renderTraceDetail(view, { offerDecode: true, decoded }); + expect(html).toContain(""free": 42"); + }); + + test("a sensitive frame renders a redacted state, never the value", () => { + const decoded = new Map([ + [0, { kind: "redacted", reason: "sensitive method", byteLength: 96 }], + ]); + const html = renderTraceDetail(view, { offerDecode: true, decoded }); + expect(html).toContain("redacted"); + expect(html).toContain("96B withheld"); + expect(html).not.toContain("free"); + }); + + test("escapes wire-sourced strings", () => { + const evil: TraceView = { + ...view, + requestId: '', + frames: [], + }; + const html = renderTraceDetail(evil); + expect(html).not.toContain(" { + const html = renderTraceDetail({ ...view, badges: ["orphaned", "retry-storm"] }); + expect(html).toContain("td-badge-orphaned"); + expect(html).toContain("retry storm"); + }); +}); + +describe("renderFrameValueDetail", () => { + test("bytes-only never shows a payload", () => { + const html = renderFrameValueDetail({ kind: "bytes", byteLength: 12 }); + expect(html).toContain("12B"); + expect(html).toContain("payload not shown"); + }); +}); diff --git a/js/packages/truapi-debugger/src/trace-render.ts b/js/packages/truapi-debugger/src/trace-render.ts new file mode 100644 index 000000000..78b02ab7f --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-render.ts @@ -0,0 +1,390 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * The one drill-down renderer, mounted in both the standalone app and dotli's + * panel. + * + * "One level deeper": given a selected op, render its frame sequence - + * request→response, or subscribe→receive×N→stop - with method, direction, byte + * length, latency, and orphaned/malformed/retry-storm badges. It is a pure + * `TraceView → HTML` function so the two mounts render identically; each mount + * supplies the {@link TraceView} through its own adapter (see {@link + * wireTraceToView} for the wire vantage). + * + * Payload-blind by default. Level-2 value decode is offered only when a mount + * opts in (`offerDecode`) and passes decode results back in (`decoded`); the + * renderer never touches bytes itself. Decode results come from the Core + + * Decode thread's {@link FrameValueDetail}, so a sensitive frame renders a + * redacted state and never its value. + * + * The renderer emits HTML strings (both mounts assign `innerHTML`) using `td-*` + * classes so one stylesheet covers both. Every interpolated string that came + * off the wire (`requestId`, `method`) is escaped. + * + * @module + */ + +import type { FrameValueDetail } from "./decode.js"; +import type { + TraceBadge, + TraceFrameBadge, + TraceFrameView, + TraceView, +} from "./trace-view.js"; + +/** Options controlling a single drill-down render. */ +export interface RenderTraceDetailOptions { + /** + * Offer the per-frame level-2 decode affordance for decodable frames. Off by + * default: the view stays payload-blind and shows no decode control. + */ + offerDecode?: boolean; + /** + * Decode results already resolved for this op, keyed by frame `seq`. The mount + * fills this after a user acts on a frame (calling the Core session's + * `frameDetail(requestId, seq)`) and re-renders. Frames absent from the map + * show only their decode control, never a value. + */ + decoded?: ReadonlyMap; + /** + * Offer the dev-only "reveal" affordance on *sensitive* frames (the escape + * hatch). Off by default: a sensitive frame then shows its redacted state + * upfront with no control. Only a mount whose session armed the reveal gate + * sets this; the reveal itself is still an explicit, confirmed per-frame action. + */ + offerReveal?: boolean; +} + +/** HTML-escape a wire-sourced string before it touches `innerHTML`. */ +function esc(value: string): string { + return value.replace(/[&<>"']/g, (c) => { + switch (c) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +/** `1234` → `1.23s`, `42` → `42ms`, for compact latency display. */ +function formatMs(ms: number): string { + if (ms < 1000) return `${String(Math.round(ms))}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +const DIRECTION_GLYPH: Record = { + out: "▶", + in: "◀", +}; + +/** + * Render the drill-down detail for one op. Returns an HTML fragment for a + * mount's detail pane (`.td-detail` in dotli, the detail column in the app). + */ +export function renderTraceDetail( + view: TraceView, + options: RenderTraceDetailOptions = {}, +): string { + const offerDecode = options.offerDecode ?? false; + const offerReveal = options.offerReveal ?? false; + const decoded = options.decoded; + + const header = renderHeader(view); + const rows = view.frames + .map((frame) => + renderFrameRow(frame, offerDecode, offerReveal, decoded?.get(frame.seq)), + ) + .join(""); + + return ( + `
` + + header + + `
${rows}
` + + `
` + ); +} + +function renderHeader(view: TraceView): string { + const badges = view.badges.map(renderOpBadge).join(""); + const frameCount = view.frames.length; + return ( + `
` + + `${esc(view.requestId)}` + + `${String(frameCount)} frame${frameCount === 1 ? "" : "s"} · ${formatMs(view.durationMs)}` + + (badges === "" ? "" : `${badges}`) + + `
` + ); +} + +const OP_BADGE_LABEL: Record = { + orphaned: "orphaned", + malformed: "malformed", + "retry-storm": "retry storm", +}; + +function renderOpBadge(badge: TraceBadge): string { + return `${esc(OP_BADGE_LABEL[badge])}`; +} + +function badgeTitle(badge: TraceBadge): string { + switch (badge) { + case "orphaned": + return "An opening frame has no matching close, or a close has no opener"; + case "malformed": + return "A frame failed to decode on the wire"; + case "retry-storm": + return "This op is one of a burst of like ops in a short window"; + } +} + +const FRAME_BADGE_LABEL: Record = { + malformed: "malformed", + orphaned: "orphaned", +}; + +function renderFrameRow( + frame: TraceFrameView, + offerDecode: boolean, + offerReveal: boolean, + detail: FrameValueDetail | undefined, +): string { + const glyph = DIRECTION_GLYPH[frame.direction]; + const method = + frame.method === undefined + ? `id ${String(frame.frameId ?? "?")}` + : `${esc(frame.method)}`; + const role = `${esc(frame.role)}`; + // Privacy marker, shown before any decode: this frame carries material the + // denylist keeps redacted. Reveals nothing the method name doesn't. + const lock = frame.sensitive + ? `🔒` + : ""; + const size = + frame.byteLength === undefined + ? "" + : `${String(frame.byteLength)}B`; + const latency = renderLatency(frame); + const badges = frame.badges + .map( + (b) => + `${esc(FRAME_BADGE_LABEL[b])}`, + ) + .join(""); + + // The frame's meta (direction, role, method, size, latency, badges) is one + // grouped cell so a mount can pin the level-2 payload into a fixed second + // column beside it - every frame's decoded box then opens in the same aligned + // space rather than trailing variable-width meta. + const meta = + `
` + + `${glyph}` + + role + + method + + lock + + size + + latency + + (badges === "" ? "" : `${badges}`) + + `
`; + + const payload = + offerDecode && frame.decodable + ? `
${renderDecodeBlock(frame, offerReveal, detail)}
` + : ""; + + return ( + `
` + + meta + + payload + + `
` + ); +} + +function renderLatency(frame: TraceFrameView): string { + // A closing frame that answers an opener shows its round-trip; everything + // else shows its offset from the op's first frame. + if (frame.roundTripMs !== undefined) { + return `⟳ ${formatMs(frame.roundTripMs)}`; + } + if (frame.latencyFromStartMs === 0) { + return `+0`; + } + return `+${formatMs(frame.latencyFromStartMs)}`; +} + +/** + * The level-2 slot for one frame: a decode control plus, once resolved, the + * decoded / redacted / bytes-only outcome. Rendered only when the mount offers + * decode and the frame retained bytes. + */ +function renderDecodeBlock( + frame: TraceFrameView, + offerReveal: boolean, + detail: FrameValueDetail | undefined, +): string { + if (detail !== undefined) { + return `
${renderFrameValueDetail(detail)}
`; + } + const size = + frame.byteLength === undefined ? "" : ` · ${String(frame.byteLength)}B`; + if (frame.sensitive) { + // A sensitive frame stays redacted by default - so show that upfront rather + // than a decode control that would only ever redact. When the dev reveal + // gate is armed, offer a distinct, explicit reveal control instead (guarded + // by a per-frame confirm on the client); it is NOT a `td-frame-decode-btn`, + // so "Decode all" never sweeps it in. + if (offerReveal) { + return ( + `` + ); + } + return `
${renderFrameValueDetail({ + kind: "redacted", + reason: "sensitive method", + byteLength: frame.byteLength ?? 0, + })}
`; + } + // Non-sensitive pre-decode state: a blurred placeholder standing in for the + // encoded payload. It carries NO real bytes - the renderer is payload-blind + // and never sees them, so the blocks are decorative, sized only by byte + // length. The button is the decode trigger; the value is fetched on demand. + return ( + `` + ); +} + +/** + * A capped run of block glyphs for the pre-decode blur: it conveys "an encoded + * payload lives here" and roughly how large, without ever carrying the real + * bytes. Purely decorative (aria-hidden); the byte length is the only input. + */ +function encodedGlyphs(byteLength: number | undefined): string { + const n = + byteLength === undefined + ? 10 + : Math.max(8, Math.min(40, Math.ceil(byteLength / 2))); + return "▓".repeat(n); +} + +/** + * Render a Core-thread {@link FrameValueDetail}. Shared by both mounts so the + * redacted state is identical everywhere: a sensitive frame shows a clear + * "redacted" label and its byte length, never its value. + */ +export function renderFrameValueDetail(detail: FrameValueDetail): string { + switch (detail.kind) { + case "redacted": + return ( + `
` + + `redacted ` + + `${esc(detail.reason)} · ${String(detail.byteLength)}B withheld` + + `
` + ); + case "bytes": + return `
${String(detail.byteLength)}B · payload not shown
`; + case "decoded": + // A revealed sensitive value is flagged so the mount can style it as the + // danger it is (dev-only escape hatch); an ordinary decode is plain. + return detail.sensitive === true + ? `
${esc(stringifyValue(detail.value))}
` + : `
${esc(stringifyValue(detail.value))}
`; + } +} + +/** Pretty-print a decoded value for a `
`, tolerating cyclic/bigint inputs. */
+function stringifyValue(value: unknown): string {
+  try {
+    return JSON.stringify(
+      value,
+      (_key, v: unknown) => (typeof v === "bigint" ? `${v.toString()}n` : v),
+      2,
+    );
+  } catch {
+    return String(value);
+  }
+}
+
+/** Roles that mark an op as a subscription rather than a request/response. */
+const SUBSCRIPTION_ROLES: ReadonlySet = new Set([
+  "start",
+  "receive",
+  "stop",
+  "interrupt",
+]);
+
+/** The op's method: the first opening frame's method, else the first known one. */
+function operationMethod(view: TraceView): string | undefined {
+  const opener = view.frames.find(
+    (f) => f.role === "request" || f.role === "start",
+  );
+  if (opener?.method !== undefined) {
+    return opener.method;
+  }
+  return view.frames.find((f) => f.method !== undefined)?.method;
+}
+
+/** Whether the op is a subscription (has a start/receive/stop/interrupt frame). */
+function isSubscription(view: TraceView): boolean {
+  return view.frames.some((f) => SUBSCRIPTION_ROLES.has(f.role));
+}
+
+/**
+ * Render one operation-list row: the primary view's unit, one per op. Shows the
+ * method, a request/subscription glyph, op-level badges, frame count, and
+ * duration. A subscription with no `stop` frame is marked live.
+ *
+ * Pure and stateless: the mount toggles `.selected` and manages the keyed diff.
+ * `data-request-id` (+ `data-channel-id` when known) identify the row for
+ * selection and channel filtering. Payload-blind: only shape and timing here.
+ */
+export function renderOperationRow(view: TraceView): string {
+  const method = operationMethod(view);
+  const sub = isSubscription(view);
+  const live = sub && !view.frames.some((f) => f.role === "stop");
+  const kindGlyph = sub ? "⟳" : "▶";
+  const kindClass = sub ? "td-op-sub" : "td-op-req";
+
+  const methodHtml =
+    method === undefined
+      ? `(unknown)`
+      : `${esc(method)}`;
+  const badges = view.badges.map(renderOpBadge).join("");
+  const count = view.frames.length;
+  const meta =
+    `${String(count)} frame${count === 1 ? "" : "s"} · ` +
+    (live ? `live · ${formatMs(view.durationMs)}` : formatMs(view.durationMs));
+
+  const channelAttr =
+    view.channelId === undefined
+      ? ""
+      : ` data-channel-id="${esc(view.channelId)}"`;
+  // Op-row privacy marker + a filterable attribute: this op touches a method
+  // whose payload stays redacted by default.
+  const sensitiveAttr = view.sensitive ? ` data-sensitive="1"` : "";
+  const lock = view.sensitive
+    ? ``
+    : "";
+
+  return (
+    `
` + + `` + + methodHtml + + lock + + (badges === "" ? "" : `${badges}`) + + `${meta}` + + `
` + ); +} diff --git a/js/packages/truapi-debugger/src/trace-styles.ts b/js/packages/truapi-debugger/src/trace-styles.ts new file mode 100644 index 000000000..44de74d6e --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-styles.ts @@ -0,0 +1,199 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Canonical styling for the shared drill-down renderer's `td-*` classes + * ({@link renderTraceDetail} / {@link renderFrameValueDetail}), co-located with + * the class emitter. + * + * These rules are lifted VERBATIM from dotli's debug-panel stylesheet + * (`hosts/dotli/packages/truapi-debug/src/styles.css`, the drill-down section) + * so the standalone app and dotli render the frame sequence identically, with + * zero drift. dotli keeps its own copy for now and converges onto this one once + * the build-graph seam lets it import `@parity/truapi-debugger`. Keep the two in + * sync until then; do not hand-edit these rules here. + * + * Note the vendored `hosts/dotli` submodule is the stale pre-port copy, so most + * of these drill-down classes are NOT yet byte-comparable against it - this file + * is the source of truth for them, and the dotli-community port picks them up at + * convergence. App-level layout (grid, the summary strip, `--payload-w`, etc.) + * deliberately lives OUTSIDE this file, as overrides after `TRACE_DETAIL_CSS` in + * the standalone shell, so it never contaminates the shared rules. + */ + +/** Verbatim `td-*` drill-down rules; inline into a ` +
+ TrUAPI Wire Inspector + + + + + + + + + decode: __DECODE_STATE__ +
+
waiting for frames…
+
+
waiting for frames…
+
+
Select an operation to inspect its frames. ↑/↓ to move, Enter to open, d to decode a frame.
+
+
connecting…
+ +`; + +// Entry point: `bun run src/server.ts` (or `npm run serve`) starts the server. +// Port comes from TRUAPI_DEBUGGER_PORT, else the default. Level-2 value decode +// is off unless TRUAPI_DEBUGGER_DECODE_VALUES is truthy (1/true/yes/on). +if (import.meta.main) { + const envPort = Number(Bun.env.TRUAPI_DEBUGGER_PORT); + const decodeValues = /^(1|true|yes|on)$/i.test( + Bun.env.TRUAPI_DEBUGGER_DECODE_VALUES ?? "", + ); + const revealSensitive = /^(1|true|yes|on)$/i.test( + Bun.env.TRUAPI_DEBUGGER_REVEAL_SENSITIVE ?? "", + ); + const server = startDebugServer({ + port: Number.isFinite(envPort) && envPort > 0 ? envPort : DEFAULT_PORT, + decodeValues, + revealSensitive, + }); + console.log( + `[truapi-debugger] listening on http://localhost:${server.port}` + + ` (value decode: ${server.decodeValues ? "on" : "off"}` + + `${server.revealSensitive ? ", sensitive reveal: ARMED" : ""})`, + ); +} From 56ddb57fc3bd6dacdb5072025126c9ef44e36a5a Mon Sep 17 00:00:00 2001 From: Nidish Date: Mon, 3 Aug 2026 01:16:51 +0530 Subject: [PATCH 06/17] feat(truapi-debugger): terminal CLI and query REPL --- js/packages/truapi-debugger/src/cli-client.ts | 122 +++++++ js/packages/truapi-debugger/src/cli.ts | 182 +++++++++++ js/packages/truapi-debugger/src/repl.ts | 309 ++++++++++++++++++ .../truapi-debugger/src/trace-text.test.ts | 99 ++++++ js/packages/truapi-debugger/src/trace-text.ts | 159 +++++++++ 5 files changed, 871 insertions(+) create mode 100644 js/packages/truapi-debugger/src/cli-client.ts create mode 100644 js/packages/truapi-debugger/src/cli.ts create mode 100644 js/packages/truapi-debugger/src/repl.ts create mode 100644 js/packages/truapi-debugger/src/trace-text.test.ts create mode 100644 js/packages/truapi-debugger/src/trace-text.ts diff --git a/js/packages/truapi-debugger/src/cli-client.ts b/js/packages/truapi-debugger/src/cli-client.ts new file mode 100644 index 000000000..f71e23aa4 --- /dev/null +++ b/js/packages/truapi-debugger/src/cli-client.ts @@ -0,0 +1,122 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Shared client for the terminal frontends (the one-shot {@link module:cli} + * commands and the interactive {@link module:repl}). Reads a running debugger's + * HTTP endpoints and rebuilds the shared {@link TraceView} model, so both + * frontends agree with the web inspector on ops, badges, sensitivity, and what + * may be decoded - one engine, one denylist, no forks. + * + * @module + */ + +import { SENSITIVE_FRAME_IDS, type FrameValueDetail } from "./decode.js"; +import type { FrameRole } from "./observed-frame.js"; +import { + buildTraceView, + type TraceBadge, + type TraceView, + type TraceViewInput, +} from "./trace-view.js"; +import type { CliStats } from "./trace-text.js"; + +/** The sensitive denylist, resolved once from the generated wire-table. */ +export const sensitiveIds = SENSITIVE_FRAME_IDS; + +/** One frame as `/traces` serializes it (payload-blind: no bytes, no values). */ +export interface TracesFrame { + direction: "out" | "in"; + frameId: number; + method?: string; + role: string; + byteLength?: number; + timestamp: number; +} +/** One op as `/traces` serializes it. */ +export interface TracesEntry { + channelId: string; + requestId: string; + startedAt: number; + lastAt: number; + /** Op-level badges the server computed (incl. the cross-op retry-storm). */ + badges?: TraceBadge[]; + frames: TracesFrame[]; +} +/** One host as `/channels` reports it. */ +export interface ChannelInfo { + channelId: string; + connected: boolean; + frameCount: number; +} + +export type { CliStats, FrameValueDetail }; + +/** Rebuild the shared view model from a payload-blind `/traces` entry. */ +export function toView(entry: TracesEntry): TraceView { + const input: TraceViewInput = { + requestId: entry.requestId, + channelId: entry.channelId, + startedAt: entry.startedAt, + lastAt: entry.lastAt, + // Cross-op badges (retry-storm) are computed server-side and passed through, + // so the CLI shows the same badges as the web inspector without recomputing. + extraBadges: entry.badges, + frames: entry.frames.map((f) => ({ + direction: f.direction, + // `/traces` role strings come straight off the engine's FrameRole union. + role: f.role as FrameRole, + method: f.method, + frameId: f.frameId, + byteLength: f.byteLength, + timestamp: f.timestamp, + decodable: false, + sensitive: sensitiveIds.has(f.frameId), + })), + }; + return buildTraceView(input); +} + +export { viewMethod } from "./trace-view.js"; + +/** A thin HTTP client over a running debugger server. */ +export interface DebuggerClient { + readonly host: string; + traces(): Promise; + stats(channel: string | null): Promise; + channels(): Promise; + /** + * The gated per-frame drill-down. `reveal` is honored only when the server + * armed `TRUAPI_DEBUGGER_REVEAL_SENSITIVE`; otherwise a sensitive frame still + * comes back redacted - the guarantee lives server-side, not here. + */ + frame( + requestId: string, + seq: number, + channel: string | null, + reveal: boolean, + ): Promise; +} + +/** Build a {@link DebuggerClient} for `host` (e.g. `http://localhost:9231`). */ +export function createDebuggerClient(host: string): DebuggerClient { + const getJson = async (path: string): Promise => { + const res = await fetch(host + path); + if (!res.ok) throw new Error(`${host}${path} → HTTP ${String(res.status)}`); + return res.json() as Promise; + }; + const channelQuery = (channel: string | null): string => + channel ? `?channel=${encodeURIComponent(channel)}` : ""; + return { + host, + traces: () => getJson("/traces"), + stats: (channel) => getJson(`/stats${channelQuery(channel)}`), + channels: async () => + (await getJson<{ channels: ChannelInfo[] }>("/channels")).channels, + frame: (requestId, seq, channel, reveal) => { + const p = new URLSearchParams({ id: requestId, i: String(seq) }); + if (channel) p.set("channel", channel); + if (reveal) p.set("reveal", "1"); + return getJson(`/frame?${p.toString()}`); + }, + }; +} diff --git a/js/packages/truapi-debugger/src/cli.ts b/js/packages/truapi-debugger/src/cli.ts new file mode 100644 index 000000000..766f553d0 --- /dev/null +++ b/js/packages/truapi-debugger/src/cli.ts @@ -0,0 +1,182 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * `truapi-debugger` terminal frontend: look at wire traces from a shell, for + * headless / SSH / CI workflows where the web inspector isn't reachable. + * + * Two frontends over one running debugger (`:9231` by default), sharing the same + * {@link module:cli-client} engine and the same sensitive denylist as the web + * inspector - no forked engine, no forked denylist: + * + * - `ui` / `repl` (default in a terminal): the interactive query {@link module:repl} + * - a prompt you keep querying: ls, filter, sort, use , show, reveal. + * - `ls` / `stats` / `show` / `tail`: one-shot commands for scripting + piping. + * + * Usage (from js/packages/truapi-debugger): + * bun run src/cli.ts # interactive query REPL + * bun run src/cli.ts ls # ops + aggregate summary + * bun run src/cli.ts stats # just the aggregate line + * bun run src/cli.ts show p:4 --reveal # one op's frames + decoded values + * bun run src/cli.ts tail # live view, refreshes each second + * Flags: --host http://localhost:9231 · --channel · --reveal · --interval + * + * @module + */ + +import { + createDebuggerClient, + toView, + type FrameValueDetail, + type TracesEntry, +} from "./cli-client.js"; +import { formatOpDetail, formatOpRow, formatStats } from "./trace-text.js"; +import { runRepl } from "./repl.js"; + +interface ParsedArgs { + cmd: string; + positional: string[]; + flags: Record; +} + +/** Flags that take a following value; everything else is a boolean flag. */ +const VALUE_FLAGS = new Set(["host", "channel", "interval"]); + +function parseArgs(argv: string[]): ParsedArgs { + const flags: Record = {}; + const positional: string[] = []; + let cmd = ""; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = argv[i + 1]; + // Only value-flags consume the next token; a boolean flag (e.g. --reveal) + // leaves it as a positional, so `show --reveal p:4` parses correctly. + if (VALUE_FLAGS.has(key) && next !== undefined && !next.startsWith("--")) { + flags[key] = next; + i++; + } else { + flags[key] = true; + } + } else if (cmd === "") { + cmd = a; + } else { + positional.push(a); + } + } + // No command in an interactive terminal → the query REPL; otherwise the list. + if (cmd === "") cmd = process.stdout.isTTY ? "ui" : "ls"; + return { cmd, positional, flags }; +} + +const args = parseArgs(process.argv.slice(2)); +// A bare `--host`/`--channel` (no value) parses as boolean `true`; take only a +// real string value as provided, otherwise fall back rather than coerce garbage. +const flagValue = (v: string | boolean | undefined): string | undefined => + typeof v === "string" ? v : undefined; +const host = + flagValue(args.flags.host) ?? + process.env.TRUAPI_DEBUGGER_HTTP ?? + "http://localhost:9231"; +const channel = flagValue(args.flags.channel) ?? null; +const reveal = args.flags.reveal === true || args.flags.reveal === "1"; +const client = createDebuggerClient(host); + +async function traces(): Promise { + const all = await client.traces(); + return channel === null ? all : all.filter((t) => t.channelId === channel); +} + +async function cmdStats(): Promise { + console.log(formatStats(await client.stats(channel))); +} + +async function cmdLs(): Promise { + const [stats, entries] = await Promise.all([client.stats(channel), traces()]); + console.log(formatStats(stats)); + console.log(""); + if (entries.length === 0) console.log(" (no operations yet)"); + // Unscoped view: show the channel so same-id ops from two hosts are distinct. + for (const t of entries) console.log(formatOpRow(toView(t), channel === null)); +} + +async function cmdShow(): Promise { + const id = args.positional[0]; + if (id === undefined) { + console.error("usage: show [--reveal] [--channel ]"); + process.exit(1); + } + const entry = (await traces()).find((t) => t.requestId === id); + if (entry === undefined) { + console.error(`no operation with requestId ${id}`); + process.exit(1); + } + const view = toView(entry); + if (reveal) { + // The one-shot reveal is a deliberate, non-interactive scripting path (the + // interactive REPL uses a typed `reveal ` + `yes` confirm instead). Warn + // up front as the REPL does; the server still only honors reveal when armed. + console.error( + "\x1b[31m⚠ revealing SENSITIVE payloads\x1b[0m\x1b[2m — output may contain a private key, signature, or credential; do NOT run this while screen-sharing or recording. Honored only on a server armed with TRUAPI_DEBUGGER_REVEAL_SENSITIVE.\x1b[0m", + ); + } + const decoded = new Map(); + for (const f of view.frames) { + try { + decoded.set( + f.seq, + await client.frame(entry.requestId, f.seq, entry.channelId, reveal), + ); + } catch { + // Leave the frame value-less; the row still renders. + } + } + console.log(formatOpDetail(view, decoded)); +} + +async function cmdTail(): Promise { + const interval = Number(args.flags.interval ?? 1000); + const render = async (): Promise => { + const [stats, entries] = await Promise.all([client.stats(channel), traces()]); + process.stdout.write("\x1b[2J\x1b[H"); + console.log(formatStats(stats)); + console.log(""); + for (const t of entries.slice(-40)) console.log(formatOpRow(toView(t))); + console.log( + `\n\x1b[2mwatching ${host}${channel ? ` · ${channel}` : ""} — Ctrl-C to stop\x1b[0m`, + ); + }; + await render(); + setInterval(() => { + render().catch((e: unknown) => { + console.error(e instanceof Error ? e.message : String(e)); + }); + }, interval); +} + +async function cmdUi(): Promise { + await runRepl(client, channel); +} + +const commands: Record Promise> = { + ui: cmdUi, + repl: cmdUi, + stats: cmdStats, + ls: cmdLs, + ops: cmdLs, + show: cmdShow, + tail: cmdTail, + watch: cmdTail, +}; + +const run = commands[args.cmd]; +if (run === undefined) { + console.error( + `unknown command: ${args.cmd}\ncommands: ui · stats · ls · show · tail`, + ); + process.exit(1); +} +run().catch((e: unknown) => { + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); +}); diff --git a/js/packages/truapi-debugger/src/repl.ts b/js/packages/truapi-debugger/src/repl.ts new file mode 100644 index 000000000..2c5319053 --- /dev/null +++ b/js/packages/truapi-debugger/src/repl.ts @@ -0,0 +1,309 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Interactive query REPL for the wire debugger - a prompt you keep talking to, + * rather than a full-screen app. Line-based (via `node:readline`, so history and + * line editing come for free), over a running debugger, reusing the same + * {@link buildTraceView} engine and denylist as the web inspector. + * + * Session scope (channel / filter / sort / sensitive-only) persists across + * queries, so `ls` reflects the state you set. The sensitive-reveal escape hatch + * is a two-step, in-loop confirm (`reveal ` then `yes`) - no nested prompt, + * and the reveal is honored only when the server is armed. + * + * @module + */ + +import readline from "node:readline"; + +import { + toView, + viewMethod, + type DebuggerClient, + type FrameValueDetail, +} from "./cli-client.js"; +import type { TraceView } from "./trace-view.js"; +import { formatOpDetail, formatOpRow, formatStats } from "./trace-text.js"; + +const COLOR = + process.env.NO_COLOR === undefined && process.stdout.isTTY === true; +function c(code: string, s: string): string { + return COLOR ? `\x1b[${code}m${s}\x1b[0m` : s; +} +const bold = (s: string): string => c("1", s); +const dim = (s: string): string => c("2", s); +const red = (s: string): string => c("31", s); +const green = (s: string): string => c("32", s); +const cyan = (s: string): string => c("36", s); + +const SORTS = ["arrival", "recent", "method", "duration", "frames"]; + +interface ReplState { + channel: string | null; + filter: string; + sort: string; + sensOnly: boolean; + /** A reveal awaiting the next line's `yes` confirmation. */ + pendingReveal: { requestId: string; seq?: number } | null; +} + +const HELP = [ + bold("commands"), + ` ${cyan("ls")} [text] list ops (aggregate + rows); optional inline method filter`, + ` ${cyan("stats")} just the aggregate summary line`, + ` ${cyan("show")} an op's frames, decoding non-sensitive values`, + ` ${cyan("decode")} alias for show`, + ` ${cyan("reveal")} [seq] reveal sensitive frame(s) — asks to confirm (dev, armed server only)`, + ` ${cyan("channels")} hosts that have dialed in`, + ` ${cyan("use")} scope every query to one channel`, + ` ${cyan("filter")} [text] persistent method filter (empty clears)`, + ` ${cyan("sort")} ${SORTS.join(" | ")}`, + ` ${cyan("sensitive")} [on|off] show only ops with a sensitive method`, + ` ${cyan("clear")} clear the screen`, + ` ${cyan("help")} · ${cyan("quit")}`, +].join("\n"); + +/** Run the query REPL against `client`. Resolves when the user quits. */ +export async function runRepl( + client: DebuggerClient, + channel: string | null, +): Promise { + const state: ReplState = { + channel, + filter: "", + sort: "arrival", + sensOnly: false, + pendingReveal: null, + }; + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + historySize: 200, + terminal: process.stdin.isTTY === true, + }); + + console.log(`${bold("TrUAPI wire debugger")}${dim(` — ${client.host}`)}`); + console.log(dim("type `help` for commands, `quit` to exit")); + + const promptStr = (): string => { + const bits = [state.channel ?? "all"]; + if (state.filter) bits.push(cyan(`/${state.filter}`)); + if (state.sort !== "arrival") bits.push(`sort:${state.sort}`); + if (state.sensOnly) bits.push(red("\u{1f512}")); + return `${green("truapi")} ${dim(bits.join(" "))} ${bold("▸")} `; + }; + + function sortViews(views: TraceView[]): TraceView[] { + if (state.sort === "arrival") return views; + return [...views].sort((a, b) => { + switch (state.sort) { + case "recent": + return b.lastAt - a.lastAt; + case "duration": + return b.durationMs - a.durationMs; + case "frames": + return b.frames.length - a.frames.length; + case "method": + return viewMethod(a).localeCompare(viewMethod(b)); + default: + return 0; + } + }); + } + + async function views(inlineFilter?: string): Promise { + const all = await client.traces(); + let vs = all + .filter((t) => state.channel === null || t.channelId === state.channel) + .map(toView); + const f = (inlineFilter ?? state.filter).toLowerCase(); + if (f) vs = vs.filter((v) => viewMethod(v).toLowerCase().includes(f)); + if (state.sensOnly) vs = vs.filter((v) => v.sensitive === true); + return sortViews(vs); + } + + async function doList(inlineFilter?: string): Promise { + const [stats, vs] = await Promise.all([ + client.stats(state.channel), + views(inlineFilter), + ]); + console.log(formatStats(stats)); + console.log(""); + if (vs.length === 0) console.log(dim(" (no operations match)")); + // Unscoped view: show the channel so same-id ops from two hosts are distinct. + for (const v of vs) console.log(formatOpRow(v, state.channel === null)); + } + + async function doChannels(): Promise { + const chs = await client.channels(); + if (chs.length === 0) { + console.log(dim(" (no hosts have dialed in yet)")); + return; + } + for (const ch of chs) { + console.log( + `${ch.connected ? green("●") : dim("○")} ${ch.channelId} ${dim(`(${String(ch.frameCount)} frames)`)}${ch.channelId === state.channel ? cyan(" ← scoped") : ""}`, + ); + } + } + + async function findOp(id: string) { + return (await client.traces()).find( + (t) => + t.requestId === id && + (state.channel === null || t.channelId === state.channel), + ); + } + + async function doShow(id: string, revealSeqs?: Set): Promise { + const entry = await findOp(id); + if (entry === undefined) { + console.log(red(`no operation with requestId ${id}`)); + return; + } + const view = toView(entry); + const decoded = new Map(); + for (const f of view.frames) { + const reveal = revealSeqs?.has(f.seq) ?? false; + try { + decoded.set( + f.seq, + await client.frame(entry.requestId, f.seq, entry.channelId, reveal), + ); + } catch { + // Leave the frame value-less; the row still renders. + } + } + console.log(formatOpDetail(view, decoded)); + } + + async function startReveal(id: string, seqArg?: string): Promise { + const entry = await findOp(id); + if (entry === undefined) { + console.log(red(`no operation with requestId ${id}`)); + return; + } + const view = toView(entry); + const seq = seqArg === undefined ? undefined : Number(seqArg); + const targets = + seq === undefined + ? view.frames.filter((f) => f.sensitive === true) + : view.frames.filter((f) => f.seq === seq); + if (targets.length === 0) { + console.log(dim(" (no sensitive frame to reveal here)")); + return; + } + state.pendingReveal = { requestId: id, seq }; + console.log( + red("⚠ reveal SENSITIVE payload") + + dim(" — may contain a private key/credential; not while screen-sharing.\n") + + ` type ${bold("yes")} to confirm (anything else cancels)`, + ); + } + + async function handle(line: string): Promise { + // A pending reveal consumes this line as its confirmation. + if (state.pendingReveal) { + const { requestId, seq } = state.pendingReveal; + state.pendingReveal = null; + if (line.toLowerCase() !== "yes" && line.toLowerCase() !== "y") { + console.log(dim(" (reveal cancelled)")); + return; + } + const entry = await findOp(requestId); + if (entry === undefined) { + console.log(red(`no operation with requestId ${requestId}`)); + return; + } + const view = toView(entry); + // A specific seq reveals just that frame; otherwise every sensitive frame. + const revealSeqs = + seq === undefined + ? new Set(view.frames.filter((f) => f.sensitive === true).map((f) => f.seq)) + : new Set([seq]); + await doShow(requestId, revealSeqs); + return; + } + + const [cmd, ...rest] = line.split(/\s+/).filter(Boolean); + if (cmd === undefined) return; + const pos = rest.filter((a) => !a.startsWith("--")); + const arg = pos[0]; + switch (cmd) { + case "help": + case "?": + console.log(HELP); + return; + case "ls": + case "ops": + return doList(arg); + case "stats": + console.log(formatStats(await client.stats(state.channel))); + return; + case "channels": + return doChannels(); + case "show": + case "decode": + if (arg === undefined) { + console.log(dim("usage: show ")); + return; + } + return doShow(arg); + case "reveal": + if (arg === undefined) { + console.log(dim("usage: reveal [seq]")); + return; + } + return startReveal(arg, pos[1]); + case "use": + case "channel": + state.channel = arg === undefined || arg === "all" ? null : arg; + return; + case "filter": + state.filter = rest.filter((a) => !a.startsWith("--")).join(" "); + return; + case "sort": + if (arg !== undefined && SORTS.includes(arg)) state.sort = arg; + else console.log(dim(`sort: ${SORTS.join(" | ")}`)); + return; + case "sensitive": + case "sens": + state.sensOnly = arg === undefined ? !state.sensOnly : arg === "on"; + return; + case "clear": + console.clear(); + return; + case "quit": + case "exit": + case "q": + rl.close(); + return; + default: + console.log(dim(`unknown command: ${cmd} — try \`help\``)); + } + } + + const prompt = (): void => { + rl.setPrompt(promptStr()); + rl.prompt(); + }; + + // Serialize line handling so piped input and in-flight fetches never interleave. + let chain: Promise = Promise.resolve(); + prompt(); + rl.on("line", (line) => { + chain = chain + .then(() => handle(line.trim())) + .catch((e: unknown) => { + console.error(red(e instanceof Error ? e.message : String(e))); + }) + .then(() => prompt()); + }); + + await new Promise((resolve) => { + rl.on("close", () => { + console.log(dim("bye")); + resolve(); + }); + }); +} diff --git a/js/packages/truapi-debugger/src/trace-text.test.ts b/js/packages/truapi-debugger/src/trace-text.test.ts new file mode 100644 index 000000000..f1c1d6c74 --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-text.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; + +import { buildTraceView } from "./trace-view.js"; +import { + formatFrameValue, + formatOpRow, + formatStats, + type CliStats, +} from "./trace-text.js"; + +const stats: CliStats = { + ops: 2, + frames: 5, + bytes: 40, + subscriptions: 1, + liveSubscriptions: 1, + malformed: 0, + orphaned: 1, + retryStorms: 0, + sensitive: 1, + out: 3, + in: 2, + avgDurationMs: 12, + maxDurationMs: 30, + topMethods: [], +}; + +describe("formatStats", () => { + test("renders counts and surfaces sensitive + orphaned", () => { + const s = formatStats(stats); + expect(s).toContain("ops"); + expect(s).toContain("sensitive"); + expect(s).toContain("orphaned"); + }); +}); + +describe("formatOpRow", () => { + test("shows method, requestId, and a lock for a sensitive op", () => { + const view = buildTraceView({ + requestId: "p:1", + startedAt: 0, + lastAt: 12, + frames: [ + { + direction: "out", + role: "request", + method: "account.getAccount", + frameId: 22, + byteLength: 20, + timestamp: 0, + decodable: false, + sensitive: true, + }, + { + direction: "in", + role: "response", + method: "account.getAccount", + frameId: 23, + byteLength: 20, + timestamp: 12, + decodable: false, + sensitive: true, + }, + ], + }); + const row = formatOpRow(view); + expect(row).toContain("account.getAccount"); + expect(row).toContain("p:1"); + expect(row).toContain("\u{1f512}"); + }); +}); + +describe("formatFrameValue", () => { + test("redacted never shows a value", () => { + expect( + formatFrameValue({ + kind: "redacted", + reason: "sensitive method", + byteLength: 64, + }), + ).toContain("redacted"); + }); + + test("a revealed sensitive value is flagged dev-only, and still shows content", () => { + const out = formatFrameValue({ + kind: "decoded", + value: { free: 42 }, + sensitive: true, + }); + expect(out).toContain("revealed sensitive material"); + expect(out).toContain("42"); + }); + + test("bytes-only shows no payload", () => { + expect(formatFrameValue({ kind: "bytes", byteLength: 8 })).toContain( + "payload not shown", + ); + }); +}); diff --git a/js/packages/truapi-debugger/src/trace-text.ts b/js/packages/truapi-debugger/src/trace-text.ts new file mode 100644 index 000000000..b775bd119 --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-text.ts @@ -0,0 +1,159 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Terminal renderer for the drill-down, the text counterpart of the HTML + * {@link renderTraceDetail}. Same {@link TraceView} input, so the terminal + * viewer ({@link module:cli}) and the web inspector show the same ops, badges, + * redaction, and decoded values off one engine - no forked formatter, no forked + * denylist. Pure `TraceView → string`; the CLI supplies the view and the decode + * results, exactly as the web mount does. + * + * @module + */ + +import type { FrameValueDetail } from "./decode.js"; +import { viewMethod, type TraceFrameView, type TraceView } from "./trace-view.js"; + +// Color only on an interactive terminal, and never when NO_COLOR is set. +const USE_COLOR = + process.env.NO_COLOR === undefined && process.stdout.isTTY === true; + +function paint(code: string, s: string): string { + return USE_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s; +} +const bold = (s: string): string => paint("1", s); +const dim = (s: string): string => paint("2", s); +const red = (s: string): string => paint("31", s); +const green = (s: string): string => paint("32", s); +const yellow = (s: string): string => paint("33", s); +const magenta = (s: string): string => paint("35", s); +const gray = (s: string): string => paint("90", s); + +function fmtMs(ms: number): string { + return ms < 1000 ? `${String(Math.round(ms))}ms` : `${(ms / 1000).toFixed(2)}s`; +} +function fmtBytes(n: number): string { + if (n < 1024) return `${String(n)} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(2)} MB`; +} + +/** The payload-blind aggregate `/stats` returns, mirrored for the CLI. */ +export interface CliStats { + ops: number; + frames: number; + bytes: number; + subscriptions: number; + liveSubscriptions: number; + malformed: number; + orphaned: number; + retryStorms: number; + sensitive: number; + out: number; + in: number; + avgDurationMs: number; + maxDurationMs: number; + topMethods: { method: string; count: number }[]; +} + +/** One-line aggregate summary, the terminal form of the inspector's summary strip. */ +export function formatStats(s: CliStats): string { + const parts = [ + `${bold(String(s.ops))} ${dim("ops")}`, + `${bold(String(s.frames))} ${dim(`frames (${String(s.out)}▶ ${String(s.in)}◀)`)}`, + `${bold(fmtBytes(s.bytes))} ${dim("data")}`, + `${bold(String(s.subscriptions))} ${dim("subs")}${s.liveSubscriptions ? ` ${green(`(${String(s.liveSubscriptions)} live)`)}` : ""}`, + `${bold(fmtMs(s.avgDurationMs))} ${dim(`avg (max ${fmtMs(s.maxDurationMs)})`)}`, + s.sensitive + ? red(`\u{1f512} ${String(s.sensitive)} sensitive`) + : dim("\u{1f512} 0 sensitive"), + ]; + if (s.malformed) parts.push(red(`${String(s.malformed)} malformed`)); + if (s.orphaned) parts.push(yellow(`${String(s.orphaned)} orphaned`)); + if (s.retryStorms) parts.push(yellow(`${String(s.retryStorms)} retry-storms`)); + return parts.join(dim(" · ")); +} + +const SUBSCRIPTION_ROLES = new Set(["start", "receive", "stop", "interrupt"]); + +/** + * One op as a single row: the terminal form of an op-list row. When + * `showChannel` is set (an unscoped, multi-host view), the channel is shown so + * two hosts minting the same `requestId` are distinguishable. + */ +export function formatOpRow(view: TraceView, showChannel = false): string { + const sub = view.frames.some((f) => SUBSCRIPTION_ROLES.has(f.role)); + const live = sub && !view.frames.some((f) => f.role === "stop"); + const kind = sub ? magenta("⟳") : yellow("▶"); + const method = bold(viewMethod(view).padEnd(38).slice(0, 38)); + const lock = view.sensitive ? red(" \u{1f512}") : " "; + const badges = view.badges + .map((b) => (b === "malformed" ? red(`[${b}]`) : yellow(`[${b}]`))) + .join(" "); + const meta = dim( + `${String(view.frames.length)}f · ${live ? green("live ") : ""}${fmtMs(view.durationMs)}`, + ); + const chan = + showChannel && view.channelId !== undefined + ? gray(`[${view.channelId}] `) + : ""; + return `${kind} ${method}${lock} ${meta}${badges ? ` ${badges}` : ""} ${chan}${gray(view.requestId)}`; +} + +/** One op's full frame sequence + any resolved decode values (drill-down). */ +export function formatOpDetail( + view: TraceView, + decoded: ReadonlyMap, +): string { + const lines: string[] = []; + lines.push( + `${bold(viewMethod(view))} ${dim(`${view.requestId} · ${String(view.frames.length)} frames · ${fmtMs(view.durationMs)}`)}${view.sensitive ? red(" \u{1f512} sensitive") : ""}`, + ); + for (const f of view.frames) { + lines.push(formatFrameRow(f)); + const detail = decoded.get(f.seq); + if (detail) lines.push(indent(formatFrameValue(detail))); + } + return lines.join("\n"); +} + +function formatFrameRow(f: TraceFrameView): string { + const glyph = f.direction === "out" ? yellow("▶") : green("◀"); + const role = dim(f.role.padEnd(8).slice(0, 8)); + const method = f.method ?? `id ${String(f.frameId ?? "?")}`; + const size = f.byteLength === undefined ? "" : dim(`${String(f.byteLength)}B`); + const lat = + f.roundTripMs !== undefined + ? dim(`⟳${fmtMs(f.roundTripMs)}`) + : dim(`+${fmtMs(f.latencyFromStartMs)}`); + return ` ${glyph} ${role} ${method} ${size} ${lat}`; +} + +/** Render one {@link FrameValueDetail}; a revealed sensitive value is flagged. */ +export function formatFrameValue(detail: FrameValueDetail): string { + switch (detail.kind) { + case "redacted": + return red( + `redacted · ${detail.reason} · ${String(detail.byteLength)}B withheld`, + ); + case "bytes": + return dim(`${String(detail.byteLength)}B · payload not shown`); + case "decoded": { + const body = JSON.stringify( + detail.value, + (_key, v: unknown) => (typeof v === "bigint" ? `${v.toString()}n` : v), + 2, + ); + return detail.sensitive === true + ? `${red("⚠ revealed sensitive material — dev only")}\n${body}` + : body; + } + } +} + +function indent(s: string): string { + return s + .split("\n") + .map((l) => ` ${l}`) + .join("\n"); +} From c2de13400317a26311ca81486f37b170738c0d39 Mon Sep 17 00:00:00 2001 From: Nidish Date: Tue, 4 Aug 2026 13:29:54 +0530 Subject: [PATCH 07/17] fix(truapi-debugger): loopback bind and bounded retention --- js/packages/truapi-debugger/src/cli-client.ts | 3 + js/packages/truapi-debugger/src/ingest.ts | 50 +- .../truapi-debugger/src/server.test.ts | 126 ++++- js/packages/truapi-debugger/src/server.ts | 452 ++++++++++++++---- js/packages/truapi-debugger/src/session.ts | 11 +- .../truapi-debugger/src/trace-render.ts | 12 +- .../truapi-debugger/src/trace-styles.ts | 5 + .../truapi-debugger/src/trace-text.test.ts | 4 + js/packages/truapi-debugger/src/trace-text.ts | 20 +- js/packages/truapi-debugger/src/trace-view.ts | 33 +- .../truapi-debugger/src/wire-debugger.test.ts | 113 ++++- .../truapi-debugger/src/wire-debugger.ts | 158 +++++- .../truapi-host/src/worker-runtime.test.ts | 24 + js/packages/truapi-host/src/worker-runtime.ts | 109 ++++- js/packages/truapi/README.md | 9 +- rust/crates/truapi-codegen/src/main.rs | 11 +- rust/crates/truapi-codegen/src/rust.rs | 29 +- .../truapi-codegen/src/rust/wire_table.rs | 22 +- rust/crates/truapi-codegen/src/ts.rs | 56 +++ .../truapi-codegen/tests/golden/wire_table.rs | 6 + .../truapi-server/src/generated/wire_table.rs | 6 + rust/crates/truapi-server/src/host_core.rs | 84 +++- rust/crates/truapi-server/src/native_debug.rs | 108 ++++- 23 files changed, 1257 insertions(+), 194 deletions(-) create mode 100644 js/packages/truapi-host/src/worker-runtime.test.ts diff --git a/js/packages/truapi-debugger/src/cli-client.ts b/js/packages/truapi-debugger/src/cli-client.ts index f71e23aa4..aa08ee543 100644 --- a/js/packages/truapi-debugger/src/cli-client.ts +++ b/js/packages/truapi-debugger/src/cli-client.ts @@ -36,6 +36,8 @@ export interface TracesFrame { export interface TracesEntry { channelId: string; requestId: string; + /** Which reuse of `(channelId, requestId)` this op is; see {@link TraceView.generation}. */ + generation?: number; startedAt: number; lastAt: number; /** Op-level badges the server computed (incl. the cross-op retry-storm). */ @@ -56,6 +58,7 @@ export function toView(entry: TracesEntry): TraceView { const input: TraceViewInput = { requestId: entry.requestId, channelId: entry.channelId, + generation: entry.generation, startedAt: entry.startedAt, lastAt: entry.lastAt, // Cross-op badges (retry-storm) are computed server-side and passed through, diff --git a/js/packages/truapi-debugger/src/ingest.ts b/js/packages/truapi-debugger/src/ingest.ts index 220ac61f0..482dd0e45 100644 --- a/js/packages/truapi-debugger/src/ingest.ts +++ b/js/packages/truapi-debugger/src/ingest.ts @@ -13,6 +13,25 @@ import { decodeWireMessage } from "@parity/truapi"; import type { ObservedFrame, TransportObserver } from "./observed-frame.js"; +import type { WireMethodInfo } from "./wire-debugger.js"; + +/** + * Version of the host→debugger wire envelope (`{ channelId, dir, frame }`). + * Bumped when the envelope shape changes. Producers (the Rust `WsDebugSink`, the + * web host's debugger link) stamp it alongside a codec identity so the debugger + * can refuse to decode a frame against a wire contract that isn't its own - + * frame ids are `u8` discriminants that get reassigned as the API evolves, so an + * unversioned envelope from an older host would resolve to the wrong method, the + * wrong value, and worst case decode a frame the host's build marks sensitive. + */ +export const WIRE_ENVELOPE_VERSION = 1; + +/** + * Default cap on retained `channelId` / `requestId` length. Shared so the + * debugger server's channel registry clamps to the same bound as ingest and the + * two keys stay equal (the UI filters by the clamped key). + */ +export const DEFAULT_MAX_ID_CHARS = 256; /** * One wire frame as it crosses the host tap, matching the Rust @@ -41,6 +60,21 @@ export interface DebugIngestOptions { * either way; retaining them only makes the drill-down decoder able to run. */ retainBytes?: boolean; + /** + * Reverse map from wire `frameId` to method info (build one with + * {@link createMethodNameMap}). When set, each frame's lifecycle `role` is + * resolved here from the frame id's wire-table `kind`, so *every* consumer - + * the default console sink, the `forward` hook, and the trace engine - sees the + * real role. Without it, `role` is left `"unknown"` and only the view adapter + * recovers it. + */ + methodNames?: ReadonlyMap; + /** + * Cap on retained `channelId` / `requestId` length. Anything able to reach the + * host tap could otherwise send 200k-char ids, one copy per frame; real ids are + * short (`myapp.dot`, `p:1`). Default 256. + */ + maxIdChars?: number; } /** @@ -63,11 +97,16 @@ export function createDebugIngest( options: DebugIngestOptions = {}, ): (envelope: DebugFrameEnvelope) => void { const retainBytes = options.retainBytes ?? false; + const methodNames = options.methodNames; + const maxIdChars = options.maxIdChars ?? DEFAULT_MAX_ID_CHARS; + const clampId = (id: string): string => + id.length > maxIdChars ? id.slice(0, maxIdChars) : id; return (envelope) => { + const channelId = clampId(envelope.channelId); const decoded = decodeWireMessage(envelope.frame); if (decoded.isErr()) { sink({ - channelId: envelope.channelId, + channelId, direction: envelope.dir, requestId: "malformed", frameId: -1, @@ -79,11 +118,14 @@ export function createDebugIngest( } const { requestId, payload } = decoded.value; const frame: ObservedFrame = { - channelId: envelope.channelId, + channelId, direction: envelope.dir, - requestId, + requestId: clampId(requestId), frameId: payload.id, - role: "unknown", + // Resolve the lifecycle role from the frame id's wire-table kind (the same + // kind wireTraceToView falls back to). Left "unknown" when no map is given + // or the id is off-table. + role: methodNames?.get(payload.id)?.kind ?? "unknown", byteLength: payload.value.length, timestamp: Date.now(), ...(retainBytes ? { bytes: payload.value } : {}), diff --git a/js/packages/truapi-debugger/src/server.test.ts b/js/packages/truapi-debugger/src/server.test.ts index cff93a89b..0b6f84d91 100644 --- a/js/packages/truapi-debugger/src/server.test.ts +++ b/js/packages/truapi-debugger/src/server.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -import { encodeWireMessage } from "@parity/truapi"; +import { encodeWireMessage, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; import * as W from "@parity/truapi/wire-table"; import { startDebugServer } from "./server.js"; @@ -35,7 +35,14 @@ async function streamFrame( ws.onopen = () => resolve(); ws.onerror = () => reject(new Error("ws failed to open")); }); - ws.send(JSON.stringify({ channelId: "myapp.dot", dir, frame })); + ws.send( + JSON.stringify({ + channelId: "myapp.dot", + dir, + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + }), + ); let traces: TraceView[] = []; for (let i = 0; i < 50 && traces.length === 0; i++) { traces = (await (await fetch(`${base}/traces`)).json()) as TraceView[]; @@ -61,7 +68,14 @@ test("decodes and groups a frame a host streams over the WS", async () => { ws.onopen = () => resolve(); ws.onerror = () => reject(new Error("ws failed to open")); }); - ws.send(JSON.stringify({ channelId: "myapp.dot", dir: "out", frame })); + ws.send( + JSON.stringify({ + channelId: "myapp.dot", + dir: "out", + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + }), + ); let traces: TraceView[] = []; for (let i = 0; i < 50 && traces.length === 0; i++) { @@ -340,12 +354,109 @@ test("/frame validates its params and 404s an unknown frame", async () => { try { expect((await fetch(`${base}/frame`)).status).toBe(400); expect((await fetch(`${base}/frame?id=x&i=notint`)).status).toBe(400); + // Empty `?i=` must 400, not resolve frame 0 (Number("") === 0). + expect((await fetch(`${base}/frame?id=x&i=`)).status).toBe(400); + expect((await fetch(`${base}/frame?id=x&i=%20`)).status).toBe(400); + // Same coercion on `?gen=`: empty/whitespace/non-int must 400, not resolve + // generation 0 (the oldest recycled op) with a 200. + expect((await fetch(`${base}/frame?id=x&i=0&gen=`)).status).toBe(400); + expect((await fetch(`${base}/frame?id=x&i=0&gen=%20`)).status).toBe(400); + expect((await fetch(`${base}/frame?id=x&i=0&gen=notint`)).status).toBe(400); expect((await fetch(`${base}/frame?id=missing&i=0`)).status).toBe(404); } finally { server.stop(); } }); +test("a codec-mismatched host is banner-flagged and its frames refuse to decode", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame( + "p:1", + W.ACCOUNT_GET_ACCOUNT.request, + new Uint8Array([0]), + ); + // Stream one frame declaring a codec this debugger can't decode against. + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + ws.send( + JSON.stringify({ v: 1, codec: 999, channelId: "old.dot", dir: "out", frame }), + ); + // Wait until the frame is grouped (payload-blind grouping still happens). + for (let i = 0; i < 50; i++) { + const traces = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + if (traces.length > 0) break; + await new Promise((r) => setTimeout(r, 20)); + } + ws.close(); + + // /channels banners the mismatch. + const channels = await (await fetch(`${base}/channels`)).json(); + expect(channels.codecMismatch).toBe(true); + // Decode is refused (409) for that host's frames — never resolved against the + // wrong contract. + const refused = await fetch(`${base}/frame?id=p:1&i=0&channel=old.dot`); + expect(refused.status).toBe(409); + } finally { + server.stop(); + } +}); + +test("a wrong-schema or unstamped host refuses to decode, but still groups", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame( + "p:1", + W.ACCOUNT_GET_ACCOUNT.request, + new Uint8Array([0]), + ); + const stream = async (envelope: Record): Promise => { + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + const want = ((await (await fetch(`${base}/traces`)).json()) as unknown[]) + .length; + ws.send(JSON.stringify(envelope)); + for (let i = 0; i < 50; i++) { + const traces = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + if (traces.length > want) break; + await new Promise((r) => setTimeout(r, 20)); + } + ws.close(); + }; + // A frame stamping a wire schema this debugger can't decode against (the + // codec number alone is unchanged) must be refused, never resolved against + // the wrong contract - the case a coarse codec check misses. + await stream({ + channelId: "stale.dot", + dir: "out", + frame, + codec: 1, + schema: "deadbeefdeadbeef", + }); + expect( + (await fetch(`${base}/frame?id=p:1&i=0&channel=stale.dot`)).status, + ).toBe(409); + // A host that stamps no identity at all is refused too: absent is not trusted. + await stream({ channelId: "bare.dot", dir: "out", frame }); + expect( + (await fetch(`${base}/frame?id=p:1&i=0&channel=bare.dot`)).status, + ).toBe(409); + // Payload-blind grouping is unaffected: both ops are recorded regardless. + const traces = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + expect(traces.length).toBe(2); + } finally { + server.stop(); + } +}); + test("/frame rejects out-of-range indices (negative and huge) with 404", async () => { const server = startDebugServer({ port: 0, decodeValues: true }); const base = `http://localhost:${server.port}`; @@ -459,7 +570,14 @@ test("groups by (channel, requestId) — two hosts minting the same id do not me ws.onopen = () => resolve(); ws.onerror = () => reject(new Error("ws failed to open")); }); - ws.send(JSON.stringify({ channelId, dir: "out", frame })); + ws.send( + JSON.stringify({ + channelId, + dir: "out", + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + }), + ); await new Promise((r) => setTimeout(r, 40)); ws.close(); }; diff --git a/js/packages/truapi-debugger/src/server.ts b/js/packages/truapi-debugger/src/server.ts index dda336483..4186e08d3 100644 --- a/js/packages/truapi-debugger/src/server.ts +++ b/js/packages/truapi-debugger/src/server.ts @@ -19,9 +19,15 @@ * @module */ +import { TRUAPI_CODEC_VERSION, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; import { createDebugSession } from "./session.js"; -import type { DebugFrameEnvelope } from "./ingest.js"; -import { wireTraceToView } from "./trace-view.js"; +import { + DEFAULT_MAX_ID_CHARS, + WIRE_ENVELOPE_VERSION, + type DebugFrameEnvelope, +} from "./ingest.js"; +import { wireTraceToView, type TraceView } from "./trace-view.js"; +import type { CliStats } from "./trace-text.js"; import { renderFrameValueDetail, renderOperationRow, @@ -41,15 +47,87 @@ const SUBSCRIPTION_ROLES = new Set([ "interrupt", ]); -/** The text message a host sends per frame: the envelope with a base64 frame. */ +/** + * The text message a host sends per frame: the envelope with a base64 frame, + * plus the optional identity fields (`v`, `codec`) a versioned host stamps. + */ interface WireMessage { channelId: string; dir: "in" | "out"; frame: string; + /** Envelope version; see {@link WIRE_ENVELOPE_VERSION}. */ + v?: number; + /** The host's wire codec version (`TRUAPI_CODEC_VERSION`). */ + codec?: number; + /** + * The host's wire-contract fingerprint (`TRUAPI_WIRE_SCHEMA_HASH`): a hash of + * every frame id, its method leg, and its sensitivity. Unlike `codec` (the + * coarse handshake number, bumped ~never), this changes whenever a frame id is + * reassigned or a `#[wire(sensitive)]` flag flips - the case where a + * host-sensitive frame could otherwise decode off this debugger's denylist. + */ + schema?: string; + /** Frames this host dropped (link backlog full) before this one; surfaced in stats. */ + dropped?: number; +} + +/** A parsed inbound message: the envelope plus its wire-identity verdict. */ +interface ParsedWireMessage { + envelope: DebugFrameEnvelope; + /** + * `true` when the host stamped a `v`/`codec`/`schema` that does not match this + * debugger's - the API-evolved-underneath case. Blocks the value-decode path. + */ + identityMismatch: boolean; + /** + * `true` only when the host affirmatively stamped a `schema` equal to this + * debugger's. Decode is allowed only for confirmed channels: an absent schema + * (a foreign or pre-identity host) is NOT trusted to decode, closing the + * omit-identity-to-bypass hole. Payload-blind grouping is unaffected. + */ + identityConfirmed: boolean; + /** Frames the host reported dropping before this one. */ + dropped: number; } -/** Parse and validate one inbound WS text message into an envelope, or `null`. */ -function parseWireMessage(raw: string): DebugFrameEnvelope | null { +/** + * Whether a WebSocket upgrade may proceed. Non-browser clients (the CLI, curl) + * send no Origin and are allowed; a browser sends its page Origin, which must be + * a loopback host - a cross-origin page dialing the debugger to inject frames is + * refused (CSWSH), which binding to loopback alone does not prevent. + */ +function originAllowed(origin: string | null): boolean { + if (origin === null) return true; + try { + const host = new URL(origin).hostname; + // `new URL("http://[::1]").hostname` keeps the brackets ("[::1]"), so match + // that form (a bare "::1" never occurs, but accept it defensively). + return ( + host === "127.0.0.1" || + host === "localhost" || + host === "[::1]" || + host === "::1" + ); + } catch { + return false; + } +} + +/** + * Parse an optional integer query param: `undefined` if absent, `null` if + * malformed. Requires a canonical integer so `""`, `" "`, `"1e3"`, `"0x10"`, + * `"1.5"`, and `"+1"` all reject rather than silently coercing (`Number("")===0`). + */ +function optionalInt(raw: string | null): number | null | undefined { + if (raw === null) return undefined; + const t = raw.trim(); + if (!/^-?\d+$/.test(t)) return null; + const n = Number(t); + return Number.isInteger(n) ? n : null; +} + +/** Parse and validate one inbound WS text message, or `null`. */ +function parseWireMessage(raw: string): ParsedWireMessage | null { let parsed: unknown; try { parsed = JSON.parse(raw); @@ -61,10 +139,20 @@ function parseWireMessage(raw: string): DebugFrameEnvelope | null { if (typeof m.channelId !== "string") return null; if (m.dir !== "in" && m.dir !== "out") return null; if (typeof m.frame !== "string") return null; + const schema = typeof m.schema === "string" ? m.schema : undefined; + const identityMismatch = + (typeof m.v === "number" && m.v !== WIRE_ENVELOPE_VERSION) || + (typeof m.codec === "number" && m.codec !== TRUAPI_CODEC_VERSION) || + (schema !== undefined && schema !== TRUAPI_WIRE_SCHEMA_HASH); return { - channelId: m.channelId, - dir: m.dir, - frame: new Uint8Array(Buffer.from(m.frame, "base64")), + envelope: { + channelId: m.channelId, + dir: m.dir, + frame: new Uint8Array(Buffer.from(m.frame, "base64")), + }, + identityMismatch, + identityConfirmed: schema === TRUAPI_WIRE_SCHEMA_HASH, + dropped: typeof m.dropped === "number" && m.dropped > 0 ? m.dropped : 0, }; } @@ -118,6 +206,31 @@ export function startDebugServer( const revealSensitive = decodeValues && (options.revealSensitive ?? false); const session = createDebugSession({ decodeValues, revealSensitive }); + /** Adapt one trace to a view with the shared method map + denylist. */ + const toView = ( + trace: ReturnType[number], + storms: ReturnType, + ): TraceView => + wireTraceToView( + trace, + session.methodNames, + storms.get(trace) ?? [], + session.sensitiveIds, + ); + + /** + * Compute the cross-op retry-storm signal once over a trace set, then adapt + * every trace. The `traces() → detectRetryStorms → wireTraceToView` pipeline is + * shared by every list-level endpoint so the same aggregation runs once, not + * per endpoint. + */ + const viewsFor = ( + traces: ReturnType, + ): { trace: (typeof traces)[number]; view: TraceView }[] => { + const storms = detectRetryStorms(traces); + return traces.map((trace) => ({ trace, view: toView(trace, storms) })); + }; + function tracesJson(): string { // Payload-blind view: raw `bytes` and decoded values are deliberately never // serialized here - decode lives only on the `/frame` drill-down. `method` @@ -127,18 +240,11 @@ export function startDebugServer( // op-level badges (incl. the cross-op retry-storm signal), so the web and // 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); - const out = traces.map((t) => { - const view = wireTraceToView( - t, - session.methodNames, - storms.get(t) ?? [], - session.sensitiveIds, - ); + const out = viewsFor(session.traceEngine.traces()).map(({ trace: t, view }) => { return { channelId: t.channelId, requestId: t.requestId, + generation: t.generation, startedAt: t.startedAt, lastAt: t.lastAt, badges: view.badges, @@ -161,14 +267,25 @@ export function startDebugServer( const rawIndex = url.searchParams.get("i"); const channel = url.searchParams.get("channel") ?? undefined; const reveal = url.searchParams.get("reveal") === "1"; + // `Number("")`/`Number(" ")` are both 0 and pass Number.isInteger, so an + // empty or whitespace `?i=` or `?gen=` would otherwise resolve frame 0 / + // generation 0 (the oldest recycled op) with a 200; optionalInt rejects them. + const generation = optionalInt(url.searchParams.get("gen")); const index = Number(rawIndex); - if (id === null || rawIndex === null || !Number.isInteger(index)) { + if ( + id === null || + rawIndex === null || + rawIndex.trim() === "" || + !Number.isInteger(index) || + generation === null + ) { return new Response('{"error":"id and integer i required"}', { status: 400, headers: { "content-type": "application/json" }, }); } - const detail = session.frameDetail(id, index, channel, reveal); + if (!decodeTrusted(channel)) return codecRefusal("application/json"); + const detail = session.frameDetail(id, index, channel, reveal, generation); if (!detail) { return new Response('{"error":"no such frame"}', { status: 404, @@ -186,31 +303,20 @@ export function startDebugServer( * No payloads here; decode controls appear per frame only when level-2 is on. */ function viewHtml(): string { - const traces = session.traceEngine.traces(); - if (traces.length === 0) { + const entries = viewsFor(session.traceEngine.traces()); + if (entries.length === 0) { return `
no frames yet
`; } - // Retry-storm is a cross-op signal computed here in the list layer and fed - // to the view as extra op badges; the renderer stays display-only. - const storms = detectRetryStorms(traces); // Wrap each rendered op in `.td-drilldown` - dotli's verbatim card wrapper - // so the standalone list gets the same per-op framing without a bespoke rule. - return traces + return entries .map( - (t) => + ({ view }) => `
` + - renderTraceDetail( - wireTraceToView( - t, - session.methodNames, - storms.get(t) ?? [], - session.sensitiveIds, - ), - { - offerDecode: session.decodeValues, - offerReveal: session.revealSensitive, - }, - ) + + renderTraceDetail(view, { + offerDecode: session.decodeValues, + offerReveal: session.revealSensitive, + }) + `
`, ) .join(""); @@ -227,14 +333,27 @@ export function startDebugServer( const rawIndex = url.searchParams.get("i"); const channel = url.searchParams.get("channel") ?? undefined; const reveal = url.searchParams.get("reveal") === "1"; + const generation = optionalInt(url.searchParams.get("gen")); const index = Number(rawIndex); - if (id === null || rawIndex === null || !Number.isInteger(index)) { + if ( + id === null || + rawIndex === null || + rawIndex.trim() === "" || + !Number.isInteger(index) || + generation === null + ) { return new Response(`
bad request
`, { status: 400, headers: htmlHeaders, }); } - const detail = session.frameDetail(id, index, channel, reveal); + if (!decodeTrusted(channel)) { + return new Response( + `
decode refused — host wire codec mismatch
`, + { status: 409, headers: htmlHeaders }, + ); + } + const detail = session.frameDetail(id, index, channel, reveal, generation); if (!detail) { return new Response(`
no such frame
`, { status: 404, @@ -262,46 +381,120 @@ export function startDebugServer( // frames under many distinct channelIds can't grow it without bound; when // full, evict the least-recently-seen channel. const MAX_CHANNELS = 256; + // Clamp channelId to the same bound ingest uses so this registry's key matches + // the trace-engine key the UI filters by, and an over-long attacker-chosen id + // can't bloat the map (256 entries * an unbounded key would otherwise grow it). + const clampChannelId = (id: string): string => + id.length > DEFAULT_MAX_ID_CHARS ? id.slice(0, DEFAULT_MAX_ID_CHARS) : id; const channels = new Map< string, - { channelId: string; firstSeen: number; lastSeen: number; frameCount: number } + { + channelId: string; + firstSeen: number; + lastSeen: number; + frameCount: number; + // `false` once this host has sent a frame whose declared wire identity + // (`v`/`codec`/`schema`) does not match this debugger's. Sticky: a single + // mismatch marks the host untrusted for the rest of the session. + codecOk: boolean; + // `true` once this host affirmatively stamped a matching `schema`. Decode + // requires it, so a host that never declares identity is refused, not + // trusted by omission. + schemaOk: boolean; + // Frames the host reported dropping before delivery (its link backlog + // filled): a gap attributable to the link, surfaced so it is not read as + // the host "not answering". + dropped: number; + } >(); let openSockets = 0; + // Sticky: any host has sent an unconfirmed (mismatched or unstamped) frame this + // session. The no-channel decode path keys on this rather than scanning the live + // registry, because an untrusted host's channel record can be LRU-evicted (see + // MAX_CHANNELS) while its frames survive in the trace engine. + let sawUntrusted = false; - function recordChannel(channelId: string): void { + function recordChannel(channelId: string, parsed: ParsedWireMessage): void { + if (!parsed.identityConfirmed) sawUntrusted = true; const now = Date.now(); - const existing = channels.get(channelId); + const key = clampChannelId(channelId); + const existing = channels.get(key); if (existing) { existing.lastSeen = now; existing.frameCount += 1; + existing.dropped += parsed.dropped; + if (parsed.identityMismatch) existing.codecOk = false; + if (parsed.identityConfirmed) existing.schemaOk = true; return; } if (channels.size >= MAX_CHANNELS) { let oldestKey: string | undefined; let oldestSeen = Infinity; - for (const [key, c] of channels) { + for (const [k, c] of channels) { if (c.lastSeen < oldestSeen) { oldestSeen = c.lastSeen; - oldestKey = key; + oldestKey = k; } } if (oldestKey !== undefined) channels.delete(oldestKey); } - channels.set(channelId, { - channelId, + channels.set(key, { + channelId: key, firstSeen: now, lastSeen: now, frameCount: 1, + codecOk: !parsed.identityMismatch, + schemaOk: parsed.identityConfirmed, + dropped: parsed.dropped, + }); + } + + /** + * Whether a decoded value may be surfaced for a channel's frames. Only bites + * when decode is on (payload-blind mode never decodes anyway). Decode is + * allowed only for a channel that affirmatively stamped a matching wire + * `schema` and never mismatched. + * + * This is a COMPATIBILITY guard against honest version drift - a host built + * against a different frame table, where a host-sensitive id could resolve off + * this debugger's `SENSITIVE_FRAME_IDS` - not authentication: + * `TRUAPI_WIRE_SCHEMA_HASH` is a public build constant, so a deliberate local + * injector could stamp it. The WS Origin gate ({@link originAllowed}) is the + * boundary against injection; this is defence in depth on top of it. + */ + function decodeTrusted(channel: string | undefined): boolean { + if (!decodeValues) return true; + if (channel !== undefined) { + const c = channels.get(clampChannelId(channel)); + return c !== undefined && c.codecOk && c.schemaOk; + } + // No channel disambiguator: refuse once any host has been untrusted this + // session (sticky, so an evicted untrusted record can't launder its surviving + // frames). An all-trusted or empty session stays true, so a missing frame + // 404s rather than being masked by a refusal. + return !sawUntrusted; + } + + /** The 409 a decode path returns when the source host's wire codec mismatches. */ + function codecRefusal(contentType: string): Response { + return new Response('{"error":"decode refused: host wire codec mismatch"}', { + status: 409, + headers: { "content-type": contentType }, }); } function channelsJson(): string { const now = Date.now(); + const list = [...channels.values()].sort((a, b) => b.lastSeen - a.lastSeen); return JSON.stringify({ sockets: openSockets, - channels: [...channels.values()] - .sort((a, b) => b.lastSeen - a.lastSeen) - .map((c) => ({ ...c, connected: now - c.lastSeen < CONNECTED_WINDOW_MS })), + // A banner signal: at least one connected host is streaming a wire codec + // this debugger can't decode against. + codecMismatch: list.some((c) => !c.codecOk), + channels: list.map((c) => ({ + ...c, + connected: now - c.lastSeen < CONNECTED_WINDOW_MS, + })), }); } @@ -316,8 +509,7 @@ export function startDebugServer( const traces = channel === null ? session.traceEngine.traces() - : session.traceEngine.tracesForChannel(channel); - const storms = detectRetryStorms(traces); + : session.traceEngine.tracesForChannel(clampChannelId(channel)); let frames = 0; let bytes = 0; let subscriptions = 0; @@ -325,20 +517,21 @@ export function startDebugServer( let malformed = 0; let orphaned = 0; let retryStorms = 0; + let truncated = 0; let sensitive = 0; let out = 0; let inbound = 0; let durationTotal = 0; let durationMax = 0; const methodCounts = new Map(); - for (const t of traces) { - const view = wireTraceToView(t, session.methodNames, storms.get(t) ?? [], session.sensitiveIds); + for (const { view } of viewsFor(traces)) { frames += view.frames.length; durationTotal += view.durationMs; if (view.durationMs > durationMax) durationMax = view.durationMs; if (view.badges.includes("malformed")) malformed += 1; if (view.badges.includes("orphaned")) orphaned += 1; if (view.badges.includes("retry-storm")) retryStorms += 1; + if (view.badges.includes("truncated")) truncated += 1; if (view.sensitive) sensitive += 1; if (view.frames.some((f) => SUBSCRIPTION_ROLES.has(f.role))) { subscriptions += 1; @@ -362,7 +555,22 @@ export function startDebugServer( .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([method, count]) => ({ method, count })); - return JSON.stringify({ + // Whole-op eviction (session-wide) and host-reported drops are loss the ops + // list can't show: `ops` counts only the survivors, so without these a + // 10k-op session that kept 256 reads as "256 ops" with no sign the rest were + // dropped. `codecMismatch` flags a host whose wire contract differs. + const evictedTraces = session.traceEngine.evictedTraces(); + const chanList = + channel === null + ? [...channels.values()] + : [...channels.values()].filter( + (c) => c.channelId === clampChannelId(channel), + ); + const droppedByHost = chanList.reduce((n, c) => n + c.dropped, 0); + const codecMismatch = chanList.some((c) => !c.codecOk); + // Typed so a dropped/renamed field is a compile error, not a silent gap in + // the payload the CLI parses back as CliStats. + const payload: CliStats = { ops, frames, bytes, @@ -371,13 +579,18 @@ export function startDebugServer( malformed, orphaned, retryStorms, + truncated, + evictedTraces, + droppedByHost, + codecMismatch, sensitive, out, in: inbound, avgDurationMs: ops === 0 ? 0 : Math.round(durationTotal / ops), maxDurationMs: Math.round(durationMax), topMethods, - }); + }; + return JSON.stringify(payload); } /** The op's method for sorting: the first frame that resolves to one. */ @@ -428,7 +641,7 @@ export function startDebugServer( const base = channel === null ? session.traceEngine.traces() - : session.traceEngine.tracesForChannel(channel); + : session.traceEngine.tracesForChannel(clampChannelId(channel)); // Retry-storm is per-channel (a burst of like ops from one host), so it is // detected over exactly the traces being listed - before any reorder, since // the storm map is keyed by the trace object, not its position. @@ -436,13 +649,26 @@ export function startDebugServer( if (base.length === 0) { return `
no operations yet
`; } - return sortTraces(base, sort) - .map((t) => - renderOperationRow( - wireTraceToView(t, session.methodNames, storms.get(t) ?? [], session.sensitiveIds), - ), - ) - .join(""); + const rows = sortTraces(base, sort); + // If any listed op is from a host whose wire contract differs from this + // debugger's, its method names may be wrong. Warn inline above the rows - not + // only in the global banner - so the mislabeled rows carry the caveat. + // "Unreliable" = a mismatched OR merely unconfirmed host: either way its + // method names come from this debugger's table and may be wrong, so the label + // matches the decode gate's bar rather than the narrower banner. + const mismatched = new Set( + [...channels.values()] + .filter((c) => !c.codecOk || !c.schemaOk) + .map((c) => c.channelId), + ); + const notice = + mismatched.size > 0 && + rows.some((t) => mismatched.has(clampChannelId(t.channelId))) + ? `
⚠ a connected host's wire contract differs from this debugger's — method names below may be wrong
` + : ""; + return ( + notice + rows.map((t) => renderOperationRow(toView(t, storms))).join("") + ); } /** @@ -450,21 +676,23 @@ export function startDebugServer( * {@link renderTraceDetail}. `channel` disambiguates the `requestId` when more * than one host is connected (each mints the same `p:N` ids). */ - function opDetailHtml(requestId: string, channel: string | null): string { - const trace = session.traceEngine.trace(requestId, channel ?? undefined); + function opDetailHtml( + requestId: string, + channel: string | null, + generation?: number, + ): string { + const trace = session.traceEngine.trace( + requestId, + channel ?? undefined, + generation, + ); if (!trace) { return `
operation not found
`; } const storms = detectRetryStorms( session.traceEngine.tracesForChannel(trace.channelId), ); - const view = wireTraceToView( - trace, - session.methodNames, - storms.get(trace) ?? [], - session.sensitiveIds, - ); - return renderTraceDetail(view, { + return renderTraceDetail(toView(trace, storms), { offerDecode: session.decodeValues, offerReveal: session.revealSensitive, }); @@ -472,8 +700,23 @@ export function startDebugServer( const server = Bun.serve({ port: options.port ?? DEFAULT_PORT, + // Loopback only. The debugger holds every trace (and, with decode on, decoded + // values), so it must not listen on all interfaces where a LAN peer could + // read them or inject frames. The CLI and same-origin inspector both target + // localhost, so nothing else changes. + hostname: "127.0.0.1", fetch(req, srv) { - if (srv.upgrade(req)) return undefined; + // Reject cross-origin WebSocket upgrades (CSWSH): binding to 127.0.0.1 + // keeps off-box peers out, but a page open in the dev's own browser could + // still dial ws://127.0.0.1: to inject frames or drive the decoder + // over hostile bytes. A same-origin inspector and non-browser clients are + // allowed; a foreign browser Origin is not. + if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { + if (!originAllowed(req.headers.get("origin"))) { + return new Response("forbidden origin", { status: 403 }); + } + if (srv.upgrade(req)) return undefined; + } const url = new URL(req.url); const htmlHeaders = { "content-type": "text/html; charset=utf-8" }; if (url.pathname === "/traces") { @@ -502,10 +745,17 @@ export function startDebugServer( } if (url.pathname === "/op") { const id = url.searchParams.get("id"); + const generation = optionalInt(url.searchParams.get("gen")); + if (generation === null) { + return new Response(`
bad request
`, { + status: 400, + headers: htmlHeaders, + }); + } return new Response( id === null ? `
select an operation
` - : opDetailHtml(id, url.searchParams.get("channel")), + : opDetailHtml(id, url.searchParams.get("channel"), generation), { headers: htmlHeaders }, ); } @@ -536,10 +786,12 @@ export function startDebugServer( // the invariant local so a future ingest change can't propagate here. try { const raw = typeof message === "string" ? message : message.toString(); - const envelope = parseWireMessage(raw); - if (envelope) { - recordChannel(envelope.channelId); - session.handleEnvelope(envelope); + const parsed = parseWireMessage(raw); + if (parsed) { + recordChannel(parsed.envelope.channelId, parsed); + // Still grouped (payload-blind is safe and useful); a mismatch only + // blocks the value-decode path, via decodeTrusted. + session.handleEnvelope(parsed.envelope); } } catch { // Drop the frame; the observed session is worth more than one trace. @@ -707,6 +959,7 @@ ${TRACE_DETAIL_CSS} .ins-status { display: flex; gap: 16px; padding: 4px 12px; color: #6b7280; border-top: 1px solid rgba(255,255,255,.08); } .ins-status .live { color: #4ade80; } + .ins-status .mismatch { color: #f87171; }
TrUAPI Wire Inspector @@ -752,6 +1005,7 @@ ${TRACE_DETAIL_CSS} var selectedId = null; // requestId of the open op var selectedChannel = null; // channelId of the open op (disambiguates requestId across hosts) + var selectedGen = null; // generation of the open op (disambiguates a recycled requestId) var channel = null; // channelId filter, null = all var lastListHtml = ""; // skip rebuilds when the op list is unchanged var lastDetailHtml = ""; // skip detail refresh when the open op is unchanged @@ -798,13 +1052,13 @@ ${TRACE_DETAIL_CSS} function get(url) { return fetch(url).then(function (r) { return r.text(); }); } function keyOf(el) { - return el.getAttribute("data-request-id") + "\\0" + (el.getAttribute("data-channel-id") || ""); + return el.getAttribute("data-request-id") + "\\0" + (el.getAttribute("data-channel-id") || "") + "\\0" + (el.getAttribute("data-generation") || "0"); } // The selected op's identity is (requestId, channelId), not requestId alone - // two hosts on the "all" view mint the same p:N, so selection, the keyed diff, // and keyboard nav must all match on the composite key. function selKey() { - return selectedId === null ? null : selectedId + "\\0" + (selectedChannel || ""); + return selectedId === null ? null : selectedId + "\\0" + (selectedChannel || "") + "\\0" + (selectedGen || "0"); } function visibleRows() { return rows().filter(function (r) { return !r.classList.contains("filtered-out"); }); @@ -860,9 +1114,10 @@ ${TRACE_DETAIL_CSS} function rows() { return Array.prototype.slice.call(listEl.querySelectorAll(".td-op")); } - function selectOp(id, chan) { + function selectOp(id, chan, gen) { selectedId = id; selectedChannel = chan || null; + selectedGen = gen == null ? "0" : String(gen); var want = selKey(); var row = null; rows().forEach(function (r) { @@ -872,7 +1127,8 @@ ${TRACE_DETAIL_CSS} }); cursor = -1; get("/op?id=" + encodeURIComponent(id) + - (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "")) + (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "") + + "&gen=" + encodeURIComponent(selectedGen)) .then(function (frag) { lastDetailHtml = frag; detailEl.innerHTML = frag; @@ -890,7 +1146,7 @@ ${TRACE_DETAIL_CSS} if (rs.length === 0) return; var key = selKey(); var idx = rs.findIndex(function (r) { return keyOf(r) === key; }); - function pick(r) { selectOp(r.getAttribute("data-request-id"), r.getAttribute("data-channel-id")); } + function pick(r) { selectOp(r.getAttribute("data-request-id"), r.getAttribute("data-channel-id"), r.getAttribute("data-generation")); } if (e.key === "ArrowDown") { e.preventDefault(); var n = idx < 0 ? 0 : Math.min(idx + 1, rs.length - 1); @@ -913,7 +1169,7 @@ ${TRACE_DETAIL_CSS} }); listEl.addEventListener("click", function (e) { var row = e.target.closest && e.target.closest(".td-op"); - if (row) { listEl.focus(); selectOp(row.getAttribute("data-request-id"), row.getAttribute("data-channel-id")); } + if (row) { listEl.focus(); selectOp(row.getAttribute("data-request-id"), row.getAttribute("data-channel-id"), row.getAttribute("data-generation")); } }); // Detail keyboard: move a frame cursor, decode the cursored frame. @@ -947,7 +1203,8 @@ ${TRACE_DETAIL_CSS} if (!id || seq === null) return; btn.disabled = true; get("/frame-html?id=" + encodeURIComponent(id) + "&i=" + encodeURIComponent(seq) + - (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "")) + (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "") + + "&gen=" + encodeURIComponent(selectedGen || "0")) .then(function (frag) { btn.outerHTML = frag; }) .catch(function () { btn.disabled = false; }); } @@ -973,7 +1230,8 @@ ${TRACE_DETAIL_CSS} if (!id || seq === null) return; btn.disabled = true; get("/frame-html?id=" + encodeURIComponent(id) + "&i=" + encodeURIComponent(seq) + "&reveal=1" + - (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "")) + (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "") + + "&gen=" + encodeURIComponent(selectedGen || "0")) .then(function (frag) { btn.outerHTML = frag; }) .catch(function () { btn.disabled = false; }); } @@ -993,7 +1251,8 @@ ${TRACE_DETAIL_CSS} // placeholder (the server offers controls, not values). cursor = -1; // the re-render clears .cursor; keep the index in step get("/op?id=" + encodeURIComponent(selectedId) + - (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "")) + (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "") + + "&gen=" + encodeURIComponent(selectedGen || "0")) .then(function (frag) { detailEl.innerHTML = frag; }); } var decodeAllBtn = document.getElementById("decodeAll"); @@ -1020,8 +1279,13 @@ ${TRACE_DETAIL_CSS} }); chanEl.innerHTML = html; var hosts = (data.channels || []).length; + // A host streaming a wire codec this debugger can't decode against: value + // decode is refused for it (payload-blind grouping still works). Banner it. + var codecWarn = data.codecMismatch + ? ' · ⚠ codec mismatch' + : ""; statusEl.innerHTML = rows().length + " ops · " + hosts + " host" + (hosts === 1 ? "" : "s") + - " · " + (live > 0 ? '' + live + " live" : "idle"); + " · " + (live > 0 ? '' + live + " live" : "idle") + codecWarn; } function escHtml(s) { return String(s).replace(/[&<>"']/g, function (c) { @@ -1059,12 +1323,15 @@ ${TRACE_DETAIL_CSS} statTile(s.frames, "frames", s.out + "▶ " + s["in"] + "◀") + statTile(fmtBytes(s.bytes), "data") + statTile(s.subscriptions, "subs", s.liveSubscriptions > 0 ? s.liveSubscriptions + " live" : "") + - statTile(fmtMs(s.avgDurationMs), "avg op", "max " + fmtMs(s.maxDurationMs)) + + statTile(fmtMs(s.avgDurationMs), "avg op", "max " + fmtMs(s.maxDurationMs) + ", observed") + '
' + (s.sensitive || 0) + '🔒 sensitive
' + warnTile(s.malformed, "malformed") + warnTile(s.orphaned, "orphaned") + - warnTile(s.retryStorms, "retry storms"); + warnTile(s.retryStorms, "retry storms") + + warnTile(s.truncated || 0, "truncated") + + warnTile(s.evictedTraces || 0, "evicted") + + warnTile(s.droppedByHost || 0, "dropped"); if (s.topMethods && s.topMethods.length) { var m = '
'; s.topMethods.forEach(function (t) { @@ -1113,7 +1380,8 @@ ${TRACE_DETAIL_CSS} // every second; sticky Decode-all re-applies when it does change. if (selectedId) { get("/op?id=" + encodeURIComponent(selectedId) + - (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "")) + (selectedChannel ? "&channel=" + encodeURIComponent(selectedChannel) : "") + + "&gen=" + encodeURIComponent(selectedGen || "0")) .then(function (frag) { if (frag === lastDetailHtml) return; lastDetailHtml = frag; diff --git a/js/packages/truapi-debugger/src/session.ts b/js/packages/truapi-debugger/src/session.ts index 466c409ce..628d5b337 100644 --- a/js/packages/truapi-debugger/src/session.ts +++ b/js/packages/truapi-debugger/src/session.ts @@ -86,6 +86,7 @@ export interface DebugSession { index: number, channelId?: string, reveal?: boolean, + generation?: number, ): FrameValueDetail | undefined; } @@ -111,9 +112,12 @@ export function createDebugSession( // `console.debug`). Consumers read `traceEngine`, not stdout. const wireDebugger = createWireDebugger({ methodNames, sink: () => {} }); // Raw bytes are retained only when decode is on - they exist solely to feed - // the drill-down decoder, and `/traces` never serializes them. + // the drill-down decoder, and `/traces` never serializes them. `methodNames` + // resolves each frame's role at ingest, so the engine and any forward hook see + // the real role rather than "unknown". const handleEnvelope = createDebugIngest(wireDebugger.observe, { retainBytes: decodeValues, + methodNames, }); const decoder = createFrameDecoder({ enabled: decodeValues, @@ -125,8 +129,11 @@ export function createDebugSession( index: number, channelId?: string, reveal?: boolean, + generation?: number, ): FrameValueDetail | undefined => { - const frame = wireDebugger.trace(requestId, channelId)?.frames[index]; + const frame = wireDebugger.trace(requestId, channelId, generation)?.frames[ + index + ]; return frame ? decoder.detail(frame, { reveal }) : undefined; }; diff --git a/js/packages/truapi-debugger/src/trace-render.ts b/js/packages/truapi-debugger/src/trace-render.ts index 78b02ab7f..f94c78b70 100644 --- a/js/packages/truapi-debugger/src/trace-render.ts +++ b/js/packages/truapi-debugger/src/trace-render.ts @@ -127,6 +127,7 @@ const OP_BADGE_LABEL: Record = { orphaned: "orphaned", malformed: "malformed", "retry-storm": "retry storm", + truncated: "truncated", }; function renderOpBadge(badge: TraceBadge): string { @@ -141,6 +142,8 @@ function badgeTitle(badge: TraceBadge): string { return "A frame failed to decode on the wire"; case "retry-storm": return "This op is one of a burst of like ops in a short window"; + case "truncated": + return "Older frames were dropped to stay under the frame/byte cap"; } } @@ -210,12 +213,12 @@ function renderLatency(frame: TraceFrameView): string { // A closing frame that answers an opener shows its round-trip; everything // else shows its offset from the op's first frame. if (frame.roundTripMs !== undefined) { - return `⟳ ${formatMs(frame.roundTripMs)}`; + return `⟳ ${formatMs(frame.roundTripMs)}`; } if (frame.latencyFromStartMs === 0) { return `+0`; } - return `+${formatMs(frame.latencyFromStartMs)}`; + return `+${formatMs(frame.latencyFromStartMs)}`; } /** @@ -373,13 +376,16 @@ export function renderOperationRow(view: TraceView): string { // Op-row privacy marker + a filterable attribute: this op touches a method // whose payload stays redacted by default. const sensitiveAttr = view.sensitive ? ` data-sensitive="1"` : ""; + // Generation disambiguates ops that recycle a `(channelId, requestId)`; the + // client keys rows and the drill-down on it so reused ids stay distinct. + const genAttr = ` data-generation="${String(view.generation ?? 0)}"`; const lock = view.sensitive ? `` : ""; return ( `
` + + `data-request-id="${esc(view.requestId)}"${channelAttr}${genAttr}${sensitiveAttr} role="listitem" tabindex="-1">` + `` + methodHtml + lock + diff --git a/js/packages/truapi-debugger/src/trace-styles.ts b/js/packages/truapi-debugger/src/trace-styles.ts index 44de74d6e..78f9cf968 100644 --- a/js/packages/truapi-debugger/src/trace-styles.ts +++ b/js/packages/truapi-debugger/src/trace-styles.ts @@ -169,6 +169,11 @@ export const TRACE_DETAIL_CSS = String.raw` background: rgba(251, 146, 60, 0.12); border-color: rgba(251, 146, 60, 0.3); } +.td-badge-truncated { + color: #94a3b8; + background: rgba(148, 163, 184, 0.12); + border-color: rgba(148, 163, 184, 0.3); +} /* Level-2 decode affordance (standalone app vantage; dotli keeps bytes off). */ .td-frame-decode-btn { font: inherit; diff --git a/js/packages/truapi-debugger/src/trace-text.test.ts b/js/packages/truapi-debugger/src/trace-text.test.ts index f1c1d6c74..85f6b1c7c 100644 --- a/js/packages/truapi-debugger/src/trace-text.test.ts +++ b/js/packages/truapi-debugger/src/trace-text.test.ts @@ -17,6 +17,10 @@ const stats: CliStats = { malformed: 0, orphaned: 1, retryStorms: 0, + truncated: 0, + evictedTraces: 0, + droppedByHost: 0, + codecMismatch: false, sensitive: 1, out: 3, in: 2, diff --git a/js/packages/truapi-debugger/src/trace-text.ts b/js/packages/truapi-debugger/src/trace-text.ts index b775bd119..84c806fda 100644 --- a/js/packages/truapi-debugger/src/trace-text.ts +++ b/js/packages/truapi-debugger/src/trace-text.ts @@ -48,6 +48,10 @@ export interface CliStats { malformed: number; orphaned: number; retryStorms: number; + truncated: number; + evictedTraces: number; + droppedByHost: number; + codecMismatch: boolean; sensitive: number; out: number; in: number; @@ -63,7 +67,9 @@ export function formatStats(s: CliStats): string { `${bold(String(s.frames))} ${dim(`frames (${String(s.out)}▶ ${String(s.in)}◀)`)}`, `${bold(fmtBytes(s.bytes))} ${dim("data")}`, `${bold(String(s.subscriptions))} ${dim("subs")}${s.liveSubscriptions ? ` ${green(`(${String(s.liveSubscriptions)} live)`)}` : ""}`, - `${bold(fmtMs(s.avgDurationMs))} ${dim(`avg (max ${fmtMs(s.maxDurationMs)})`)}`, + // "observed": these times are the debugger's own WS-arrival clock, so they + // include transport + queueing delay and are not the true host call latency. + `${bold(fmtMs(s.avgDurationMs))} ${dim(`avg (max ${fmtMs(s.maxDurationMs)}, observed)`)}`, s.sensitive ? red(`\u{1f512} ${String(s.sensitive)} sensitive`) : dim("\u{1f512} 0 sensitive"), @@ -71,6 +77,13 @@ export function formatStats(s: CliStats): string { if (s.malformed) parts.push(red(`${String(s.malformed)} malformed`)); if (s.orphaned) parts.push(yellow(`${String(s.orphaned)} orphaned`)); if (s.retryStorms) parts.push(yellow(`${String(s.retryStorms)} retry-storms`)); + // Loss the op list can't show: frames dropped within a kept op, whole ops + // evicted, and frames the host dropped before delivery. A codec mismatch means + // a connected host's wire contract differs, so its method names may be wrong. + if (s.truncated) parts.push(yellow(`${String(s.truncated)} truncated`)); + if (s.evictedTraces) parts.push(yellow(`${String(s.evictedTraces)} evicted`)); + if (s.droppedByHost) parts.push(yellow(`${String(s.droppedByHost)} dropped`)); + if (s.codecMismatch) parts.push(red("⚠ codec mismatch")); return parts.join(dim(" · ")); } @@ -106,8 +119,11 @@ export function formatOpDetail( decoded: ReadonlyMap, ): string { const lines: string[] = []; + // Durations here (and the per-frame ⟳/+ below) are the debugger's own + // WS-arrival clock — transport + queueing included — so label them "observed" + // rather than let a Network-tab-shaped readout imply true host call latency. lines.push( - `${bold(viewMethod(view))} ${dim(`${view.requestId} · ${String(view.frames.length)} frames · ${fmtMs(view.durationMs)}`)}${view.sensitive ? red(" \u{1f512} sensitive") : ""}`, + `${bold(viewMethod(view))} ${dim(`${view.requestId} · ${String(view.frames.length)} frames · ${fmtMs(view.durationMs)} observed`)}${view.sensitive ? red(" \u{1f512} sensitive") : ""}`, ); for (const f of view.frames) { lines.push(formatFrameRow(f)); diff --git a/js/packages/truapi-debugger/src/trace-view.ts b/js/packages/truapi-debugger/src/trace-view.ts index ecb970691..d5af0e532 100644 --- a/js/packages/truapi-debugger/src/trace-view.ts +++ b/js/packages/truapi-debugger/src/trace-view.ts @@ -38,8 +38,10 @@ import type { WireMethodInfo, WireTrace } from "./wire-debugger.js"; * This is a *cross-op* signal the single-trace renderer cannot see on its * own, so it is supplied by the caller (the list/engine layer) rather than * derived here. Left as a follow-up for the engine to compute. + * - `truncated`: older frames of this op were dropped to stay under the engine's + * frame/byte cap, so the sequence shown is not the whole op. */ -export type TraceBadge = "orphaned" | "malformed" | "retry-storm"; +export type TraceBadge = "orphaned" | "malformed" | "retry-storm" | "truncated"; /** A per-frame badge, surfaced against a single row in the frame sequence. */ export type TraceFrameBadge = "malformed" | "orphaned"; @@ -63,11 +65,17 @@ export interface TraceFrameView { byteLength?: number; /** Epoch ms the frame was observed. */ timestamp: number; - /** Offset in ms from the trace's first frame. */ + /** + * Offset in ms from the trace's first frame. Debugger-observed: measured from + * the debugger's envelope-arrival clock, so it includes WS transport and + * queueing delay. Reliable for ordering and presence, not a host-side latency. + */ latencyFromStartMs: number; /** * Round-trip in ms from this frame back to the opening frame it answers, - * present only on a closing frame that has a matched opener. + * present only on a closing frame that has a matched opener. Debugger-observed + * (see {@link latencyFromStartMs}): it includes transport/queueing, so it is + * not the host's "this call took N ms". */ roundTripMs?: number; /** Badges for this frame alone. */ @@ -97,6 +105,12 @@ export interface TraceView { * so the op list keys and filters on `(channelId, requestId)`. */ channelId?: string; + /** + * Which reuse of `(channelId, requestId)` this op is, from `0`. A product may + * recycle a requestId for a later call; this lets the op list and drill-down + * address the right op instead of merging or masking one. + */ + generation?: number; /** Epoch ms of the first frame. */ startedAt: number; /** Epoch ms of the most recent frame. */ @@ -150,6 +164,8 @@ export interface TraceViewInput { requestId: string; /** Channel/host the op belongs to, when the vantage supplies it. */ channelId?: string; + /** Which reuse of `(channelId, requestId)` this op is; see {@link TraceView.generation}. */ + generation?: number; startedAt: number; lastAt: number; frames: readonly TraceFrameInput[]; @@ -187,6 +203,7 @@ export function buildTraceView(input: TraceViewInput): TraceView { return { requestId: input.requestId, channelId: input.channelId, + generation: input.generation, startedAt: input.startedAt, lastAt: input.lastAt, durationMs: input.lastAt - input.startedAt, @@ -222,13 +239,15 @@ export function wireTraceToView( return buildTraceView({ requestId: trace.requestId, channelId: trace.channelId, + generation: trace.generation, startedAt: trace.startedAt, lastAt: trace.lastAt, - extraBadges, + // Surface engine-level frame/byte-cap eviction as an op badge. + extraBadges: trace.truncated ? [...extraBadges, "truncated"] : extraBadges, frames: trace.frames.map((frame): TraceFrameInput => { - // The wire ingest leaves `role` as `"unknown"` (lifecycle is not on the - // wire); the frameId's wire-table `kind` is the lifecycle role, so use it - // when the frame has no better one. A `"malformed"` sentinel is kept. + // A frame may still arrive `role: "unknown"` (a vantage with no wire + // frameId, or an off-table id); the frameId's wire-table `kind` is the + // lifecycle role, so use it as the fallback. A `"malformed"` sentinel is kept. const info = methodNames?.get(frame.frameId); const role = frame.role === "unknown" && info !== undefined ? info.kind : frame.role; diff --git a/js/packages/truapi-debugger/src/wire-debugger.test.ts b/js/packages/truapi-debugger/src/wire-debugger.test.ts index cc34a1c12..2b1b4d2f3 100644 --- a/js/packages/truapi-debugger/src/wire-debugger.test.ts +++ b/js/packages/truapi-debugger/src/wire-debugger.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { createWireDebugger } from "./wire-debugger.js"; -import type { ObservedFrame } from "./observed-frame.js"; +import { createWireDebugger, type WireMethodInfo } from "./wire-debugger.js"; +import type { FrameRole, ObservedFrame } from "./observed-frame.js"; /** A minimal observed frame; only the fields the trace engine keys/groups on matter. */ function frame( @@ -9,13 +9,14 @@ function frame( requestId: string, frameId: number, timestamp: number, + role: FrameRole = "unknown", ): ObservedFrame { return { channelId, direction: "out", requestId, frameId, - role: "unknown", + role, byteLength: 1, timestamp, }; @@ -83,4 +84,110 @@ describe("createWireDebugger grouping", () => { expect(wd.tracesForChannel("hostB.dot")).toHaveLength(1); expect(wd.tracesForChannel("absent.dot")).toHaveLength(0); }); + + test("counts whole-op evictions so ops aren't silently under-reported", () => { + const wd = createWireDebugger({ sink: () => {}, maxTraces: 2 }); + // Four distinct ops under a cap of 2: the two oldest whole ops are evicted. + // traces() shows only survivors, so evictedTraces() is the only signal that + // the other two happened. + wd.observe(frame("app.dot", "p:1", 22, 1)); + wd.observe(frame("app.dot", "p:2", 22, 2)); + wd.observe(frame("app.dot", "p:3", 22, 3)); + wd.observe(frame("app.dot", "p:4", 22, 4)); + expect(wd.traces().length).toBe(2); + expect(wd.evictedTraces()).toBe(2); + wd.clear(); + expect(wd.evictedTraces()).toBe(0); + }); + + test("a recycled requestId opens a new op instead of merging (generation)", () => { + // Regression for real dotli traffic: a product recycles `p:5` for an unrelated + // later call. Mirror real ingest — frames arrive role "unknown" and the opener + // is resolved from the frameId's wire-table kind — so the split must still fire. + const methodNames = new Map([ + [40, { method: "chat.createRoom", kind: "request" }], + [41, { method: "chat.createRoom", kind: "response" }], + [22, { method: "account.getAccount", kind: "request" }], + [23, { method: "account.getAccount", kind: "response" }], + ]); + const wd = createWireDebugger({ sink: () => {}, methodNames }); + wd.observe(frame("app.dot", "p:5", 40, 1)); // op 0: chat.createRoom (role "unknown") + wd.observe(frame("app.dot", "p:5", 41, 2)); + wd.observe(frame("app.dot", "p:5", 22, 3_600_000)); // id reused: account.getAccount + wd.observe(frame("app.dot", "p:5", 23, 3_600_002)); + + const traces = wd.traces(); + expect(traces).toHaveLength(2); + expect(traces.map((t) => t.frames.map((f) => f.frameId))).toEqual([ + [40, 41], + [22, 23], + ]); + expect(traces.map((t) => t.generation)).toEqual([0, 1]); + // Durations stay honest — neither op spans the hour-long gap between them. + expect(traces[0].lastAt - traces[0].startedAt).toBe(1); + expect(traces[1].lastAt - traces[1].startedAt).toBe(2); + // trace() resolves to the latest generation. + expect(wd.trace("p:5", "app.dot")?.frames[0].frameId).toBe(22); + }); + + test("the frame cap evicts from index 1, keeping the opener (frames[0])", () => { + // Regression: evicting the oldest frame drops the subscription's `start`, so + // pairing would falsely flag the live sub `orphaned`. The opener must survive. + const wd = createWireDebugger({ sink: () => {}, maxFramesPerTrace: 3 }); + wd.observe(frame("app.dot", "s:7", 18, 1, "start")); // opener + for (let i = 0; i < 10; i++) { + wd.observe(frame("app.dot", "s:7", 21, 2 + i, "receive")); + } + const [trace] = wd.traces(); + expect(trace.frames).toHaveLength(3); + // frames[0] is still the start (id 18), not a mid-stream receive. + expect(trace.frames[0].frameId).toBe(18); + expect(trace.frames[0].role).toBe("start"); + expect(trace.truncated).toBe(true); + }); + + test("an un-truncated trace is not marked truncated", () => { + const wd = createWireDebugger({ sink: () => {}, maxFramesPerTrace: 100 }); + wd.observe(frame("app.dot", "p:1", 22, 1)); + wd.observe(frame("app.dot", "p:1", 23, 2)); + expect(wd.traces()[0].truncated).toBe(false); + }); + + test("the byte cap evicts payload frames but keeps the opener", () => { + const withBytes = ( + requestId: string, + frameId: number, + timestamp: number, + bytes: number, + role: FrameRole = "unknown", + ): ObservedFrame => ({ + ...frame("app.dot", requestId, frameId, timestamp, role), + byteLength: bytes, + bytes: new Uint8Array(bytes), + }); + const wd = createWireDebugger({ sink: () => {}, maxBytesPerTrace: 100 }); + wd.observe(withBytes("s:9", 18, 1, 10, "start")); // opener, 10B + for (let i = 0; i < 20; i++) { + wd.observe(withBytes("s:9", 21, 2 + i, 40, "receive")); // 40B each + } + const [trace] = wd.traces(); + const retained = trace.frames.reduce((n, f) => n + (f.bytes?.length ?? 0), 0); + expect(retained).toBeLessThanOrEqual(100); + expect(trace.frames[0].frameId).toBe(18); // opener kept + expect(trace.truncated).toBe(true); + }); + + test("receives never rotate; a re-subscribe (second start) opens a new op", () => { + const wd = createWireDebugger({ sink: () => {} }); + wd.observe(frame("app.dot", "s:1", 18, 1, "start")); + wd.observe(frame("app.dot", "s:1", 21, 2, "receive")); + wd.observe(frame("app.dot", "s:1", 21, 3, "receive")); + expect(wd.traces()).toHaveLength(1); // one live sub — receives append, no rotate + + wd.observe(frame("app.dot", "s:1", 18, 100, "start")); // id recycled for a new sub + const traces = wd.traces(); + expect(traces).toHaveLength(2); + expect(traces.map((t) => t.frames.length)).toEqual([3, 1]); + expect(traces.map((t) => t.generation)).toEqual([0, 1]); + }); }); diff --git a/js/packages/truapi-debugger/src/wire-debugger.ts b/js/packages/truapi-debugger/src/wire-debugger.ts index 348124896..5d09ac42c 100644 --- a/js/packages/truapi-debugger/src/wire-debugger.ts +++ b/js/packages/truapi-debugger/src/wire-debugger.ts @@ -29,7 +29,11 @@ import type { ObservedFrame, TransportObserver } from "./observed-frame.js"; export interface WireTrace { /** Product channel this op belongs to, e.g. `"myapp.dot"`. */ channelId: string; - /** Correlation id shared by every frame in this trace (unique within the channel). */ + /** + * Correlation id shared by every frame in this trace. A product may recycle it + * for a later, unrelated call; {@link WireTrace.generation} disambiguates the + * successive ops that then share it. + */ requestId: string; /** Frames observed for this id, in the order they crossed the transport. */ frames: ObservedFrame[]; @@ -37,6 +41,18 @@ export interface WireTrace { startedAt: number; /** Epoch ms of the most recent frame. */ lastAt: number; + /** + * Which reuse of `(channelId, requestId)` this op is, from `0`. A fresh opener + * (`request`/`start`) arriving after the id's current op already opened starts + * the next generation, so a recycled id never merges two unrelated calls. + */ + generation: number; + /** + * Whether older frames were dropped from this trace to stay under the frame or + * byte cap. Surfaced as a `truncated` op badge so the operator can tell "older + * frames dropped" from a genuinely short op. + */ + truncated: boolean; } /** Sink for fully-formatted debug lines (defaults to `console.debug`). */ @@ -135,6 +151,15 @@ export interface WireDebuggerOptions { * all of them. */ maxFramesPerTrace?: number; + /** + * Cap on total retained payload bytes within a single trace. Only bites when + * the ingest retains bytes (level-2 decode); with decode off, frames carry no + * bytes and this never triggers. Without it, a burst of large payloads sharing + * one long-lived `requestId` grows memory unbounded even under + * {@link maxFramesPerTrace} (count-capped, not byte-capped). Oldest non-opener + * frames are evicted until the trace is under budget. Default 1 MiB. + */ + maxBytesPerTrace?: number; /** * Reverse map from wire `frameId` to method name (build one with * {@link createMethodNameMap}). When set, formatted lines carry @@ -150,14 +175,25 @@ export interface WireDebugger { /** All retained traces across all channels, most-recently-active last. */ traces(): WireTrace[]; /** - * The trace for a specific `requestId`. Pass `channelId` to disambiguate when - * more than one host is connected (each mints the same `p:N` ids); without it, - * the first trace matching `requestId` in activity order is returned - fine for - * a single-host session or product-span (`correlationId`) correlation. + * The current (latest-generation) trace for a `requestId`. Pass `channelId` to + * disambiguate when more than one host is connected (each mints the same `p:N` + * ids); without it, the most-recently-active op matching `requestId` is returned + * - fine for a single-host session or product-span (`correlationId`) correlation. */ - trace(requestId: string, channelId?: string): WireTrace | undefined; + trace( + requestId: string, + channelId?: string, + generation?: number, + ): WireTrace | undefined; /** All retained traces for one channel, most-recently-active last. */ tracesForChannel(channelId: string): WireTrace[]; + /** + * Count of whole operations LRU-evicted since the last {@link clear}. Distinct + * from per-op frame truncation ({@link WireTrace.truncated}): whole-op eviction + * is otherwise invisible because {@link traces} shows only survivors, so this + * is how a consumer tells "kept 256 of 10k" from "only 256 ever happened". + */ + evictedTraces(): number; /** Drop all retained traces. */ clear(): void; } @@ -184,6 +220,7 @@ export function createWireDebugger(options: WireDebuggerOptions = {}): WireDebug const forward = options.forward; const maxTraces = options.maxTraces ?? 256; const maxFramesPerTrace = options.maxFramesPerTrace ?? 1024; + const maxBytesPerTrace = options.maxBytesPerTrace ?? 1024 * 1024; const methodNames = options.methodNames; // Insertion-ordered; re-inserting on activity keeps the map LRU-ordered. // Keyed by `(channelId, requestId)` since requestId is per-channel only. @@ -191,36 +228,89 @@ export function createWireDebugger(options: WireDebuggerOptions = {}): WireDebug const keyOf = (channelId: string, requestId: string): string => `${channelId}\u0000${requestId}`; + // `(channelId, requestId)` -> the gen-key of that id's current (latest) op. + const current = new Map(); + // Whole operations LRU-evicted since the last clear(). Surfaced so a session + // that overflowed maxTraces doesn't silently under-report its op count. + let evictedCount = 0; + // A frame's lifecycle role. The ingest leaves it "unknown" (lifecycle isn't on + // the wire), so fall back to the frameId's wire-table kind — the same resolution + // wireTraceToView uses — otherwise no real frame ever reads as an opener. + const roleOf = (f: ObservedFrame): string | undefined => + f.role !== "unknown" ? f.role : methodNames?.get(f.frameId)?.kind; + // A frame that begins an operation: a unary request or a subscription start. + const isOpener = (f: ObservedFrame): boolean => { + const r = roleOf(f); + return r === "request" || r === "start"; + }; + const observe: TransportObserver = (frame) => { - const key = keyOf(frame.channelId, frame.requestId); - let trace = traces.get(key); - if (trace) { - traces.delete(key); + const baseKey = keyOf(frame.channelId, frame.requestId); + const curKey = current.get(baseKey); + const cur = curKey !== undefined ? traces.get(curKey) : undefined; + + // A fresh opener for an id whose current op already opened means the product + // recycled the requestId: rotate to a new generation so the two never merge. + const rotate = + cur !== undefined && + isOpener(frame) && + cur.frames.some((f) => isOpener(f)); + + let trace: WireTrace; + let key: string; + if (curKey !== undefined && cur !== undefined && !rotate) { + traces.delete(curKey); // re-insert below to keep the map LRU-ordered + trace = cur; + key = curKey; } else { + const generation = cur === undefined ? 0 : cur.generation + 1; + key = `${baseKey}${String(generation)}`; trace = { channelId: frame.channelId, requestId: frame.requestId, + generation, frames: [], startedAt: frame.timestamp, lastAt: frame.timestamp, + truncated: false, }; } trace.frames.push(frame); if (trace.frames.length > maxFramesPerTrace) { - // Evict oldest to keep an exact hard cap. This is O(cap) per frame once - // the cap is reached; kept deliberately simple over an O(1) ring buffer - // 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); + // Evict oldest to keep an exact hard cap, but NEVER the opener (frames[0]): + // it is the request/start the pairing (`orphaned`) and retry-storm signals + // key on, so dropping it would falsely orphan a long-lived subscription + // (e.g. account.connectionStatus). Ring-buffer from index 1 instead. + trace.frames.splice(1, trace.frames.length - maxFramesPerTrace); + trace.truncated = true; + } + // Byte cap: only bites when bytes are retained (level-2 decode). Evict oldest + // non-opener frames until the retained payload is under budget, so one id's + // large payloads can't grow memory without bound even under the count cap. + if (frame.bytes !== undefined && maxBytesPerTrace !== Infinity) { + let retained = 0; + for (const f of trace.frames) retained += f.bytes?.length ?? 0; + while (retained > maxBytesPerTrace && trace.frames.length > 1) { + const [removed] = trace.frames.splice(1, 1); + retained -= removed?.bytes?.length ?? 0; + trace.truncated = true; + } } trace.lastAt = frame.timestamp; traces.set(key, trace); + current.set(baseKey, key); while (traces.size > maxTraces) { const oldest = traces.keys().next().value; if (oldest === undefined) break; + const evicted = traces.get(oldest); traces.delete(oldest); + evictedCount += 1; + // If the evicted op was an id's current, forget it so reuse starts clean. + if (evicted !== undefined) { + const bk = keyOf(evicted.channelId, evicted.requestId); + if (current.get(bk) === oldest) current.delete(bk); + } } try { @@ -240,16 +330,40 @@ export function createWireDebugger(options: WireDebuggerOptions = {}): WireDebug return { observe, traces: () => [...traces.values()], - trace: (requestId, channelId) => { - if (channelId !== undefined) return traces.get(keyOf(channelId, requestId)); - // No channel given: first trace matching this requestId in activity order. + trace: (requestId, channelId, generation) => { + // A specific generation (drill-down into one op of a recycled id). + if (generation !== undefined) { + for (const t of traces.values()) { + if ( + t.requestId === requestId && + t.generation === generation && + (channelId === undefined || t.channelId === channelId) + ) { + return t; + } + } + return undefined; + } + // The current (latest) generation for this id. + if (channelId !== undefined) { + const key = current.get(keyOf(channelId, requestId)); + return key !== undefined ? traces.get(key) : undefined; + } + // No channel given: the most recent op matching this requestId. Iterate in + // LRU order and keep the last match, so a reused id resolves to its latest op. + let match: WireTrace | undefined; for (const t of traces.values()) { - if (t.requestId === requestId) return t; + if (t.requestId === requestId) match = t; } - return undefined; + return match; }, tracesForChannel: (channelId) => [...traces.values()].filter((t) => t.channelId === channelId), - clear: () => traces.clear(), + evictedTraces: () => evictedCount, + clear: () => { + traces.clear(); + current.clear(); + evictedCount = 0; + }, }; } diff --git a/js/packages/truapi-host/src/worker-runtime.test.ts b/js/packages/truapi-host/src/worker-runtime.test.ts new file mode 100644 index 000000000..362643896 --- /dev/null +++ b/js/packages/truapi-host/src/worker-runtime.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; + +import { isLoopbackWsUrl } from "./worker-runtime.js"; + +describe("isLoopbackWsUrl", () => { + test("accepts ws:// on every genuine loopback form", () => { + expect(isLoopbackWsUrl("ws://localhost:9231")).toBe(true); + expect(isLoopbackWsUrl("ws://127.0.0.1:9231")).toBe(true); + expect(isLoopbackWsUrl("ws://127.5.6.7:9231")).toBe(true); + expect(isLoopbackWsUrl("ws://[::1]:9231")).toBe(true); + }); + + test("rejects wss:// — the tap is ws-only, matching the native sink", () => { + expect(isLoopbackWsUrl("wss://localhost:9231")).toBe(false); + expect(isLoopbackWsUrl("wss://127.0.0.1:9231")).toBe(false); + }); + + test("rejects non-ws schemes and non-loopback hosts", () => { + expect(isLoopbackWsUrl("http://127.0.0.1:9231")).toBe(false); + expect(isLoopbackWsUrl("ws://192.0.2.1:9231")).toBe(false); + expect(isLoopbackWsUrl("ws://example.com:9231")).toBe(false); + expect(isLoopbackWsUrl("not a url")).toBe(false); + }); +}); diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index 790e2833c..e9bd1f331 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -10,6 +10,7 @@ import type { WorkerToMain, } from "./worker-protocol.js"; import type { GenericError } from "@parity/truapi"; +import { TRUAPI_CODEC_VERSION, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; import { createWorkerRawCallbacks, type CallbackName, @@ -173,14 +174,22 @@ function toBase64(bytes: Uint8Array): string { * debugger only loses the trace, it can never throw into the frame path. */ /** - * Is `url` a WebSocket URL on a loopback host? The debug tap forwards raw frames + * Envelope version stamped on each frame, mirroring the debugger's + * `WIRE_ENVELOPE_VERSION`. Kept in sync by hand (a value constant, not a shared + * dep, to avoid truapi-host depending on the debugger package). + */ +const WIRE_ENVELOPE_VERSION = 1; + +/** + * Is `url` a `ws://` URL on a loopback host? The debug tap forwards raw frames * (including sensitive payloads, before the debugger's denylist runs), so it is - * loopback-only: refuse to stream them off the local machine. + * loopback-only: refuse to stream them off the local machine. `ws://` only, + * matching the native sink (`native_debug.rs`), which is also ws-only. */ -function isLoopbackWsUrl(url: string): boolean { +export function isLoopbackWsUrl(url: string): boolean { try { const u = new URL(url); - if (u.protocol !== "ws:" && u.protocol !== "wss:") return false; + if (u.protocol !== "ws:") return false; const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase(); return ( host === "localhost" || @@ -197,13 +206,26 @@ function isLoopbackWsUrl(url: string): boolean { function createDebuggerLink(url: string): { emit(channelId: string, dir: string, frame: Uint8Array): void; } { - // Loopback-only, dev-only: a non-loopback debugger URL yields an inert link - // rather than streaming frames across the network. - if (!isLoopbackWsUrl(url)) return { emit() {} }; + // Loopback-only, dev-only: a non-loopback (or non-ws://) debugger URL yields an + // inert link rather than streaming frames across the network. Warn so a + // mistyped value reads as "misconfigured", not "the debugger doesn't work". + if (!isLoopbackWsUrl(url)) { + console.warn( + `[truapi] wire debugger URL rejected (must be ws:// on a loopback host): ${url}`, + ); + return { emit() {} }; + } let socket: WebSocket | null = null; let open = false; const queue: string[] = []; + // Count *and* byte caps: each queued item is a base64 ProtocolMessage (storage + // writes, RPC responses - up to MBs each), so a count-only cap would let a slow + // or absent debugger buffer unbounded RSS on the observed session. Whichever + // ceiling hits first drops the frame (counted), never blocking the frame path. const MAX_QUEUE = 1000; + const MAX_QUEUE_BYTES = 8 * 1024 * 1024; + let queuedBytes = 0; + let droppedSinceSend = 0; function connect(): void { try { @@ -214,7 +236,24 @@ function createDebuggerLink(url: string): { } socket.addEventListener("open", () => { open = true; - for (const message of queue.splice(0)) send(message); + const pending = queue.splice(0); + queuedBytes = 0; + // Deliver drops accumulated while disconnected by stamping the count on the + // first drained frame - a bare marker without channelId/dir/frame wouldn't + // parse server-side. Drops only happen once the queue is full, so when the + // count is nonzero there is always a pending frame to carry it; if not, it + // rides the next live emit. + if (pending.length > 0 && droppedSinceSend > 0) { + try { + const first = JSON.parse(pending[0]) as Record; + first.dropped = droppedSinceSend; + pending[0] = JSON.stringify(first); + droppedSinceSend = 0; + } catch { + // Leave the frame as-is; the count rides the next live emit. + } + } + for (const message of pending) send(message); }); socket.addEventListener("close", () => { open = false; @@ -246,15 +285,57 @@ function createDebuggerLink(url: string): { connect(); + let warnedDrop = false; return { emit(channelId, dir, frame) { - const message = JSON.stringify({ channelId, dir, frame: toBase64(frame) }); - if (open && socket) { - send(message); - return; + // A debug tap must never throw into the observed frame path: toBase64 / + // JSON.stringify can raise on a pathological frame (btoa or V8 string-length + // limits), and only send() swallows its own errors. Losing a trace is fine; + // breaking dispatch is not. + try { + const base = { + v: WIRE_ENVELOPE_VERSION, + codec: TRUAPI_CODEC_VERSION, + schema: TRUAPI_WIRE_SCHEMA_HASH, + channelId, + dir, + frame: toBase64(frame), + }; + if (open && socket) { + // Piggyback any frames dropped while the link was down onto the next + // live frame, so the debugger attributes the gap to the link, not the + // host. + send( + droppedSinceSend > 0 + ? JSON.stringify({ ...base, dropped: droppedSinceSend }) + : JSON.stringify(base), + ); + droppedSinceSend = 0; + return; + } + const message = JSON.stringify(base); + if ( + queue.length < MAX_QUEUE && + queuedBytes + message.length <= MAX_QUEUE_BYTES + ) { + queue.push(message); + queuedBytes += message.length; + } else { + droppedSinceSend += 1; + if (!warnedDrop) { + // The link buffers a bounded backlog while the debugger is + // absent/slow; once full (by count or bytes), frames are dropped. + // Warn once so the gap is attributable to the link, not the host. + warnedDrop = true; + console.warn( + "[truapi] wire debugger link queue full — dropping frames until it drains", + ); + } + } + if (!socket) connect(); + } catch { + // Swallow: never let the tap disturb the frame path. } - if (queue.length < MAX_QUEUE) queue.push(message); - if (!socket) connect(); }, }; } diff --git a/js/packages/truapi/README.md b/js/packages/truapi/README.md index 3bf3dc955..079a59221 100644 --- a/js/packages/truapi/README.md +++ b/js/packages/truapi/README.md @@ -113,9 +113,12 @@ bytes }`, opaque bytes - to a separate debugger app, which decodes and groups th - The debugger app itself (trace + envelope-decode engines + the WS server): `@parity/truapi-debugger`. The generated `WIRE_DECODE_TABLE` on the `./wire-decode` subpath (raw SCALE bytes → typed value) -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. +stays here, since it is generated from this package's contract. It is the decode source the +[`@parity/truapi-debugger`](../truapi-debugger/) app uses for its opt-in, level-2 typed-value view: +payload decode is available in the debugger behind `TRUAPI_DEBUGGER_DECODE_VALUES` (off by default), +with sensitive frames excluded by the generated `SENSITIVE_FRAME_IDS` denylist. `@parity/truapi` +itself never decodes payloads — the envelope decode it does expose (`decodeWireMessage`: `requestId`, +frame id) carries no payload value. ## Wire format diff --git a/rust/crates/truapi-codegen/src/main.rs b/rust/crates/truapi-codegen/src/main.rs index 9fd3e0adb..59d877162 100644 --- a/rust/crates/truapi-codegen/src/main.rs +++ b/rust/crates/truapi-codegen/src/main.rs @@ -150,7 +150,16 @@ fn main() -> Result<()> { println!("Generated client examples in {path}"); } if let Some(path) = &cli.rust_output { - rust::generate(&api, path) + // The Rust routing table (wire_table.rs) is version-*unfiltered* - the + // native host can route any method the crate defines - so its stamp hashes + // the full/latest table, not the client-pinned subset. Otherwise a + // `--client-version`-pinned build would route a newer #[wire(sensitive)] + // frame under an older hash that a same-pinned debugger would accept and + // decode. At the default (latest) client version this equals the TS hash. + let schema_hash = + ts::wire_schema_hash(&api, ts::latest_wire_version(&api), cli.codec_version) + .context("computing wire schema hash")?; + rust::generate(&api, path, &schema_hash) .with_context(|| format!("writing Rust dispatcher to {}", path.display()))?; println!("Wrote Rust dispatcher to {}", path.display()); } diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index f3f156f76..120ab6287 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -23,11 +23,11 @@ pub use wasm_bridge::generate_wasm_bridge; pub use wire_table::generate_wire_table; /// Generates the Rust wire dispatcher and wire-table sources into `output_dir`. -pub fn generate(api: &ApiDefinition, output_dir: &Path) -> Result<()> { +pub fn generate(api: &ApiDefinition, output_dir: &Path, schema_hash: &str) -> Result<()> { fs::create_dir_all(output_dir)?; let dispatcher = generate_dispatcher(api)?; fs::write(output_dir.join("dispatcher.rs"), dispatcher)?; - let wire_table = generate_wire_table(api)?; + let wire_table = generate_wire_table(api, schema_hash)?; fs::write(output_dir.join("wire_table.rs"), wire_table)?; Ok(()) } @@ -279,7 +279,7 @@ mod tests { types: vec![], }; - let src = generate_wire_table(&api).expect("generate_wire_table"); + let src = generate_wire_table(&api, "testhash").expect("generate_wire_table"); let entries = parse_entries(&src); assert_eq!( entries, @@ -327,7 +327,7 @@ mod tests { "dispatcher missing prefixed Preimage const:\n{dispatcher}" ); - let table = generate_wire_table(&api).expect("wire_table"); + let table = generate_wire_table(&api, "testhash").expect("wire_table"); let entries = parse_entries(&table); assert!( entries @@ -368,7 +368,8 @@ mod tests { public_trait_order: vec!["Foo".to_string(), "FooBar".to_string()], types: vec![], }; - let err = generate_wire_table(&api).expect_err("duplicate wire method name must error"); + let err = generate_wire_table(&api, "testhash") + .expect_err("duplicate wire method name must error"); let msg = format!("{err}"); assert!( msg.contains("wire method name `foo_bar_baz` reused"), @@ -402,8 +403,8 @@ mod tests { let dispatcher_b = generate_dispatcher(&api).expect("dispatcher b"); assert_eq!(dispatcher_a, dispatcher_b); - let table_a = generate_wire_table(&api).expect("wire_table a"); - let table_b = generate_wire_table(&api).expect("wire_table b"); + let table_a = generate_wire_table(&api, "testhash").expect("wire_table a"); + let table_b = generate_wire_table(&api, "testhash").expect("wire_table b"); assert_eq!(table_a, table_b); } @@ -426,7 +427,7 @@ mod tests { public_trait_order: vec!["Permissions".to_string()], types: vec![], }; - let err = generate_wire_table(&api).expect_err("duplicate ids must error"); + let err = generate_wire_table(&api, "testhash").expect_err("duplicate ids must error"); let msg = format!("{err}"); assert!( msg.contains("wire id 10 reused"), @@ -481,7 +482,8 @@ mod tests { public_trait_order: vec!["Permissions".to_string()], types: vec![], }; - let err = generate_wire_table(&api).expect_err("request kind + start_id must error"); + let err = + generate_wire_table(&api, "testhash").expect_err("request kind + start_id must error"); let msg = format!("{err}"); assert!( msg.contains("must not use subscription wire ids"), @@ -504,7 +506,8 @@ mod tests { public_trait_order: vec!["Account".to_string()], types: vec![], }; - let err = generate_wire_table(&api).expect_err("subscription kind + request_id must error"); + let err = generate_wire_table(&api, "testhash") + .expect_err("subscription kind + request_id must error"); let msg = format!("{err}"); assert!( msg.contains("must not use request wire ids"), @@ -528,7 +531,8 @@ mod tests { public_trait_order: vec!["Permissions".to_string()], types: vec![], }; - let err = generate_wire_table(&api).expect_err("missing request_id annotation must error"); + let err = generate_wire_table(&api, "testhash") + .expect_err("missing request_id annotation must error"); let msg = format!("{err}"); assert!( msg.contains("missing #[wire(request_id"), @@ -551,7 +555,8 @@ mod tests { public_trait_order: vec!["Account".to_string()], types: vec![], }; - let err = generate_wire_table(&api).expect_err("missing start_id annotation must error"); + let err = generate_wire_table(&api, "testhash") + .expect_err("missing start_id annotation must error"); let msg = format!("{err}"); assert!( msg.contains("missing #[wire(start_id"), diff --git a/rust/crates/truapi-codegen/src/rust/wire_table.rs b/rust/crates/truapi-codegen/src/rust/wire_table.rs index 8696b5756..540322482 100644 --- a/rust/crates/truapi-codegen/src/rust/wire_table.rs +++ b/rust/crates/truapi-codegen/src/rust/wire_table.rs @@ -38,8 +38,9 @@ enum MethodEntry { Subscription(SubEntry), } -/// Emit the contents of `wire_table.rs`. -pub fn generate_wire_table(api: &ApiDefinition) -> Result { +/// Emit the contents of `wire_table.rs`. `schema_hash` is the wire-contract +/// fingerprint emitted as `TRUAPI_WIRE_SCHEMA_HASH`, identical to the TS client's. +pub fn generate_wire_table(api: &ApiDefinition, schema_hash: &str) -> Result { let mut method_entries: Vec<(String, MethodEntry)> = Vec::new(); let mut seen: BTreeMap = BTreeMap::new(); let mut seen_methods: BTreeMap = BTreeMap::new(); @@ -68,7 +69,7 @@ pub fn generate_wire_table(api: &ApiDefinition) -> Result { MethodEntry::Subscription(SubEntry { start_id, .. }) => *start_id, }); - render(&method_entries) + render(&method_entries, schema_hash) } fn method_entry(trait_def: &TraitDef, method: &MethodDef) -> Result { @@ -169,7 +170,7 @@ fn insert_entry( Ok(()) } -fn render(methods: &[(String, MethodEntry)]) -> Result { +fn render(methods: &[(String, MethodEntry)], schema_hash: &str) -> Result { let mut out = String::new(); writedoc!( out, @@ -225,6 +226,19 @@ fn render(methods: &[(String, MethodEntry)]) -> Result { ) .unwrap(); + writedoc!( + out, + r#" + /// Fingerprint of this build's wire contract: frame ids, method legs, + /// sensitivity, and codec version, identical to the TS client's + /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so + /// the debugger refuses to decode a frame whose contract differs from + /// its own, even when the coarse handshake codec version is unchanged. + pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "{schema_hash}"; + "# + ) + .unwrap(); + // Per-method consts: the single source of truth for each method's ids. for (name, entry) in methods { let konst = const_name(name); diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index dfab261ba..b446eda06 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -677,6 +677,60 @@ fn generate_wire_table(api: &ApiDefinition, target_version: u32) -> Result Result> { + let wrappers = collect_versioned_wrappers(api); + let mut seen: BTreeMap = BTreeMap::new(); + for trait_def in &api.traits { + for method in &trait_def.methods { + if !method_is_included(trait_def, method, &wrappers, target_version)? { + continue; + } + let wire_ids = wire_ids_for_method(trait_def, method)?; + for (id, tag) in wire_ids.entries(&method.name) { + if let Some((existing, _)) = seen.insert(id, (tag.clone(), method.wire.sensitive)) { + bail!("wire id {id} reused: `{existing}` and `{tag}` collide"); + } + } + } + } + Ok(seen + .into_iter() + .map(|(id, (tag, sensitive))| (id, tag, sensitive)) + .collect()) +} + +/// A stable fingerprint of the wire contract: every frame id, the method leg it +/// resolves to, and its sensitivity, folded together with the codec version. +/// Two builds whose frame tables differ - a reassigned id, a renamed or +/// added/removed method, or a flipped `#[wire(sensitive)]` - produce different +/// hashes even when the handshake `codec_version` is unchanged, which is the +/// case the coarse codec number cannot see. Emitted as `TRUAPI_WIRE_SCHEMA_HASH` +/// on both the TS and Rust sides so a host stamps it on every debug envelope and +/// the debugger refuses to decode a frame whose contract differs from its own. +pub(crate) fn wire_schema_hash( + api: &ApiDefinition, + target_version: u32, + codec_version: u8, +) -> Result { + let mut canonical = format!("codec={codec_version}\n"); + for (id, tag, sensitive) in wire_id_rows(api, target_version)? { + let flag = u8::from(sensitive); + canonical.push_str(&format!("{id}:{tag}:{flag}\n")); + } + // FNV-1a 64-bit: deterministic across platforms and Rust versions (unlike + // `DefaultHasher`), dependency-free, and ample for a contract fingerprint. + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in canonical.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + Ok(format!("{hash:016x}")) +} + fn method_is_included( trait_def: &TraitDef, method: &MethodDef, @@ -951,6 +1005,7 @@ fn generate_types(api: &ApiDefinition, target_version: u32) -> Result { fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) -> Result { validate_versioned_wrapper_shapes(api)?; + let schema_hash = wire_schema_hash(api, target_version, codec_version)?; let mut out = String::new(); writedoc!( out, @@ -969,6 +1024,7 @@ fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) export type {{ ObservableLike, ObservableSource, Observer, Result, Subscription, TrUApiTransport }}; export const TRUAPI_VERSION = {target_version} as const; export const TRUAPI_CODEC_VERSION = {codec_version} as const; + export const TRUAPI_WIRE_SCHEMA_HASH = "{schema_hash}" as const; function toSubscriptionError(error: unknown): SubscriptionError {{ if (error instanceof SubscriptionError) return error as SubscriptionError; diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index dccb9d61a..cd1df9b32 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -45,6 +45,12 @@ pub enum WireKind { /// Subscription method. Subscription(SubscriptionFrameIds), } +/// Fingerprint of this build's wire contract: frame ids, method legs, +/// sensitivity, and codec version, identical to the TS client's +/// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so +/// the debugger refuses to decode a frame whose contract differs from +/// its own, even when the coarse handshake codec version is unchanged. +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "06adc386fa1a18a3"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index dccb9d61a..cd1df9b32 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -45,6 +45,12 @@ pub enum WireKind { /// Subscription method. Subscription(SubscriptionFrameIds), } +/// Fingerprint of this build's wire contract: frame ids, method legs, +/// sensitivity, and codec version, identical to the TS client's +/// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so +/// the debugger refuses to decode a frame whose contract differs from +/// its own, even when the coarse handshake codec version is unchanged. +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "06adc386fa1a18a3"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 6b60f5351..2ce00ad68 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -89,6 +89,22 @@ impl FrameDirection { } } +/// Hand one event to a [`DebugSink`] without letting a misbehaving out-of-repo +/// implementation take down a live dispatch. +/// +/// The trait contract forbids `emit` from panicking, but the trait is `pub`, so +/// this guards the two in-path call sites: a panic is caught, logged, and +/// swallowed. `DebugEvent` is `UnwindSafe` (a `ChannelId`/`Vec`), so the +/// caught closure carries no broken invariant across the boundary. +fn emit_debug(sink: &dyn DebugSink, event: DebugEvent) { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + sink.emit(event); + })); + if result.is_err() { + tracing::error!("truapi debug sink panicked in emit; frame dropped, session unaffected"); + } +} + /// One observable host debug event. Frame bytes are the untouched /// `ProtocolMessage`; the debugger decodes them, so the core never does. The /// enum leaves room for host-internal events (e.g. SSO) that have no wire frame, @@ -945,11 +961,14 @@ impl ProductRuntime { // Tap inbound before decode, so a corrupt frame is still observed. if let Some((channel_id, debug)) = self.transport.debug() { - debug.emit(DebugEvent::Frame { - channel_id, - dir: FrameDirection::In, - bytes: frame.clone(), - }); + emit_debug( + debug.as_ref(), + DebugEvent::Frame { + channel_id, + dir: FrameDirection::In, + bytes: frame.clone(), + }, + ); } let message = ProtocolMessage::decode(&mut frame.as_slice()).map_err(|err| { @@ -1102,11 +1121,14 @@ impl Transport for SinkTransport { match self.debug() { Some((channel_id, debug)) => { self.sink.emit_frame(encoded.clone()); - debug.emit(DebugEvent::Frame { - channel_id, - dir: FrameDirection::Out, - bytes: encoded, - }); + emit_debug( + debug.as_ref(), + DebugEvent::Frame { + channel_id, + dir: FrameDirection::Out, + bytes: encoded, + }, + ); } None => self.sink.emit_frame(encoded), } @@ -1270,6 +1292,48 @@ mod tests { ); } + struct PanickingDebugSink; + + impl DebugSink for PanickingDebugSink { + fn emit(&self, _event: DebugEvent) { + panic!("misbehaving out-of-repo debug sink"); + } + } + + #[test] + fn a_panicking_debug_sink_does_not_take_down_the_dispatch() { + // The trait forbids panicking, but it is `pub`, so a bad out-of-repo sink + // could. `emit_debug` catches it: `receive_frame` must still succeed. + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + Arc::new(RecordingSink::default()), + ); + runtime.set_debug_sink( + ChannelId("myapp.dot".to_string()), + Arc::new(PanickingDebugSink), + ); + + let ids = subscription_ids("theme_subscribe").expect("known subscription"); + let raw = ProtocolMessage { + request_id: "theme:1".to_string(), + payload: Payload { + id: ids.start_id, + value: Vec::new(), + }, + } + .encode(); + // The inbound tap panics inside receive_frame; the guard swallows it. + let result = futures::executor::block_on(runtime.receive_frame(raw)); + assert!( + result.is_ok(), + "a panicking sink must not fail the dispatch" + ); + } + #[test] fn frame_direction_wire_str_is_product_vantage() { // The wire string is product-vantage (what the debugger and design doc diff --git a/rust/crates/truapi-server/src/native_debug.rs b/rust/crates/truapi-server/src/native_debug.rs index 89b1b1b4b..ec04ffd3e 100644 --- a/rust/crates/truapi-server/src/native_debug.rs +++ b/rust/crates/truapi-server/src/native_debug.rs @@ -21,7 +21,7 @@ //! the tap inert. use core::net::SocketAddr; -use core::sync::atomic::{AtomicU64, Ordering}; +use core::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use core::time::Duration; use std::sync::Arc; @@ -37,12 +37,28 @@ use tokio_tungstenite::client_async; use tokio_tungstenite::tungstenite::Message; use tracing::debug; +use crate::generated::wire_table::TRUAPI_WIRE_SCHEMA_HASH; use crate::host_core::{DebugEvent, DebugSink}; /// Bounded so a stalled or absent debugger applies backpressure as counted /// drops, never unbounded memory growth on the observed session. const QUEUE_CAPACITY: usize = 4096; +/// Byte budget alongside [`QUEUE_CAPACITY`]: one `ProtocolMessage` can be MBs, so +/// a count-only cap could still buffer unbounded RSS while the debugger is +/// absent. Whichever ceiling hits first drops the frame (counted), never blocks. +const MAX_QUEUE_BYTES: usize = 8 * 1024 * 1024; + +/// Envelope version, mirroring the debugger's `WIRE_ENVELOPE_VERSION` and the web +/// host's constant. Kept in sync by hand. +const WIRE_ENVELOPE_VERSION: u32 = 1; + +/// The host's wire codec version, mirroring `@parity/truapi`'s +/// `TRUAPI_CODEC_VERSION` (the handshake `codec_version`). Stamped on the +/// envelope so the debugger refuses to decode a frame whose codec differs from +/// its own, rather than resolving `u8` frame ids against the wrong contract. +const WIRE_CODEC_VERSION: u32 = 1; + /// Initial reconnect delay; doubles on each failed dial up to [`MAX_BACKOFF`]. const INITIAL_BACKOFF: Duration = Duration::from_millis(200); @@ -76,12 +92,17 @@ pub enum DebugSinkError { pub struct WsDebugSink { outbound: mpsc::Sender, dropped: Arc, + queued_bytes: Arc, } /// The wire envelope, matching the debugger's `parseWireMessage` / ingest /// `DebugFrameEnvelope`: `dir` is product-vantage, `frame` is base64 SCALE bytes. +/// `v`/`codec` are the identity the debugger checks before decoding. #[derive(Serialize)] struct WireMessage<'a> { + v: u32, + codec: u32, + schema: &'static str, #[serde(rename = "channelId")] channel_id: &'a str, dir: &'a str, @@ -130,13 +151,19 @@ impl WsDebugSink { let (outbound, inbox) = mpsc::channel::(QUEUE_CAPACITY); let dropped = Arc::new(AtomicU64::new(0)); + let queued_bytes = Arc::new(AtomicUsize::new(0)); tokio::spawn(writer_loop( url.to_string(), addr, inbox, Arc::clone(&dropped), + Arc::clone(&queued_bytes), )); - Ok(Arc::new(Self { outbound, dropped })) + Ok(Arc::new(Self { + outbound, + dropped, + queued_bytes, + })) } /// Number of frames dropped because the outbound queue was full (debugger @@ -154,6 +181,9 @@ impl DebugSink for WsDebugSink { bytes, } = event; let message = WireMessage { + v: WIRE_ENVELOPE_VERSION, + codec: WIRE_CODEC_VERSION, + schema: TRUAPI_WIRE_SCHEMA_HASH, channel_id: &channel_id.0, // Product-vantage string; never hand-mapped, so it cannot invert. dir: dir.wire_str(), @@ -163,8 +193,28 @@ impl DebugSink for WsDebugSink { self.dropped.fetch_add(1, Ordering::Relaxed); return; }; + // Byte budget on top of the channel's count cap: one frame can be MBs, so + // a count-only bound could still grow RSS without limit while the debugger + // is absent. Reserve the frame's bytes BEFORE handing the line to the + // channel: the writer task can recv and release (fetch_sub) the instant + // try_send succeeds, so adding *after* would let that sub run first and + // wrap the counter - an overflow panic in debug builds, on the frame path. + // Reserve atomically, then release on any failure. + let len = line.len(); + if self.queued_bytes.fetch_add(len, Ordering::Relaxed) + len > MAX_QUEUE_BYTES { + // This reservation pushed us past the budget: back it out and drop. + self.queued_bytes.fetch_sub(len, Ordering::Relaxed); + let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1; + debug!("truapi debug sink: byte budget full, frame dropped (total {dropped})"); + return; + } if self.outbound.try_send(line).is_err() { - self.dropped.fetch_add(1, Ordering::Relaxed); + // Not enqueued after all: release the reservation. The frame is lost + // (never the session); count it and log so the gap is attributable to + // the link, not to the host. + self.queued_bytes.fetch_sub(len, Ordering::Relaxed); + let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1; + debug!("truapi debug sink: outbound queue full, frame dropped (total {dropped})"); } } } @@ -176,6 +226,7 @@ async fn writer_loop( addr: SocketAddr, mut inbox: mpsc::Receiver, dropped: Arc, + queued_bytes: Arc, ) { let mut backoff = INITIAL_BACKOFF; loop { @@ -217,15 +268,20 @@ async fn writer_loop( loop { tokio::select! { queued = inbox.recv() => match queued { - Some(line) => match write.send(Message::Text(line)).await { - Ok(()) => backoff = INITIAL_BACKOFF, - Err(_) => { - debug!("truapi debug sink: socket closed, reconnecting"); - // The in-flight line is lost across this reconnect. - dropped.fetch_add(1, Ordering::Relaxed); - break; + Some(line) => { + // Off the queue now: release its bytes from the budget + // before the (moving) send so the counter can't drift. + queued_bytes.fetch_sub(line.len(), Ordering::Relaxed); + match write.send(Message::Text(line)).await { + Ok(()) => backoff = INITIAL_BACKOFF, + Err(_) => { + debug!("truapi debug sink: socket closed, reconnecting"); + // The in-flight line is lost across this reconnect. + dropped.fetch_add(1, Ordering::Relaxed); + break; + } } - }, + } // All senders dropped: the sink is gone, so is the host. Done. None => return, }, @@ -286,6 +342,10 @@ mod tests { let value: serde_json::Value = serde_json::from_str(&text).unwrap(); assert_eq!(value["channelId"], "myapp.dot"); + // Identity the debugger checks before decoding. + assert_eq!(value["v"], WIRE_ENVELOPE_VERSION); + assert_eq!(value["codec"], WIRE_CODEC_VERSION); + assert_eq!(value["schema"], TRUAPI_WIRE_SCHEMA_HASH); // Guard against re-inversion: In must serialize as product-vantage "out". assert_eq!(value["dir"], FrameDirection::In.wire_str()); assert_eq!(value["dir"], "out"); @@ -333,4 +393,30 @@ mod tests { "a full queue must count drops, not block" ); } + + #[tokio::test] + async fn byte_budget_drops_large_frames_before_the_count_cap() { + // Nothing listening: the writer never drains, so queued bytes accumulate. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let sink = WsDebugSink::connect(&format!("ws://127.0.0.1:{port}")).unwrap(); + // ~2 MiB per frame; a handful blows past the 8 MiB byte budget long before + // the 4096-frame count cap, so the BYTE cap is what drops here. Also + // exercises reserve-before-send: emit must never panic on the counter even + // as the writer task races it. + let big = vec![0u8; 2 * 1024 * 1024]; + for _ in 0..8 { + sink.emit(DebugEvent::Frame { + channel_id: ChannelId("myapp.dot".to_string()), + dir: FrameDirection::Out, + bytes: big.clone(), + }); + } + assert!( + sink.dropped() > 0, + "the byte budget must drop large frames well under the count cap" + ); + } } From 50475d36d4070bf7c4a698ae68d3775e17b5ea90 Mon Sep 17 00:00:00 2001 From: Nidish Date: Tue, 4 Aug 2026 18:55:01 +0530 Subject: [PATCH 08/17] feat(truapi-debugger): in-app embed for host-mounted panels --- .../truapi-debugger/src/in-app.test.ts | 81 +++++++++++++++ js/packages/truapi-debugger/src/in-app.ts | 98 +++++++++++++++++++ js/packages/truapi-debugger/src/index.ts | 2 + js/packages/truapi-debugger/tsconfig.json | 1 + 4 files changed, 182 insertions(+) create mode 100644 js/packages/truapi-debugger/src/in-app.test.ts create mode 100644 js/packages/truapi-debugger/src/in-app.ts diff --git a/js/packages/truapi-debugger/src/in-app.test.ts b/js/packages/truapi-debugger/src/in-app.test.ts new file mode 100644 index 000000000..4db9222ac --- /dev/null +++ b/js/packages/truapi-debugger/src/in-app.test.ts @@ -0,0 +1,81 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { encodeWireMessage } from "@parity/truapi"; +import * as W from "@parity/truapi/wire-table"; + +import { createInAppDebugger } from "./in-app.js"; + +// A minimal element stand-in — the mount only needs createElement, append, +// textContent/className/innerHTML, and remove(). No real DOM needed. +interface FakeEl { + textContent: string; + className: string; + innerHTML: string; + children: FakeEl[]; + append(...nodes: FakeEl[]): void; + remove(): void; +} +function fakeEl(): FakeEl { + return { + textContent: "", + className: "", + innerHTML: "", + children: [], + append(...nodes) { + this.children.push(...nodes); + }, + remove() {}, + }; +} + +function frameBytes(id: number, value: number[] = [0]): Uint8Array { + const r = encodeWireMessage({ + requestId: "p:1", + payload: { id, value: new Uint8Array(value) }, + }); + if (r.isErr()) throw r.error; + return r.value; +} + +describe("createInAppDebugger", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- shim a DOM + const g = globalThis as any; + const original = g.document; + beforeAll(() => { + g.document = { createElement: (): FakeEl => fakeEl() }; + }); + afterAll(() => { + g.document = original; + }); + + test("feeds frames in-process and mounts a payload-blind panel", () => { + const dbg = createInAppDebugger(); // decode OFF by default + + // Two frames of one op, fed exactly as dotli's tap would (raw SCALE bytes). + dbg.handleFrame("shop.dot", "out", frameBytes(W.ACCOUNT_GET_ACCOUNT.request)); + dbg.handleFrame("shop.dot", "in", frameBytes(W.ACCOUNT_GET_ACCOUNT.response)); + + expect(dbg.session.traceEngine.traces()).toHaveLength(1); + expect(dbg.session.decodeValues).toBe(false); // payload-blind by default + expect(dbg.session.revealSensitive).toBe(false); + + const el = fakeEl(); + const dispose = dbg.mount(el as unknown as HTMLElement); + const list = el.children[1]; // [style, list] + // Rendered by the shared renderer — the method resolved via the wire table. + expect(list.innerHTML).toContain("account.getAccount"); + dispose(); + expect(list.children).toHaveLength(0); + }); + + test("a sensitive op stays redacted with decode off", () => { + const dbg = createInAppDebugger(); + dbg.handleFrame("shop.dot", "out", frameBytes(W.SIGNING_SIGN_RAW.request, [1, 2])); + dbg.handleFrame("shop.dot", "in", frameBytes(W.SIGNING_SIGN_RAW.response)); + const view = dbg.session.traceEngine.traces()[0]; + expect(view).toBeDefined(); + // The signing op is on the type-driven denylist, so the session flags it. + expect( + dbg.session.frameDetail("p:1", 0, "shop.dot")?.kind, + ).not.toBe("decoded"); + }); +}); diff --git a/js/packages/truapi-debugger/src/in-app.ts b/js/packages/truapi-debugger/src/in-app.ts new file mode 100644 index 000000000..bc9489900 --- /dev/null +++ b/js/packages/truapi-debugger/src/in-app.ts @@ -0,0 +1,98 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * In-app mount: render the inspector from a {@link DebugSession} that lives in + * the SAME app as the host — no server, no dial-out, no relay. A host running in + * the page (dotli) feeds each tapped frame via {@link InAppDebugger.handleFrame}; + * {@link InAppDebugger.mount} renders them with the same engine, renderer, and + * type-driven denylist the standalone app uses, payload-blind by default. + * + * This is the "host and debugger in the same bits" transport: the frames never + * leave the app, so each browser tab is its own tenant — nothing to host or + * scope. Browser-only (uses `document`). + * + * @module + */ + +import { createDebugSession } from "./session.js"; +import type { DebugSession, DebugSessionOptions } from "./session.js"; +import { wireTraceToView } from "./trace-view.js"; +import { renderTraceDetail } from "./trace-render.js"; +import { detectRetryStorms } from "./retry-storm.js"; +import { TRACE_DETAIL_CSS } from "./trace-styles.js"; + +/** A same-app debugger: feed it frames, mount its panel. */ +export interface InAppDebugger { + /** The underlying session — grouped traces, per-frame decode gate. */ + readonly session: DebugSession; + /** + * Feed one tapped frame: the raw SCALE `ProtocolMessage` bytes, opaque. `dir` + * is product-vantage (`out` = left the product), matching the standalone tap. + */ + handleFrame(channelId: string, dir: "in" | "out", frame: Uint8Array): void; + /** + * Render a live, self-contained panel into `el` and keep it refreshed; returns + * a disposer that tears the panel down. Payload-blind unless the session was + * created with `decodeValues`. + */ + mount(el: HTMLElement, options?: { refreshMs?: number }): () => void; +} + +/** + * Create an in-app debugger. Decode stays OFF unless `decodeValues` is set (the + * reveal gate folds under it exactly as {@link createDebugSession} does), so a + * bundled mount is payload-blind by default. + */ +export function createInAppDebugger( + options: DebugSessionOptions = {}, +): InAppDebugger { + const session = createDebugSession(options); + return { + session, + handleFrame(channelId, dir, frame) { + session.handleEnvelope({ channelId, dir, frame }); + }, + mount(el, mountOptions = {}) { + const style = document.createElement("style"); + style.textContent = TRACE_DETAIL_CSS; + const list = document.createElement("div"); + list.className = "td-inapp"; + el.append(style, list); + + let disposed = false; + const render = (): void => { + if (disposed) return; + const traces = session.traceEngine.traces(); + const storms = detectRetryStorms(traces); + list.innerHTML = + traces.length === 0 + ? `
no frames yet
` + : traces + .map( + (trace) => + `
${renderTraceDetail( + wireTraceToView( + trace, + session.methodNames, + storms.get(trace) ?? [], + session.sensitiveIds, + ), + { + offerDecode: session.decodeValues, + offerReveal: session.revealSensitive, + }, + )}
`, + ) + .join(""); + }; + render(); + const timer = setInterval(render, mountOptions.refreshMs ?? 1000); + return () => { + disposed = true; + clearInterval(timer); + style.remove(); + list.remove(); + }; + }, + }; +} diff --git a/js/packages/truapi-debugger/src/index.ts b/js/packages/truapi-debugger/src/index.ts index 643651ed3..6f755529a 100644 --- a/js/packages/truapi-debugger/src/index.ts +++ b/js/packages/truapi-debugger/src/index.ts @@ -41,3 +41,5 @@ export type { RenderTraceDetailOptions } from "./trace-render.js"; export { detectRetryStorms } from "./retry-storm.js"; export type { RetryStormOptions } from "./retry-storm.js"; export { TRACE_DETAIL_CSS } from "./trace-styles.js"; +export { createInAppDebugger } from "./in-app.js"; +export type { InAppDebugger } from "./in-app.js"; diff --git a/js/packages/truapi-debugger/tsconfig.json b/js/packages/truapi-debugger/tsconfig.json index d9330dd38..caa17a6be 100644 --- a/js/packages/truapi-debugger/tsconfig.json +++ b/js/packages/truapi-debugger/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "ES2022", "module": "ES2022", + "lib": ["ES2022", "DOM"], "moduleResolution": "bundler", "composite": true, "declaration": true, From 51d914f2f2716d0ce04259a95e06763313d7390d Mon Sep 17 00:00:00 2001 From: Nidish Date: Wed, 5 Aug 2026 13:01:46 +0530 Subject: [PATCH 09/17] feat(truapi-debugger): decode every frame, drop the CLI --- js/packages/truapi-debugger/README.md | 49 +-- js/packages/truapi-debugger/package.json | 1 - js/packages/truapi-debugger/src/cli-client.ts | 125 ------ js/packages/truapi-debugger/src/cli.ts | 182 -------- .../truapi-debugger/src/decode.test.ts | 344 ++------------- js/packages/truapi-debugger/src/decode.ts | 184 +------- .../truapi-debugger/src/in-app.test.ts | 53 ++- js/packages/truapi-debugger/src/in-app.ts | 43 +- js/packages/truapi-debugger/src/index.ts | 2 +- js/packages/truapi-debugger/src/ingest.ts | 4 +- js/packages/truapi-debugger/src/repl.ts | 309 -------------- .../truapi-debugger/src/server.test.ts | 266 +++++++----- js/packages/truapi-debugger/src/server.ts | 401 ++++++------------ js/packages/truapi-debugger/src/session.ts | 92 ++-- .../truapi-debugger/src/trace-render.test.ts | 16 +- .../truapi-debugger/src/trace-render.ts | 109 +---- .../truapi-debugger/src/trace-styles.ts | 23 - .../truapi-debugger/src/trace-text.test.ts | 103 ----- js/packages/truapi-debugger/src/trace-text.ts | 175 -------- js/packages/truapi-debugger/src/trace-view.ts | 15 - .../truapi-debugger/src/wire-debugger.test.ts | 27 ++ .../truapi-debugger/src/wire-debugger.ts | 34 +- .../src/web/create-worker-host-runtime.ts | 14 +- js/packages/truapi/README.md | 10 +- rust/crates/truapi-codegen/src/rustdoc.rs | 5 +- rust/crates/truapi-codegen/src/ts.rs | 54 --- rust/crates/truapi-macros/src/lib.rs | 6 +- rust/crates/truapi-server/src/native_debug.rs | 3 +- 28 files changed, 580 insertions(+), 2069 deletions(-) delete mode 100644 js/packages/truapi-debugger/src/cli-client.ts delete mode 100644 js/packages/truapi-debugger/src/cli.ts delete mode 100644 js/packages/truapi-debugger/src/repl.ts delete mode 100644 js/packages/truapi-debugger/src/trace-text.test.ts delete mode 100644 js/packages/truapi-debugger/src/trace-text.ts diff --git a/js/packages/truapi-debugger/README.md b/js/packages/truapi-debugger/README.md index ade73a1c4..6c1dc0bb7 100644 --- a/js/packages/truapi-debugger/README.md +++ b/js/packages/truapi-debugger/README.md @@ -36,43 +36,34 @@ of in the product transport. traces (correlates with product-sdk telemetry spans on the same id). - **`createFrameDecoder(...)`** — the level-2 value decoder (see below): a gated, per-frame decode of a payload to a plain JS value, reusing `@parity/truapi`'s - generated `WIRE_DECODE_TABLE` behind a dev-only opt-in and a sensitive-method - denylist. + generated `WIRE_DECODE_TABLE`. A dev-only tool that decodes every frame it can, + with no sensitive special-casing. - **`startDebugServer(...)`** (`server.ts`) — the runnable app: a Bun WS+HTTP server. A host dials the WS and sends one text message per frame, `{ channelId, dir, frame }` with `frame` base64-encoded; `GET /traces` returns the grouped traces (payload-blind), `GET /frame?id=&i=` is the per-frame drill-down (see below), `GET /` serves the view. -## Value decode (level 2 — dev-only, off by default) +## Value decode (level 2 — dev-only, on by default) -By default the debugger is **payload-blind**: it groups frames and shows byte -lengths, never their contents. A separate, opt-in **level-2** capability can -decode a single frame's payload to a plain JS value in the drill-down detail -path. Its contract: +This is a **dev-only tool that decodes everything**. The list views stay +payload-blind — they group frames and show byte lengths, never their contents — +but the **level-2** drill-down decodes a single frame's payload to a plain JS +value, for every frame, with no "sensitive" special-casing. Its contract: -- **Off by default.** The server enables it only when - `TRUAPI_DEBUGGER_DECODE_VALUES` is truthy (`startDebugServer({ decodeValues })` - in code). With it off, every frame reports byte length only, and no bytes are - even retained. +- **On by default.** The server decodes unless + `TRUAPI_DEBUGGER_DECODE_VALUES` is set to a falsy value (`0`/`false`/`no`/`off`), + or `startDebugServer({ decodeValues: false })` in code — useful for a demo. + With decode off, every frame reports byte length only, and no bytes are even + retained. - **Reuses the generated table.** Decoding is `WIRE_DECODE_TABLE[frameId]?.(bytes)` from `@parity/truapi/wire-decode` — the same dev-only codecs the client uses. The debugger writes none of its own. -- **Sensitive denylist.** The generated table decodes *every* frame, including - signing and login. The security of this feature is the denylist layered on - top: the generated `SENSITIVE_FRAME_IDS` set in `@parity/truapi/wire-table`, - emitted from every method marked `#[wire(..., sensitive)]` on the Rust trait — - so sensitivity is a property of the payload type, and a codegen rename cannot - silently drop a family. It covers **signing/\*** (create-transaction, sign-raw, - sign-payload, and their legacy variants), **\*create\*proof\*** (account + - statement-store, incl. authorized), **entropy/derive**, **SSO/login + - get-user-id**, **local-storage read/write** (`clear` carries only a key name, - so it stays decodable), **payment/top-up**, - **coin-payment create-cheque/deposit/listen-for-payment**, and - **statement-store subscribe/submit**. A sensitive frame is never decoded — it - reports its byte length labelled `redacted: sensitive method`, even with the - toggle on. A fail-closed content check (any secret-named field in a decoded - value) backs it up for any secret-bearing method that was never annotated. +- **No redaction, no reveal toggle.** Every frame the table can decode is + decoded, including signing, login, and payment. A developer inspecting their + own session's traffic sees the real values; there is no denylist, no reveal + escape hatch, and no `redacted` state. A frame renders either its decoded value + or, when it has no codec / no retained bytes / fails to decode, its byte length. - **Never over the wire, never in `/traces`.** The host still emits opaque bytes only; nothing about decode changes what it sends. `/traces` never serializes raw bytes or decoded values. Decode happens only in the debugger, only in the @@ -83,10 +74,10 @@ path. Its contract: ```bash npm install # links @parity/truapi via the workspace npm run build # tsc -b -npm run serve # bun run src/server.ts — listens on :9231 +npm run serve # bun run src/server.ts — listens on :9231, decodes by default -# opt into level-2 value decode (dev machines only) -TRUAPI_DEBUGGER_DECODE_VALUES=1 npm run serve +# turn value decode off for a demo +TRUAPI_DEBUGGER_DECODE_VALUES=0 npm run serve ``` Point a host's debugger URL at `ws://:9231` (the host dials out), diff --git a/js/packages/truapi-debugger/package.json b/js/packages/truapi-debugger/package.json index f6e800693..3f1a2826b 100644 --- a/js/packages/truapi-debugger/package.json +++ b/js/packages/truapi-debugger/package.json @@ -13,7 +13,6 @@ "build": "tsc -b", "typecheck": "tsc -b", "serve": "bun run src/server.ts", - "view": "bun run src/cli.ts", "test": "bun test" }, "devDependencies": { diff --git a/js/packages/truapi-debugger/src/cli-client.ts b/js/packages/truapi-debugger/src/cli-client.ts deleted file mode 100644 index aa08ee543..000000000 --- a/js/packages/truapi-debugger/src/cli-client.ts +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright 2026 Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: MIT -/** - * Shared client for the terminal frontends (the one-shot {@link module:cli} - * commands and the interactive {@link module:repl}). Reads a running debugger's - * HTTP endpoints and rebuilds the shared {@link TraceView} model, so both - * frontends agree with the web inspector on ops, badges, sensitivity, and what - * may be decoded - one engine, one denylist, no forks. - * - * @module - */ - -import { SENSITIVE_FRAME_IDS, type FrameValueDetail } from "./decode.js"; -import type { FrameRole } from "./observed-frame.js"; -import { - buildTraceView, - type TraceBadge, - type TraceView, - type TraceViewInput, -} from "./trace-view.js"; -import type { CliStats } from "./trace-text.js"; - -/** The sensitive denylist, resolved once from the generated wire-table. */ -export const sensitiveIds = SENSITIVE_FRAME_IDS; - -/** One frame as `/traces` serializes it (payload-blind: no bytes, no values). */ -export interface TracesFrame { - direction: "out" | "in"; - frameId: number; - method?: string; - role: string; - byteLength?: number; - timestamp: number; -} -/** One op as `/traces` serializes it. */ -export interface TracesEntry { - channelId: string; - requestId: string; - /** Which reuse of `(channelId, requestId)` this op is; see {@link TraceView.generation}. */ - generation?: number; - startedAt: number; - lastAt: number; - /** Op-level badges the server computed (incl. the cross-op retry-storm). */ - badges?: TraceBadge[]; - frames: TracesFrame[]; -} -/** One host as `/channels` reports it. */ -export interface ChannelInfo { - channelId: string; - connected: boolean; - frameCount: number; -} - -export type { CliStats, FrameValueDetail }; - -/** Rebuild the shared view model from a payload-blind `/traces` entry. */ -export function toView(entry: TracesEntry): TraceView { - const input: TraceViewInput = { - requestId: entry.requestId, - channelId: entry.channelId, - generation: entry.generation, - startedAt: entry.startedAt, - lastAt: entry.lastAt, - // Cross-op badges (retry-storm) are computed server-side and passed through, - // so the CLI shows the same badges as the web inspector without recomputing. - extraBadges: entry.badges, - frames: entry.frames.map((f) => ({ - direction: f.direction, - // `/traces` role strings come straight off the engine's FrameRole union. - role: f.role as FrameRole, - method: f.method, - frameId: f.frameId, - byteLength: f.byteLength, - timestamp: f.timestamp, - decodable: false, - sensitive: sensitiveIds.has(f.frameId), - })), - }; - return buildTraceView(input); -} - -export { viewMethod } from "./trace-view.js"; - -/** A thin HTTP client over a running debugger server. */ -export interface DebuggerClient { - readonly host: string; - traces(): Promise; - stats(channel: string | null): Promise; - channels(): Promise; - /** - * The gated per-frame drill-down. `reveal` is honored only when the server - * armed `TRUAPI_DEBUGGER_REVEAL_SENSITIVE`; otherwise a sensitive frame still - * comes back redacted - the guarantee lives server-side, not here. - */ - frame( - requestId: string, - seq: number, - channel: string | null, - reveal: boolean, - ): Promise; -} - -/** Build a {@link DebuggerClient} for `host` (e.g. `http://localhost:9231`). */ -export function createDebuggerClient(host: string): DebuggerClient { - const getJson = async (path: string): Promise => { - const res = await fetch(host + path); - if (!res.ok) throw new Error(`${host}${path} → HTTP ${String(res.status)}`); - return res.json() as Promise; - }; - const channelQuery = (channel: string | null): string => - channel ? `?channel=${encodeURIComponent(channel)}` : ""; - return { - host, - traces: () => getJson("/traces"), - stats: (channel) => getJson(`/stats${channelQuery(channel)}`), - channels: async () => - (await getJson<{ channels: ChannelInfo[] }>("/channels")).channels, - frame: (requestId, seq, channel, reveal) => { - const p = new URLSearchParams({ id: requestId, i: String(seq) }); - if (channel) p.set("channel", channel); - if (reveal) p.set("reveal", "1"); - return getJson(`/frame?${p.toString()}`); - }, - }; -} diff --git a/js/packages/truapi-debugger/src/cli.ts b/js/packages/truapi-debugger/src/cli.ts deleted file mode 100644 index 766f553d0..000000000 --- a/js/packages/truapi-debugger/src/cli.ts +++ /dev/null @@ -1,182 +0,0 @@ -// Copyright 2026 Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: MIT -/** - * `truapi-debugger` terminal frontend: look at wire traces from a shell, for - * headless / SSH / CI workflows where the web inspector isn't reachable. - * - * Two frontends over one running debugger (`:9231` by default), sharing the same - * {@link module:cli-client} engine and the same sensitive denylist as the web - * inspector - no forked engine, no forked denylist: - * - * - `ui` / `repl` (default in a terminal): the interactive query {@link module:repl} - * - a prompt you keep querying: ls, filter, sort, use , show, reveal. - * - `ls` / `stats` / `show` / `tail`: one-shot commands for scripting + piping. - * - * Usage (from js/packages/truapi-debugger): - * bun run src/cli.ts # interactive query REPL - * bun run src/cli.ts ls # ops + aggregate summary - * bun run src/cli.ts stats # just the aggregate line - * bun run src/cli.ts show p:4 --reveal # one op's frames + decoded values - * bun run src/cli.ts tail # live view, refreshes each second - * Flags: --host http://localhost:9231 · --channel · --reveal · --interval - * - * @module - */ - -import { - createDebuggerClient, - toView, - type FrameValueDetail, - type TracesEntry, -} from "./cli-client.js"; -import { formatOpDetail, formatOpRow, formatStats } from "./trace-text.js"; -import { runRepl } from "./repl.js"; - -interface ParsedArgs { - cmd: string; - positional: string[]; - flags: Record; -} - -/** Flags that take a following value; everything else is a boolean flag. */ -const VALUE_FLAGS = new Set(["host", "channel", "interval"]); - -function parseArgs(argv: string[]): ParsedArgs { - const flags: Record = {}; - const positional: string[] = []; - let cmd = ""; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a.startsWith("--")) { - const key = a.slice(2); - const next = argv[i + 1]; - // Only value-flags consume the next token; a boolean flag (e.g. --reveal) - // leaves it as a positional, so `show --reveal p:4` parses correctly. - if (VALUE_FLAGS.has(key) && next !== undefined && !next.startsWith("--")) { - flags[key] = next; - i++; - } else { - flags[key] = true; - } - } else if (cmd === "") { - cmd = a; - } else { - positional.push(a); - } - } - // No command in an interactive terminal → the query REPL; otherwise the list. - if (cmd === "") cmd = process.stdout.isTTY ? "ui" : "ls"; - return { cmd, positional, flags }; -} - -const args = parseArgs(process.argv.slice(2)); -// A bare `--host`/`--channel` (no value) parses as boolean `true`; take only a -// real string value as provided, otherwise fall back rather than coerce garbage. -const flagValue = (v: string | boolean | undefined): string | undefined => - typeof v === "string" ? v : undefined; -const host = - flagValue(args.flags.host) ?? - process.env.TRUAPI_DEBUGGER_HTTP ?? - "http://localhost:9231"; -const channel = flagValue(args.flags.channel) ?? null; -const reveal = args.flags.reveal === true || args.flags.reveal === "1"; -const client = createDebuggerClient(host); - -async function traces(): Promise { - const all = await client.traces(); - return channel === null ? all : all.filter((t) => t.channelId === channel); -} - -async function cmdStats(): Promise { - console.log(formatStats(await client.stats(channel))); -} - -async function cmdLs(): Promise { - const [stats, entries] = await Promise.all([client.stats(channel), traces()]); - console.log(formatStats(stats)); - console.log(""); - if (entries.length === 0) console.log(" (no operations yet)"); - // Unscoped view: show the channel so same-id ops from two hosts are distinct. - for (const t of entries) console.log(formatOpRow(toView(t), channel === null)); -} - -async function cmdShow(): Promise { - const id = args.positional[0]; - if (id === undefined) { - console.error("usage: show [--reveal] [--channel ]"); - process.exit(1); - } - const entry = (await traces()).find((t) => t.requestId === id); - if (entry === undefined) { - console.error(`no operation with requestId ${id}`); - process.exit(1); - } - const view = toView(entry); - if (reveal) { - // The one-shot reveal is a deliberate, non-interactive scripting path (the - // interactive REPL uses a typed `reveal ` + `yes` confirm instead). Warn - // up front as the REPL does; the server still only honors reveal when armed. - console.error( - "\x1b[31m⚠ revealing SENSITIVE payloads\x1b[0m\x1b[2m — output may contain a private key, signature, or credential; do NOT run this while screen-sharing or recording. Honored only on a server armed with TRUAPI_DEBUGGER_REVEAL_SENSITIVE.\x1b[0m", - ); - } - const decoded = new Map(); - for (const f of view.frames) { - try { - decoded.set( - f.seq, - await client.frame(entry.requestId, f.seq, entry.channelId, reveal), - ); - } catch { - // Leave the frame value-less; the row still renders. - } - } - console.log(formatOpDetail(view, decoded)); -} - -async function cmdTail(): Promise { - const interval = Number(args.flags.interval ?? 1000); - const render = async (): Promise => { - const [stats, entries] = await Promise.all([client.stats(channel), traces()]); - process.stdout.write("\x1b[2J\x1b[H"); - console.log(formatStats(stats)); - console.log(""); - for (const t of entries.slice(-40)) console.log(formatOpRow(toView(t))); - console.log( - `\n\x1b[2mwatching ${host}${channel ? ` · ${channel}` : ""} — Ctrl-C to stop\x1b[0m`, - ); - }; - await render(); - setInterval(() => { - render().catch((e: unknown) => { - console.error(e instanceof Error ? e.message : String(e)); - }); - }, interval); -} - -async function cmdUi(): Promise { - await runRepl(client, channel); -} - -const commands: Record Promise> = { - ui: cmdUi, - repl: cmdUi, - stats: cmdStats, - ls: cmdLs, - ops: cmdLs, - show: cmdShow, - tail: cmdTail, - watch: cmdTail, -}; - -const run = commands[args.cmd]; -if (run === undefined) { - console.error( - `unknown command: ${args.cmd}\ncommands: ui · stats · ls · show · tail`, - ); - process.exit(1); -} -run().catch((e: unknown) => { - console.error(e instanceof Error ? e.message : String(e)); - process.exit(1); -}); diff --git a/js/packages/truapi-debugger/src/decode.test.ts b/js/packages/truapi-debugger/src/decode.test.ts index 3c7d78c38..857481910 100644 --- a/js/packages/truapi-debugger/src/decode.test.ts +++ b/js/packages/truapi-debugger/src/decode.test.ts @@ -3,11 +3,7 @@ import { describe, expect, test } from "bun:test"; import * as W from "@parity/truapi/wire-table"; import { WIRE_DECODE_TABLE } from "@parity/truapi/wire-decode"; -import { - createFrameDecoder, - SENSITIVE_FRAME_IDS, - type FrameValueDetail, -} from "./decode.js"; +import { createFrameDecoder, type FrameValueDetail } from "./decode.js"; import type { ObservedFrame } from "./observed-frame.js"; /** A minimal observed frame for a given id/bytes; the fields decode ignores are stubbed. */ @@ -24,111 +20,10 @@ function frame(frameId: number, bytes?: Uint8Array): ObservedFrame { }; } -describe("sensitive denylist from the generated wire-table", () => { - // Authoritative denylist: the generated SENSITIVE_FRAME_IDS set, emitted by - // truapi-codegen from every `#[wire(..., sensitive)]` method on the Rust trait. - const sensitive = SENSITIVE_FRAME_IDS; - - test("re-exports the generated SENSITIVE_FRAME_IDS set verbatim", () => { - expect(sensitive).toBe(W.SENSITIVE_FRAME_IDS); - }); - - // Every id of each sensitive family must be present (both request/response, - // both start/receive), so neither leg of a sensitive op can be decoded. - const mustExclude: Record> = { - "signing/create-transaction": Object.values(W.SIGNING_CREATE_TRANSACTION), - "signing/sign-raw": Object.values(W.SIGNING_SIGN_RAW), - "signing/sign-payload": Object.values(W.SIGNING_SIGN_PAYLOAD), - "signing/sign-raw-legacy": Object.values( - W.SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT, - ), - "account/create-proof": Object.values(W.ACCOUNT_CREATE_ACCOUNT_PROOF), - "statement-store/create-proof": Object.values(W.STATEMENT_STORE_CREATE_PROOF), - "statement-store/create-proof-authorized": Object.values( - W.STATEMENT_STORE_CREATE_PROOF_AUTHORIZED, - ), - "entropy/derive": Object.values(W.ENTROPY_DERIVE), - "account/request-login": Object.values(W.ACCOUNT_REQUEST_LOGIN), - "account/get-user-id": Object.values(W.ACCOUNT_GET_USER_ID), - "account/sign-vrf": Object.values(W.ACCOUNT_SIGN_VRF), - "local-storage/read": Object.values(W.LOCAL_STORAGE_READ), - "local-storage/write": Object.values(W.LOCAL_STORAGE_WRITE), - // Payment payloads carrying key material / bearer secrets (C1/M2). - "payment/top-up": Object.values(W.PAYMENT_TOP_UP), - "coin-payment/create-cheque": Object.values(W.COIN_PAYMENT_CREATE_CHEQUE), - "coin-payment/deposit": Object.values(W.COIN_PAYMENT_DEPOSIT), - "coin-payment/listen-for-payment": Object.values( - W.COIN_PAYMENT_LISTEN_FOR_PAYMENT, - ), - // Statement-store subscribe/submit carry SignedStatement.decryptionKey. - "statement-store/subscribe": Object.values(W.STATEMENT_STORE_SUBSCRIBE), - "statement-store/submit": Object.values(W.STATEMENT_STORE_SUBMIT), - }; - for (const [name, ids] of Object.entries(mustExclude)) { - test(`excludes ${name}`, () => { - for (const id of ids) expect(sensitive.has(id)).toBe(true); - }); - } - - // Non-sensitive families stay decodable: chain reads, account reads, payments. - // local-storage/clear is deliberately decodable — its request is just a key - // name and its response is empty, so unlike read/write it carries no secret. - const mustAllow: Record> = { - "local-storage/clear": Object.values(W.LOCAL_STORAGE_CLEAR), - "account/get-account": Object.values(W.ACCOUNT_GET_ACCOUNT), - "account/connection-status": Object.values( - W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE, - ), - "chain/call-head": Object.values(W.CHAIN_CALL_HEAD), - "chain/broadcast-transaction": Object.values(W.CHAIN_BROADCAST_TRANSACTION), - "payment/request": Object.values(W.PAYMENT_REQUEST), - }; - for (const [name, ids] of Object.entries(mustAllow)) { - test(`allows ${name}`, () => { - for (const id of ids) expect(sensitive.has(id)).toBe(false); - }); - } -}); - -describe("gated frame decoder (real table + denylist)", () => { - test("a signing frame does NOT decode even with the toggle on", () => { - const decoder = createFrameDecoder({ enabled: true }); - const detail = decoder.detail( - frame(W.SIGNING_SIGN_RAW.request, new Uint8Array([1, 2, 3, 4])), - ); - expect(detail.kind).toBe("redacted"); - if (detail.kind === "redacted") { - expect(detail.reason).toBe("sensitive method"); - expect(detail.byteLength).toBe(4); - } - }); - - test("every signing family id redacts, never decodes", () => { - const decoder = createFrameDecoder({ enabled: true }); - for (const id of [ - ...Object.values(W.SIGNING_CREATE_TRANSACTION), - ...Object.values(W.SIGNING_SIGN_PAYLOAD), - ...Object.values(W.ACCOUNT_CREATE_ACCOUNT_PROOF), - ...Object.values(W.ENTROPY_DERIVE), - ...Object.values(W.ACCOUNT_REQUEST_LOGIN), - ]) { - const detail = decoder.detail(frame(id, new Uint8Array([0, 0]))); - expect(detail.kind).toBe("redacted"); - } - }); - - test("payment.topUp redacts (never decodes a raw private key) with toggle on (C1)", () => { - const decoder = createFrameDecoder({ enabled: true }); - for (const id of Object.values(W.PAYMENT_TOP_UP)) { - expect(decoder.detail(frame(id, new Uint8Array([0, 0]))).kind).toBe( - "redacted", - ); - } - }); - +describe("frame decoder (real table) — decodes everything, no special-casing", () => { test("a non-sensitive frame decodes only with the toggle on", () => { // `connection-status.subscribe` start payload is `V1(void)` = a single 0x00 - // index byte: a real, non-sensitive frame the generated table can decode. + // index byte: a real frame the generated table can decode. const id = W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start; const bytes = new Uint8Array([0]); @@ -144,6 +39,19 @@ describe("gated frame decoder (real table + denylist)", () => { expect(typeof WIRE_DECODE_TABLE[id]).toBe("function"); }); + test("a formerly-'sensitive' signing frame decodes too (dev-only tool)", () => { + // No denylist any more: a signing request decodes like every other frame. + const decoder = createFrameDecoder({ enabled: true }); + const detail = decoder.detail( + frame(W.SIGNING_SIGN_RAW.request, new Uint8Array([0])), + ); + // It either decodes (id has a codec + valid bytes) or, on a codec throw for + // the stub bytes, falls back to bytes — never a "redacted" state. + expect(["decoded", "bytes"]).toContain(detail.kind); + // Whatever the outcome, the kind is never the old "redacted" variant. + expect(detail.kind).not.toBe("redacted"); + }); + test("disabled decoder is bytes-only for every frame", () => { const decoder = createFrameDecoder({ enabled: false }); for (const id of [ @@ -156,16 +64,11 @@ describe("gated frame decoder (real table + denylist)", () => { }); }); -describe("gated frame decoder (injected table for gating isolation)", () => { +describe("frame decoder (injected table)", () => { const table = { 999: (b: Uint8Array) => ({ ok: Array.from(b) }) }; - const sensitiveIds = new Set([7]); - test("decodes a non-sensitive id when enabled and bytes present", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: table, - sensitiveIds, - }); + test("decodes an id when enabled and bytes present", () => { + const decoder = createFrameDecoder({ enabled: true, decodeTable: table }); const detail = decoder.detail(frame(999, new Uint8Array([1, 2]))); expect(detail).toEqual({ kind: "decoded", @@ -173,24 +76,20 @@ describe("gated frame decoder (injected table for gating isolation)", () => { } satisfies FrameValueDetail); }); - test("redacts a sensitive id before ever touching the table", () => { - let called = false; + test("decodes a secret-named field too — no content guard withholds it", () => { const decoder = createFrameDecoder({ enabled: true, - decodeTable: { 7: () => ((called = true), "leaked") }, - sensitiveIds, + decodeTable: { 999: () => ({ source: { sr25519SecretKey: "0xdead" } }) }, }); - const detail = decoder.detail(frame(7, new Uint8Array([1, 2, 3]))); - expect(detail.kind).toBe("redacted"); - expect(called).toBe(false); + const detail = decoder.detail(frame(999, new Uint8Array([1]))); + expect(detail.kind).toBe("decoded"); + if (detail.kind === "decoded") { + expect(detail.value).toEqual({ source: { sr25519SecretKey: "0xdead" } }); + } }); test("falls back to bytes when the frame retained no bytes", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: table, - sensitiveIds, - }); + const decoder = createFrameDecoder({ enabled: true, decodeTable: table }); expect(decoder.detail(frame(999)).kind).toBe("bytes"); }); @@ -202,195 +101,12 @@ describe("gated frame decoder (injected table for gating isolation)", () => { throw new Error("bad payload"); }, }, - sensitiveIds, }); expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe("bytes"); }); - test("content guard redacts a decoded value carrying a secret-named field", () => { - // A non-denylisted id whose decoded payload nonetheless carries key material - // (the C1/H1 class): the fail-closed content check must redact it. - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { - 999: () => ({ source: { PrivateKey: { sr25519SecretKey: "0xdead" } } }), - }, - sensitiveIds: new Set(), - }); - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "redacted", - ); - }); - - test("content guard redacts encryptedSecrets (cheque bearer material)", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { 999: () => ({ cheque: { encryptedSecrets: "0xbeef" } }) }, - sensitiveIds: new Set(), - }); - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "redacted", - ); - }); - - test("content guard redacts a decryptionKey (statement key material)", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { - 999: () => ({ statements: [{ decryptionKey: "0xc0ffee" }] }), - }, - sensitiveIds: new Set(), - }); - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "redacted", - ); - }); - - test("content guard redacts a generically-named credential field", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { 999: () => ({ auth: { sessionToken: "0xabc" } }) }, - sensitiveIds: new Set(), - }); - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "redacted", - ); - }); - - test("content guard still decodes a public identifier (publicKey)", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { 999: () => ({ account: { publicKey: "0x01" } }) }, - sensitiveIds: new Set(), - }); - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "decoded", - ); - }); - - test("content guard allows a benign value with no secret-named field", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { 999: () => ({ account: { address: "0x01" }, amount: 5 }) }, - sensitiveIds: new Set(), - }); - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "decoded", - ); - }); - - test("content guard terminates on a cyclic / shared-DAG value (no blowup)", () => { - // The pre-visited-set guard hung on exactly this shape (a cycle with two - // back-edges + shared substructure). If it regresses to exponential, this - // test hangs instead of passing - which is the signal we want. - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { - 999: () => { - const a: Record = {}; - const b: Record = { a }; - a.b = b; - a.self = a; - return { a, b, both: [a, b, a, b] }; - }, - }, - sensitiveIds: new Set(), - }); - // Benign field names ⇒ decodes (and, crucially, returns promptly). - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "decoded", - ); - }); - - test("content guard still redacts a secret nested inside a cyclic value", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: { - 999: () => { - const a: Record = { secretKey: "0xdead" }; - const b: Record = { a }; - a.b = b; - return { a, b }; - }, - }, - sensitiveIds: new Set(), - }); - expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe( - "redacted", - ); - }); -}); - -describe("sensitive reveal escape hatch (dev-only, safe by default)", () => { - const table = { 7: (b: Uint8Array) => ({ secretKey: Array.from(b) }) }; - const sensitiveIds = new Set([7]); - - test("with reveal capability OFF, an explicit reveal request is ignored", () => { - const decoder = createFrameDecoder({ - enabled: true, - decodeTable: table, - sensitiveIds, - // revealSensitive omitted → off - }); - const detail = decoder.detail(frame(7, new Uint8Array([1, 2])), { - reveal: true, - }); - expect(detail.kind).toBe("redacted"); - }); - - test("with reveal capability ON but no explicit request, sensitive still redacts", () => { - const decoder = createFrameDecoder({ - enabled: true, - revealSensitive: true, - decodeTable: table, - sensitiveIds, - }); - // Default call (no reveal) — the safe default must still hold. - expect(decoder.detail(frame(7, new Uint8Array([1, 2]))).kind).toBe( - "redacted", - ); - }); - - test("with reveal capability ON and an explicit request, a sensitive frame decodes and is marked", () => { - const decoder = createFrameDecoder({ - enabled: true, - revealSensitive: true, - decodeTable: table, - sensitiveIds, - }); - const detail = decoder.detail(frame(7, new Uint8Array([1, 2])), { - reveal: true, - }); - expect(detail).toEqual({ - kind: "decoded", - value: { secretKey: [1, 2] }, - sensitive: true, - } satisfies FrameValueDetail); - }); - - test("an explicit reveal also bypasses the content guard for a non-denylisted frame", () => { - const decoder = createFrameDecoder({ - enabled: true, - revealSensitive: true, - decodeTable: { 999: () => ({ auth: { sessionToken: "0xabc" } }) }, - sensitiveIds: new Set(), - }); - const detail = decoder.detail(frame(999, new Uint8Array([1])), { - reveal: true, - }); - expect(detail.kind).toBe("decoded"); - if (detail.kind === "decoded") expect(detail.sensitive).toBe(true); - }); - - test("the master gate still wins: reveal armed but decode disabled ⇒ bytes only", () => { - const decoder = createFrameDecoder({ - enabled: false, - revealSensitive: true, - decodeTable: table, - sensitiveIds, - }); - expect(decoder.detail(frame(7, new Uint8Array([1, 2])), { reveal: true }).kind).toBe( - "bytes", - ); + test("falls back to bytes when the id has no codec", () => { + const decoder = createFrameDecoder({ enabled: true, decodeTable: table }); + expect(decoder.detail(frame(1, new Uint8Array([1]))).kind).toBe("bytes"); }); }); diff --git a/js/packages/truapi-debugger/src/decode.ts b/js/packages/truapi-debugger/src/decode.ts index 0110493f9..5a9c2c51b 100644 --- a/js/packages/truapi-debugger/src/decode.ts +++ b/js/packages/truapi-debugger/src/decode.ts @@ -2,25 +2,19 @@ // SPDX-License-Identifier: MIT /** * Level-2 decode: turn a frame's raw SCALE payload into a plain JS value, in the - * drill-down detail path only, behind a dev-only opt-in. + * drill-down detail path. * * This is the one place the debugger looks *inside* a frame. Everything else - * the trace engine, `/traces`, the host tap - is payload-blind and stays that - * way. The rules that make that safe live here: + * way. The rules that make that work live here: * - * - **Off by default.** With the decoder disabled every frame reports its byte - * length and nothing else; no payload is ever inspected. + * - **Dev-only tool: decode everything.** This debugger decodes every frame it + * can, with no "sensitive" special-casing. A developer inspecting their own + * session's traffic sees the real values. When decoding is disabled every + * frame reports its byte length only. * - **Reuse, don't reinvent.** Decoding is `WIRE_DECODE_TABLE[frameId]?.(bytes)` * from `@parity/truapi/wire-decode` - the same generated, dev-only codecs the * client uses. The debugger writes no codecs of its own. - * - **Sensitive denylist.** The generated table decodes *every* frame, including - * signing and login. The security of this feature is the denylist layered on - * top: a sensitive frame is never decoded, even with the toggle on - it - * reports its byte length labelled `"sensitive method"`. The denylist is - * itself generated: `SENSITIVE_FRAME_IDS` in `@parity/truapi/wire-table` - * carries every frame id of a method marked `#[wire(..., sensitive)]` on the - * Rust trait, so sensitivity is a property of the payload type, not a name - * the debugger pattern-matches. * * Nothing here is ever serialized into `/traces`; the detail it produces is * returned only from the explicit per-frame drill-down. @@ -29,106 +23,25 @@ */ import { WIRE_DECODE_TABLE } from "@parity/truapi/wire-decode"; -import * as W from "@parity/truapi/wire-table"; import type { ObservedFrame } from "./observed-frame.js"; /** * Per-frame decode result for the drill-down detail path. * - * `"bytes"` is the safe default returned whenever the decoder is off, the frame - * carries no retained bytes, the id has no codec, or decoding throws. - * `"redacted"` is returned for a sensitive frame even when the decoder is on. - * `"decoded"` carries the plain JS value and is reachable only with the decoder - * on, for a non-sensitive frame whose id is in the table. + * `"decoded"` carries the plain JS value, returned whenever the decoder is on + * and the frame's id has a codec that decodes its retained bytes. `"bytes"` is + * the fallback: the decoder is off, the frame carries no retained bytes, its id + * has no codec, or decoding threw. */ export type FrameValueDetail = - | { kind: "decoded"; value: unknown; sensitive?: boolean } - | { kind: "redacted"; reason: "sensitive method"; byteLength: number } + | { kind: "decoded"; value: unknown } | { kind: "bytes"; byteLength: number }; -/** - * The set of wire `frameId`s that must never be decoded, sourced directly from - * the generated {@link W.SENSITIVE_FRAME_IDS}. That set is emitted by - * `truapi-codegen` from every method marked `#[wire(..., sensitive)]` on the - * Rust trait and carries all of the method's frame ids (request/response and - * start/stop/interrupt/receive), so both legs of a sensitive op are redacted. - * - * Sensitivity therefore lives on the Rust payload type, not on a name the - * debugger pattern-matches: a codegen rename cannot silently drop a family, and - * a newly annotated method is denylisted the moment the client is regenerated. - * The families it covers today: - * - * - signing — every method (create-transaction(+legacy), sign-raw(+legacy), - * sign-payload(+legacy)): payloads to be signed and the resulting signatures. - * - account/statement-store proof creation: cryptographic proofs bound to a - * key/identity. - * - entropy/derive: key-derivation material. - * - account request-login / get-user-id: SSO/login and the user id it resolves. - * - local-storage read/write: a read response or a write request can carry - * tokens, session state, or PII. (`clear` carries only a key name and an - * empty response, so it is intentionally *not* sensitive.) - * - payment top-up: can carry a raw sr25519 secret key (PaymentTopUpSource). - * - coin-payment create-cheque/deposit/listen-for-payment: redeemable - * `encryptedSecrets` on a CoinPaymentCheque. - * - statement-store subscribe/submit: a SignedStatement's `decryptionKey`. - * - * Deliberately decodable, because they hold no key material: chain calls - * (`CHAIN_*`) carry public on-chain data — headers, bodies, storage, runtime - * calls, and the broadcast of already-public signed transactions — and are the - * primary useful decode surface; chat, notifications, permissions, theme, - * resource-allocation, and preimage likewise carry no credentials. - * - * Because sensitivity is a property of the payload *type*, the decoder also - * applies a fail-closed content check (see {@link createFrameDecoder}) that - * redacts any decoded value carrying a secret-named field — so a secret-bearing - * method that was never annotated is still caught. - */ -export const SENSITIVE_FRAME_IDS: ReadonlySet = W.SENSITIVE_FRAME_IDS; - -/** - * Field-name pattern for the fail-closed content check: keys whose name implies - * key material or a bearer secret (`sr25519SecretKey`, `encryptedSecrets`, - * `decryptionKey`, a mnemonic, a token/credential/passphrase, …). Deliberately - * omits a bare `key` so public identifiers like `publicKey` still decode. This - * is only a backstop — the authoritative guarantee is the generated - * {@link SENSITIVE_FRAME_IDS} denylist (type-driven via `#[wire(sensitive)]`); - * the content check catches any secret-bearing method that was never annotated. - */ -const SECRET_FIELD_RE = - /secret|mnemonic|entropy|private|decrypt|token|credential|passphrase|password|apikey|bearer|seed/i; - -/** - * Does a decoded value carry a secret-named field anywhere in its structure? - * - * Sensitivity ultimately lives in the payload type, so this backs up - * {@link SENSITIVE_FRAME_IDS}: a decoded value with a secret-named key is - * redacted even if its method was not on the denylist. The `seen` set makes it - * O(nodes) - each object is visited once - so it terminates in linear time on - * cycles and shared-substructure DAGs, not just trees. Safe on arrays, tagged - * unions, and nested structs. - */ -function containsSecretField( - value: unknown, - seen: WeakSet = new WeakSet(), - depth = 0, -): boolean { - // Depth cap is generous headroom; the `seen` set is what bounds work, by - // never revisiting an object even when the graph re-references it. - if (depth > 64 || value === null || typeof value !== "object") return false; - if (seen.has(value)) return false; - seen.add(value); - for (const [key, nested] of Object.entries(value as Record)) { - if (SECRET_FIELD_RE.test(key)) return true; - if (containsSecretField(nested, seen, depth + 1)) return true; - } - return false; -} - /** Options for {@link createFrameDecoder}. */ export interface FrameDecoderOptions { /** * Master gate. `false` (the default) means the decoder never inspects a - * payload: every frame reports bytes only. This is the dev-only opt-in. + * payload: every frame reports bytes only. */ enabled?: boolean; /** @@ -136,97 +49,34 @@ export interface FrameDecoderOptions { * {@link WIRE_DECODE_TABLE}; overridable for tests. */ decodeTable?: Record unknown>; - /** - * Frame ids that must never be decoded. Defaults to the generated - * {@link SENSITIVE_FRAME_IDS} denylist. - */ - sensitiveIds?: ReadonlySet; - /** - * Second, independent gate that *allows* a sensitive frame to be decoded - but - * only on an explicit per-frame `reveal` request (see {@link FrameDecoder.detail}), - * never by default. Off by default and only meaningful when {@link enabled} is - * also on. This is the dev-only "reveal sensitive" escape hatch: it is wired - * from its own env gate (`TRUAPI_DEBUGGER_REVEAL_SENSITIVE`) so it is - * structurally impossible to turn on in a shipped build, and even with it on - * the safe default (redact) still holds until the operator confirms a reveal. - */ - revealSensitive?: boolean; -} - -/** Options for a single {@link FrameDecoder.detail} call. */ -export interface FrameDetailOptions { - /** - * Explicit operator request to reveal a sensitive frame's value. Honored only - * when the decoder was built with {@link FrameDecoderOptions.revealSensitive} - * (and {@link FrameDecoderOptions.enabled}); otherwise ignored and the frame - * redacts as usual. A reveal bypasses both the denylist and the content guard - * for that one frame - it is the "show me everything" dev path. - */ - reveal?: boolean; } /** A gated per-frame value decoder for the drill-down detail path. */ export interface FrameDecoder { /** Whether decoding is on. `false` ⇒ every `detail` is bytes-only. */ readonly enabled: boolean; - /** Whether the sensitive-reveal escape hatch is armed (still off by default per call). */ - readonly revealSensitive: boolean; - /** The sensitive-frame denylist in effect (redacted unless explicitly revealed). */ - readonly sensitiveIds: ReadonlySet; /** Resolve one frame to its {@link FrameValueDetail}. */ - detail(frame: ObservedFrame, options?: FrameDetailOptions): FrameValueDetail; + detail(frame: ObservedFrame): FrameValueDetail; } /** * Build a {@link FrameDecoder}. Off by default: pass `enabled: true` to opt in. - * Even then, sensitive frames (see {@link SENSITIVE_FRAME_IDS}) are reported - * as `"redacted"`, never decoded. + * When on, every frame with a codec and retained bytes decodes to its value. */ export function createFrameDecoder( options: FrameDecoderOptions = {}, ): FrameDecoder { const enabled = options.enabled ?? false; - const revealSensitive = options.revealSensitive ?? false; const decodeTable = options.decodeTable ?? WIRE_DECODE_TABLE; - const sensitiveIds = options.sensitiveIds ?? SENSITIVE_FRAME_IDS; - const detail = ( - frame: ObservedFrame, - detailOptions: FrameDetailOptions = {}, - ): FrameValueDetail => { + const detail = (frame: ObservedFrame): FrameValueDetail => { if (!enabled) return { kind: "bytes", byteLength: frame.byteLength }; - // The reveal escape hatch fires only when the capability is armed AND the - // operator explicitly asked for this frame. Absent either, the safe default - // (redact sensitive / content-guard) stands - so the guarantee "sensitive - // never decodes" holds by default even in a reveal-armed session. - const reveal = revealSensitive && detailOptions.reveal === true; - if (sensitiveIds.has(frame.frameId) && !reveal) { - return { - kind: "redacted", - reason: "sensitive method", - byteLength: frame.byteLength, - }; - } const decode = decodeTable[frame.frameId]; if (!decode || !frame.bytes) { return { kind: "bytes", byteLength: frame.byteLength }; } try { - const value = decode(frame.bytes); - // Fail-closed net: redact if the decoded payload carries a secret-named - // field, even though the method itself was not on the denylist - unless - // this is an explicit reveal, which is the "show me everything" path. - if (!reveal && containsSecretField(value)) { - return { - kind: "redacted", - reason: "sensitive method", - byteLength: frame.byteLength, - }; - } - // Mark a revealed value so the UI can style it as the danger it is. - return reveal - ? { kind: "decoded", value, sensitive: true } - : { kind: "decoded", value }; + return { kind: "decoded", value: decode(frame.bytes) }; } catch { // A malformed or version-skewed payload must not break the drill-down; // fall back to the byte-length view. @@ -234,5 +84,5 @@ export function createFrameDecoder( } }; - return { enabled, revealSensitive, sensitiveIds, detail }; + return { enabled, detail }; } diff --git a/js/packages/truapi-debugger/src/in-app.test.ts b/js/packages/truapi-debugger/src/in-app.test.ts index 4db9222ac..e2786cb35 100644 --- a/js/packages/truapi-debugger/src/in-app.test.ts +++ b/js/packages/truapi-debugger/src/in-app.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { encodeWireMessage } from "@parity/truapi"; +import { encodeWireMessage, VersionedHostAccountGetRequest } from "@parity/truapi"; import * as W from "@parity/truapi/wire-table"; import { createInAppDebugger } from "./in-app.js"; @@ -36,6 +36,25 @@ function frameBytes(id: number, value: number[] = [0]): Uint8Array { return r.value; } +/** A real, decodable account-get request wire message (non-sensitive). */ +function accountGetRequestBytes(): Uint8Array { + const value = VersionedHostAccountGetRequest.enc({ + tag: "V1", + value: { + productAccountId: { + dotNsIdentifier: "alice.dot", + derivationIndex: { tag: "Index", value: 0 }, + }, + }, + }); + const r = encodeWireMessage({ + requestId: "p:1", + payload: { id: W.ACCOUNT_GET_ACCOUNT.request, value }, + }); + if (r.isErr()) throw r.error; + return r.value; +} + describe("createInAppDebugger", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- shim a DOM const g = globalThis as any; @@ -47,16 +66,20 @@ describe("createInAppDebugger", () => { g.document = original; }); - test("feeds frames in-process and mounts a payload-blind panel", () => { - const dbg = createInAppDebugger(); // decode OFF by default + test("feeds frames in-process and decodes by default", () => { + const dbg = createInAppDebugger(); // decode ON by default (dev-only tool) // Two frames of one op, fed exactly as dotli's tap would (raw SCALE bytes). - dbg.handleFrame("shop.dot", "out", frameBytes(W.ACCOUNT_GET_ACCOUNT.request)); + // The request leg carries a real, decodable account-get payload. + dbg.handleFrame("shop.dot", "out", accountGetRequestBytes()); dbg.handleFrame("shop.dot", "in", frameBytes(W.ACCOUNT_GET_ACCOUNT.response)); expect(dbg.session.traceEngine.traces()).toHaveLength(1); - expect(dbg.session.decodeValues).toBe(false); // payload-blind by default - expect(dbg.session.revealSensitive).toBe(false); + expect(dbg.session.decodeValues).toBe(true); // decodes by default + + // The drill-down surfaces the decoded value. + const detail = dbg.session.frameDetail("p:1", 0, "shop.dot"); + expect(detail?.kind).toBe("decoded"); const el = fakeEl(); const dispose = dbg.mount(el as unknown as HTMLElement); @@ -67,15 +90,23 @@ describe("createInAppDebugger", () => { expect(list.children).toHaveLength(0); }); - test("a sensitive op stays redacted with decode off", () => { + test("a formerly-sensitive op is no longer special-cased (never redacted)", () => { const dbg = createInAppDebugger(); dbg.handleFrame("shop.dot", "out", frameBytes(W.SIGNING_SIGN_RAW.request, [1, 2])); dbg.handleFrame("shop.dot", "in", frameBytes(W.SIGNING_SIGN_RAW.response)); const view = dbg.session.traceEngine.traces()[0]; expect(view).toBeDefined(); - // The signing op is on the type-driven denylist, so the session flags it. - expect( - dbg.session.frameDetail("p:1", 0, "shop.dot")?.kind, - ).not.toBe("decoded"); + // No denylist: the drill-down either decodes or falls back to bytes, but + // never returns the old "redacted" state. + const detail = dbg.session.frameDetail("p:1", 0, "shop.dot"); + expect(["decoded", "bytes"]).toContain(detail?.kind); + expect(detail?.kind).not.toBe("redacted"); + }); + + test("decodeValues:false keeps the mount payload-blind (bytes only)", () => { + const dbg = createInAppDebugger({ decodeValues: false }); + dbg.handleFrame("shop.dot", "out", accountGetRequestBytes()); + expect(dbg.session.decodeValues).toBe(false); + expect(dbg.session.frameDetail("p:1", 0, "shop.dot")?.kind).toBe("bytes"); }); }); diff --git a/js/packages/truapi-debugger/src/in-app.ts b/js/packages/truapi-debugger/src/in-app.ts index bc9489900..aeadf37c7 100644 --- a/js/packages/truapi-debugger/src/in-app.ts +++ b/js/packages/truapi-debugger/src/in-app.ts @@ -4,8 +4,8 @@ * In-app mount: render the inspector from a {@link DebugSession} that lives in * the SAME app as the host — no server, no dial-out, no relay. A host running in * the page (dotli) feeds each tapped frame via {@link InAppDebugger.handleFrame}; - * {@link InAppDebugger.mount} renders them with the same engine, renderer, and - * type-driven denylist the standalone app uses, payload-blind by default. + * {@link InAppDebugger.mount} renders them with the same engine and renderer the + * standalone app uses, decoding every frame by default (dev-only tool). * * This is the "host and debugger in the same bits" transport: the frames never * leave the app, so each browser tab is its own tenant — nothing to host or @@ -14,7 +14,7 @@ * @module */ -import { createDebugSession } from "./session.js"; +import { createDebugSession, decodeTraceFrames } from "./session.js"; import type { DebugSession, DebugSessionOptions } from "./session.js"; import { wireTraceToView } from "./trace-view.js"; import { renderTraceDetail } from "./trace-render.js"; @@ -23,7 +23,7 @@ import { TRACE_DETAIL_CSS } from "./trace-styles.js"; /** A same-app debugger: feed it frames, mount its panel. */ export interface InAppDebugger { - /** The underlying session — grouped traces, per-frame decode gate. */ + /** The underlying session — grouped traces, inline value decode. */ readonly session: DebugSession; /** * Feed one tapped frame: the raw SCALE `ProtocolMessage` bytes, opaque. `dir` @@ -32,16 +32,15 @@ export interface InAppDebugger { handleFrame(channelId: string, dir: "in" | "out", frame: Uint8Array): void; /** * Render a live, self-contained panel into `el` and keep it refreshed; returns - * a disposer that tears the panel down. Payload-blind unless the session was - * created with `decodeValues`. + * a disposer that tears the panel down. Decodes every frame unless the session + * was created with `decodeValues: false`. */ mount(el: HTMLElement, options?: { refreshMs?: number }): () => void; } /** - * Create an in-app debugger. Decode stays OFF unless `decodeValues` is set (the - * reveal gate folds under it exactly as {@link createDebugSession} does), so a - * bundled mount is payload-blind by default. + * Create an in-app debugger. Decode is ON by default (dev-only tool); pass + * `decodeValues: false` to keep a bundled mount payload-blind. */ export function createInAppDebugger( options: DebugSessionOptions = {}, @@ -68,21 +67,17 @@ export function createInAppDebugger( traces.length === 0 ? `
no frames yet
` : traces - .map( - (trace) => - `
${renderTraceDetail( - wireTraceToView( - trace, - session.methodNames, - storms.get(trace) ?? [], - session.sensitiveIds, - ), - { - offerDecode: session.decodeValues, - offerReveal: session.revealSensitive, - }, - )}
`, - ) + .map((trace) => { + const view = wireTraceToView( + trace, + session.methodNames, + storms.get(trace) ?? [], + ); + return `
${renderTraceDetail(view, { + offerDecode: session.decodeValues, + decoded: decodeTraceFrames(session, view), + })}
`; + }) .join(""); }; render(); diff --git a/js/packages/truapi-debugger/src/index.ts b/js/packages/truapi-debugger/src/index.ts index 6f755529a..1ac12ae01 100644 --- a/js/packages/truapi-debugger/src/index.ts +++ b/js/packages/truapi-debugger/src/index.ts @@ -8,7 +8,7 @@ export { createDebugIngest } from "./ingest.js"; export type { DebugFrameEnvelope, DebugIngestOptions } from "./ingest.js"; export { createDebugSession } from "./session.js"; export type { DebugSession, DebugSessionOptions } from "./session.js"; -export { createFrameDecoder, SENSITIVE_FRAME_IDS } from "./decode.js"; +export { createFrameDecoder } from "./decode.js"; export type { FrameDecoder, FrameDecoderOptions, diff --git a/js/packages/truapi-debugger/src/ingest.ts b/js/packages/truapi-debugger/src/ingest.ts index 482dd0e45..ce9f64a85 100644 --- a/js/packages/truapi-debugger/src/ingest.ts +++ b/js/packages/truapi-debugger/src/ingest.ts @@ -21,8 +21,8 @@ import type { WireMethodInfo } from "./wire-debugger.js"; * web host's debugger link) stamp it alongside a codec identity so the debugger * can refuse to decode a frame against a wire contract that isn't its own - * frame ids are `u8` discriminants that get reassigned as the API evolves, so an - * unversioned envelope from an older host would resolve to the wrong method, the - * wrong value, and worst case decode a frame the host's build marks sensitive. + * unversioned envelope from an older host would resolve to the wrong method and + * the wrong value. */ export const WIRE_ENVELOPE_VERSION = 1; diff --git a/js/packages/truapi-debugger/src/repl.ts b/js/packages/truapi-debugger/src/repl.ts deleted file mode 100644 index 2c5319053..000000000 --- a/js/packages/truapi-debugger/src/repl.ts +++ /dev/null @@ -1,309 +0,0 @@ -// Copyright 2026 Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: MIT -/** - * Interactive query REPL for the wire debugger - a prompt you keep talking to, - * rather than a full-screen app. Line-based (via `node:readline`, so history and - * line editing come for free), over a running debugger, reusing the same - * {@link buildTraceView} engine and denylist as the web inspector. - * - * Session scope (channel / filter / sort / sensitive-only) persists across - * queries, so `ls` reflects the state you set. The sensitive-reveal escape hatch - * is a two-step, in-loop confirm (`reveal ` then `yes`) - no nested prompt, - * and the reveal is honored only when the server is armed. - * - * @module - */ - -import readline from "node:readline"; - -import { - toView, - viewMethod, - type DebuggerClient, - type FrameValueDetail, -} from "./cli-client.js"; -import type { TraceView } from "./trace-view.js"; -import { formatOpDetail, formatOpRow, formatStats } from "./trace-text.js"; - -const COLOR = - process.env.NO_COLOR === undefined && process.stdout.isTTY === true; -function c(code: string, s: string): string { - return COLOR ? `\x1b[${code}m${s}\x1b[0m` : s; -} -const bold = (s: string): string => c("1", s); -const dim = (s: string): string => c("2", s); -const red = (s: string): string => c("31", s); -const green = (s: string): string => c("32", s); -const cyan = (s: string): string => c("36", s); - -const SORTS = ["arrival", "recent", "method", "duration", "frames"]; - -interface ReplState { - channel: string | null; - filter: string; - sort: string; - sensOnly: boolean; - /** A reveal awaiting the next line's `yes` confirmation. */ - pendingReveal: { requestId: string; seq?: number } | null; -} - -const HELP = [ - bold("commands"), - ` ${cyan("ls")} [text] list ops (aggregate + rows); optional inline method filter`, - ` ${cyan("stats")} just the aggregate summary line`, - ` ${cyan("show")} an op's frames, decoding non-sensitive values`, - ` ${cyan("decode")} alias for show`, - ` ${cyan("reveal")} [seq] reveal sensitive frame(s) — asks to confirm (dev, armed server only)`, - ` ${cyan("channels")} hosts that have dialed in`, - ` ${cyan("use")} scope every query to one channel`, - ` ${cyan("filter")} [text] persistent method filter (empty clears)`, - ` ${cyan("sort")} ${SORTS.join(" | ")}`, - ` ${cyan("sensitive")} [on|off] show only ops with a sensitive method`, - ` ${cyan("clear")} clear the screen`, - ` ${cyan("help")} · ${cyan("quit")}`, -].join("\n"); - -/** Run the query REPL against `client`. Resolves when the user quits. */ -export async function runRepl( - client: DebuggerClient, - channel: string | null, -): Promise { - const state: ReplState = { - channel, - filter: "", - sort: "arrival", - sensOnly: false, - pendingReveal: null, - }; - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - historySize: 200, - terminal: process.stdin.isTTY === true, - }); - - console.log(`${bold("TrUAPI wire debugger")}${dim(` — ${client.host}`)}`); - console.log(dim("type `help` for commands, `quit` to exit")); - - const promptStr = (): string => { - const bits = [state.channel ?? "all"]; - if (state.filter) bits.push(cyan(`/${state.filter}`)); - if (state.sort !== "arrival") bits.push(`sort:${state.sort}`); - if (state.sensOnly) bits.push(red("\u{1f512}")); - return `${green("truapi")} ${dim(bits.join(" "))} ${bold("▸")} `; - }; - - function sortViews(views: TraceView[]): TraceView[] { - if (state.sort === "arrival") return views; - return [...views].sort((a, b) => { - switch (state.sort) { - case "recent": - return b.lastAt - a.lastAt; - case "duration": - return b.durationMs - a.durationMs; - case "frames": - return b.frames.length - a.frames.length; - case "method": - return viewMethod(a).localeCompare(viewMethod(b)); - default: - return 0; - } - }); - } - - async function views(inlineFilter?: string): Promise { - const all = await client.traces(); - let vs = all - .filter((t) => state.channel === null || t.channelId === state.channel) - .map(toView); - const f = (inlineFilter ?? state.filter).toLowerCase(); - if (f) vs = vs.filter((v) => viewMethod(v).toLowerCase().includes(f)); - if (state.sensOnly) vs = vs.filter((v) => v.sensitive === true); - return sortViews(vs); - } - - async function doList(inlineFilter?: string): Promise { - const [stats, vs] = await Promise.all([ - client.stats(state.channel), - views(inlineFilter), - ]); - console.log(formatStats(stats)); - console.log(""); - if (vs.length === 0) console.log(dim(" (no operations match)")); - // Unscoped view: show the channel so same-id ops from two hosts are distinct. - for (const v of vs) console.log(formatOpRow(v, state.channel === null)); - } - - async function doChannels(): Promise { - const chs = await client.channels(); - if (chs.length === 0) { - console.log(dim(" (no hosts have dialed in yet)")); - return; - } - for (const ch of chs) { - console.log( - `${ch.connected ? green("●") : dim("○")} ${ch.channelId} ${dim(`(${String(ch.frameCount)} frames)`)}${ch.channelId === state.channel ? cyan(" ← scoped") : ""}`, - ); - } - } - - async function findOp(id: string) { - return (await client.traces()).find( - (t) => - t.requestId === id && - (state.channel === null || t.channelId === state.channel), - ); - } - - async function doShow(id: string, revealSeqs?: Set): Promise { - const entry = await findOp(id); - if (entry === undefined) { - console.log(red(`no operation with requestId ${id}`)); - return; - } - const view = toView(entry); - const decoded = new Map(); - for (const f of view.frames) { - const reveal = revealSeqs?.has(f.seq) ?? false; - try { - decoded.set( - f.seq, - await client.frame(entry.requestId, f.seq, entry.channelId, reveal), - ); - } catch { - // Leave the frame value-less; the row still renders. - } - } - console.log(formatOpDetail(view, decoded)); - } - - async function startReveal(id: string, seqArg?: string): Promise { - const entry = await findOp(id); - if (entry === undefined) { - console.log(red(`no operation with requestId ${id}`)); - return; - } - const view = toView(entry); - const seq = seqArg === undefined ? undefined : Number(seqArg); - const targets = - seq === undefined - ? view.frames.filter((f) => f.sensitive === true) - : view.frames.filter((f) => f.seq === seq); - if (targets.length === 0) { - console.log(dim(" (no sensitive frame to reveal here)")); - return; - } - state.pendingReveal = { requestId: id, seq }; - console.log( - red("⚠ reveal SENSITIVE payload") + - dim(" — may contain a private key/credential; not while screen-sharing.\n") + - ` type ${bold("yes")} to confirm (anything else cancels)`, - ); - } - - async function handle(line: string): Promise { - // A pending reveal consumes this line as its confirmation. - if (state.pendingReveal) { - const { requestId, seq } = state.pendingReveal; - state.pendingReveal = null; - if (line.toLowerCase() !== "yes" && line.toLowerCase() !== "y") { - console.log(dim(" (reveal cancelled)")); - return; - } - const entry = await findOp(requestId); - if (entry === undefined) { - console.log(red(`no operation with requestId ${requestId}`)); - return; - } - const view = toView(entry); - // A specific seq reveals just that frame; otherwise every sensitive frame. - const revealSeqs = - seq === undefined - ? new Set(view.frames.filter((f) => f.sensitive === true).map((f) => f.seq)) - : new Set([seq]); - await doShow(requestId, revealSeqs); - return; - } - - const [cmd, ...rest] = line.split(/\s+/).filter(Boolean); - if (cmd === undefined) return; - const pos = rest.filter((a) => !a.startsWith("--")); - const arg = pos[0]; - switch (cmd) { - case "help": - case "?": - console.log(HELP); - return; - case "ls": - case "ops": - return doList(arg); - case "stats": - console.log(formatStats(await client.stats(state.channel))); - return; - case "channels": - return doChannels(); - case "show": - case "decode": - if (arg === undefined) { - console.log(dim("usage: show ")); - return; - } - return doShow(arg); - case "reveal": - if (arg === undefined) { - console.log(dim("usage: reveal [seq]")); - return; - } - return startReveal(arg, pos[1]); - case "use": - case "channel": - state.channel = arg === undefined || arg === "all" ? null : arg; - return; - case "filter": - state.filter = rest.filter((a) => !a.startsWith("--")).join(" "); - return; - case "sort": - if (arg !== undefined && SORTS.includes(arg)) state.sort = arg; - else console.log(dim(`sort: ${SORTS.join(" | ")}`)); - return; - case "sensitive": - case "sens": - state.sensOnly = arg === undefined ? !state.sensOnly : arg === "on"; - return; - case "clear": - console.clear(); - return; - case "quit": - case "exit": - case "q": - rl.close(); - return; - default: - console.log(dim(`unknown command: ${cmd} — try \`help\``)); - } - } - - const prompt = (): void => { - rl.setPrompt(promptStr()); - rl.prompt(); - }; - - // Serialize line handling so piped input and in-flight fetches never interleave. - let chain: Promise = Promise.resolve(); - prompt(); - rl.on("line", (line) => { - chain = chain - .then(() => handle(line.trim())) - .catch((e: unknown) => { - console.error(red(e instanceof Error ? e.message : String(e))); - }) - .then(() => prompt()); - }); - - await new Promise((resolve) => { - rl.on("close", () => { - console.log(dim("bye")); - resolve(); - }); - }); -} diff --git a/js/packages/truapi-debugger/src/server.test.ts b/js/packages/truapi-debugger/src/server.test.ts index 0b6f84d91..fa7a631c6 100644 --- a/js/packages/truapi-debugger/src/server.test.ts +++ b/js/packages/truapi-debugger/src/server.test.ts @@ -1,9 +1,13 @@ import { expect, test } from "bun:test"; -import { encodeWireMessage, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; +import { + encodeWireMessage, + TRUAPI_WIRE_SCHEMA_HASH, + VersionedHostSignRawRequest, +} from "@parity/truapi"; import * as W from "@parity/truapi/wire-table"; -import { startDebugServer } from "./server.js"; +import { isLoopbackDebugHost, startDebugServer } from "./server.js"; interface TraceFrameView { direction: string; @@ -23,6 +27,30 @@ function encodeFrame(requestId: string, frameId: number, value: Uint8Array): str return Buffer.from(encoded.value).toString("base64"); } +/** + * base64 of a real, decodable sign-raw request wire message. Carries a + * recognizable `dotNsIdentifier` ("alice.dot") in its decoded value so a test + * can prove the value surfaced — this debugger decodes it like any other frame. + */ +function signFrame(requestId: string): string { + const value = VersionedHostSignRawRequest.enc({ + tag: "V1", + value: { + account: { + dotNsIdentifier: "alice.dot", + derivationIndex: { tag: "Index", value: 0 }, + }, + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, + }, + }); + const encoded = encodeWireMessage({ + requestId, + payload: { id: W.SIGNING_SIGN_RAW.request, value }, + }); + if (encoded.isErr()) throw encoded.error; + return Buffer.from(encoded.value).toString("base64"); +} + /** Open a WS to the server, send one envelope, wait until `/traces` is non-empty. */ async function streamFrame( base: string, @@ -105,7 +133,7 @@ test("the inspector page is served at /", async () => { expect(html).toContain("TrUAPI Wire Inspector"); // The shell fetches the shared fragments, not a bespoke renderer. expect(html).toContain("/op-list"); - expect(html).toContain("/frame-html"); + expect(html).toContain("/op?id="); } finally { server.stop(); } @@ -232,16 +260,17 @@ test("/stats is byte- and value-free even with value decode on", async () => { } }); -test("/frame decodes a non-sensitive frame only when decode is on", async () => { +test("/frame decodes a non-sensitive frame by default; decodeValues:false reports bytes", async () => { const frame = encodeFrame( "p:1", W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, new Uint8Array([0]), ); - // Decode ON: the drill-down surfaces the decoded value. - const on = startDebugServer({ port: 0, decodeValues: true }); + // Default (dev-only tool): decode is on, so the drill-down surfaces the value. + const on = startDebugServer({ port: 0 }); try { + expect(on.decodeValues).toBe(true); const baseOn = `http://localhost:${on.port}`; await streamFrame(baseOn, on.port, frame); const detail = await (await fetch(`${baseOn}/frame?id=p:1&i=0`)).json(); @@ -251,8 +280,8 @@ test("/frame decodes a non-sensitive frame only when decode is on", async () => on.stop(); } - // Decode OFF (the default): the same drill-down reports byte length only. - const off = startDebugServer({ port: 0 }); + // `decodeValues: false` (still supported, for demos/tests): byte length only. + const off = startDebugServer({ port: 0, decodeValues: false }); try { expect(off.decodeValues).toBe(false); const baseOff = `http://localhost:${off.port}`; @@ -265,84 +294,129 @@ test("/frame decodes a non-sensitive frame only when decode is on", async () => } }); -test("/frame redacts a signing frame even with decode on, and /traces never carries its bytes", async () => { +test("a signing frame decodes like any other; /traces never carries its bytes", async () => { const server = startDebugServer({ port: 0, decodeValues: true }); const base = `http://localhost:${server.port}`; try { - const secret = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x01]); - const frame = encodeFrame("p:sign", W.SIGNING_SIGN_RAW.request, secret); - await streamFrame(base, server.port, frame); + await streamFrame(base, server.port, signFrame("p:sign")); + // Dev-only tool: no denylist, so the frame decodes and its value surfaces. const detail = await (await fetch(`${base}/frame?id=p:sign&i=0`)).json(); - expect(detail.kind).toBe("redacted"); - expect(detail.reason).toBe("sensitive method"); - expect(detail.byteLength).toBe(secret.length); + expect(detail.kind).toBe("decoded"); + expect(JSON.stringify(detail.value)).toContain("alice.dot"); + // The decoded result never carries a "sensitive"/"redacted" marker any more. + expect(detail.sensitive).toBeUndefined(); - // The signing payload bytes must not appear anywhere in the trace list. + // The payload-blind grouping invariant still holds: /traces never serializes + // the raw or decoded bytes, only the /frame drill-down does. const raw = await (await fetch(`${base}/traces`)).text(); expect(raw).not.toContain("deadbeef"); - expect(raw).not.toContain("222,173"); // 0xde,0xad as a decimal byte array + expect(raw).not.toContain("alice.dot"); } finally { server.stop(); } }); -test("/view renders the shared drill-down and stays payload-blind by default", async () => { - // Decode OFF (default): the level-1 view shows the frame sequence but offers - // no decode control and no value. - const off = startDebugServer({ port: 0 }); +test("/view renders the shared drill-down with decoded values by default", async () => { + // Default (dev-only tool): decode is on, so the drill-down renders each + // frame's value inline — no click-to-decode control. + const server = startDebugServer({ port: 0 }); try { - const base = `http://localhost:${off.port}`; + const base = `http://localhost:${server.port}`; const frame = encodeFrame( "p:1", W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, new Uint8Array([0]), ); - await streamFrame(base, off.port, frame); + await streamFrame(base, server.port, frame); const html = await (await fetch(`${base}/view`)).text(); // Shared-renderer markup, not the old table. expect(html).toContain("td-trace"); expect(html).toContain("td-frame"); expect(html).toContain('data-request-id="p:1"'); - // Payload-blind: no decode affordance and no decoded value. + // Values render inline; the click-to-decode control is gone. + expect(html).toContain("td-frame-payload"); + expect(html).not.toContain("td-frame-decode-btn"); expect(html).not.toContain("decode payload"); - expect(html).not.toContain("V1"); } finally { - off.stop(); + server.stop(); } }); -test("/view offers a decode control per frame when level-2 is on", async () => { - const on = startDebugServer({ port: 0, decodeValues: true }); +test("/view is payload-blind when decode is off", async () => { + const off = startDebugServer({ port: 0, decodeValues: false }); try { - const base = `http://localhost:${on.port}`; + const base = `http://localhost:${off.port}`; const frame = encodeFrame( "p:1", W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, new Uint8Array([0]), ); - await streamFrame(base, on.port, frame); + await streamFrame(base, off.port, frame); const html = await (await fetch(`${base}/view`)).text(); - expect(html).toContain("td-frame-decode-btn"); - // The control is still an opt-in click; the value is not inlined into /view. - expect(html).not.toContain("V1"); + expect(html).toContain('data-request-id="p:1"'); + // No payload column at all, and no decode control. + expect(html).not.toContain("td-frame-payload"); + expect(html).not.toContain("td-frame-decode-btn"); } finally { - on.stop(); + off.stop(); } }); -test("/frame-html renders a redacted fragment for a signing frame", async () => { - const server = startDebugServer({ port: 0, decodeValues: true }); +test("/op decodes every frame inline via the real decodeTraceFrames path", async () => { + const server = startDebugServer({ port: 0 }); const base = `http://localhost:${server.port}`; try { - const secret = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x01]); - const frame = encodeFrame("p:sign", W.SIGNING_SIGN_RAW.request, secret); - await streamFrame(base, server.port, frame); - const res = await fetch(`${base}/frame-html?id=p:sign&i=0`); - expect(res.headers.get("content-type")).toContain("text/html"); - const html = await res.text(); - expect(html).toContain("redacted"); - expect(html).not.toContain("deadbeef"); + // A real sign-raw request whose decoded value carries "alice.dot". + await streamFrame(base, server.port, signFrame("p:sign")); + + // The op drill-down renders the decoded value inline — proving the + // session → decodeTraceFrames → renderer wiring, not just structural markup. + const html = await ( + await fetch(`${base}/op?id=p:sign&channel=myapp.dot&gen=0`) + ).text(); + expect(html).toContain("td-frame-decoded"); + expect(html).toContain("alice.dot"); + // Inline, not behind a control, and nothing withheld. + expect(html).not.toContain("td-frame-decode-btn"); + expect(html).not.toContain("redacted"); + } finally { + server.stop(); + } +}); + +test("/op refuses to decode a codec-mismatched (untrusted) channel", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + // Stream a frame with a wrong wire schema hash: the channel is untrusted. + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed")); + }); + ws.send( + JSON.stringify({ + channelId: "drift.dot", + dir: "out", + frame: signFrame("p:sign"), + schema: "0000000000000000", + }), + ); + for (let i = 0; i < 50; i++) { + const t = (await (await fetch(`${base}/traces`)).json()) as TraceView[]; + if (t.length > 0) break; + await new Promise((r) => setTimeout(r, 20)); + } + ws.close(); + + const html = await ( + await fetch(`${base}/op?id=p:sign&channel=drift.dot&gen=0`) + ).text(); + // Grouped and shown, but no decoded value for the untrusted channel. + expect(html).toContain('data-request-id="p:sign"'); + expect(html).not.toContain("alice.dot"); + expect(html).toContain("payload not shown"); } finally { server.stop(); } @@ -457,6 +531,24 @@ test("a wrong-schema or unstamped host refuses to decode, but still groups", asy } }); +test("isLoopbackDebugHost is an exact allowlist (drives the Host-header guard)", () => { + expect(isLoopbackDebugHost("127.0.0.1")).toBe(true); + expect(isLoopbackDebugHost("localhost")).toBe(true); + expect(isLoopbackDebugHost("::1")).toBe(true); + // Everything else is non-loopback. A fuzzy match that read any of these as + // loopback would let a rebound page past the DNS-rebinding Host guard. + for (const host of [ + "0.0.0.0", + "127.0.0.1.evil.com", + "127.0.0.2", + "[::1]", + "LOCALHOST", + "example.com", + ]) { + expect(isLoopbackDebugHost(host)).toBe(false); + } +}); + test("/frame rejects out-of-range indices (negative and huge) with 404", async () => { const server = startDebugServer({ port: 0, decodeValues: true }); const base = `http://localhost:${server.port}`; @@ -476,76 +568,42 @@ test("/frame rejects out-of-range indices (negative and huge) with 404", async ( } }); -test("the reveal gate folds in decode: armed without decode ⇒ not armed", async () => { - // A stray TRUAPI_DEBUGGER_REVEAL_SENSITIVE with decode OFF must not arm reveal. - const server = startDebugServer({ - port: 0, - decodeValues: false, - revealSensitive: true, - }); - const base = `http://localhost:${server.port}`; - try { - expect(server.revealSensitive).toBe(false); - const frame = encodeFrame( - "p:sign", - W.SIGNING_SIGN_RAW.request, - new Uint8Array([0xde, 0xad]), - ); - await streamFrame(base, server.port, frame); - // Decode off ⇒ bytes-only regardless of a reveal request. - const detail = await ( - await fetch(`${base}/frame?id=p:sign&i=0&reveal=1`) - ).json(); - expect(detail.kind).toBe("bytes"); - } finally { - server.stop(); - } -}); - -test("an unarmed server ignores reveal=1 and still redacts a sensitive frame", async () => { - // Decode ON but reveal NOT armed: reveal=1 must be ignored server-side. - const server = startDebugServer({ port: 0, decodeValues: true }); +test("a default server decodes every frame, including formerly-sensitive ones", async () => { + // Dev-only tool: decode is on by default, so a signing frame decodes. + const server = startDebugServer({ port: 0 }); const base = `http://localhost:${server.port}`; try { - expect(server.revealSensitive).toBe(false); - const frame = encodeFrame( - "p:sign", - W.SIGNING_SIGN_RAW.request, - new Uint8Array([0xde, 0xad]), - ); - await streamFrame(base, server.port, frame); - const detail = await ( - await fetch(`${base}/frame?id=p:sign&i=0&reveal=1`) + expect(server.decodeValues).toBe(true); + await streamFrame(base, server.port, signFrame("p:sign")); + const detail = await (await fetch(`${base}/frame?id=p:sign&i=0`)).json(); + expect(detail.kind).toBe("decoded"); + expect(JSON.stringify(detail.value)).toContain("alice.dot"); + // No sensitive/redacted machinery: `?reveal=0` is just an unknown param, + // ignored, and the frame still decodes. + const still = await ( + await fetch(`${base}/frame?id=p:sign&i=0&reveal=0`) ).json(); - expect(detail.kind).toBe("redacted"); + expect(still.kind).toBe("decoded"); } finally { server.stop(); } }); -test("an armed server honors reveal only on the explicit per-call flag", async () => { - const server = startDebugServer({ - port: 0, - decodeValues: true, - revealSensitive: true, - }); +test("a page with a non-loopback Host header is refused (DNS-rebinding guard)", async () => { + const server = startDebugServer({ port: 0 }); const base = `http://localhost:${server.port}`; try { - expect(server.revealSensitive).toBe(true); - const frame = encodeFrame( - "p:sign", - W.SIGNING_SIGN_RAW.request, - new Uint8Array([0]), - ); - await streamFrame(base, server.port, frame); - // No reveal flag ⇒ still redacts, even on an armed server. - const guarded = await (await fetch(`${base}/frame?id=p:sign&i=0`)).json(); - expect(guarded.kind).toBe("redacted"); - // With the explicit flag ⇒ the denylist is bypassed (decoded or bytes, never redacted). - const revealed = await ( - await fetch(`${base}/frame?id=p:sign&i=0&reveal=1`) - ).json(); - expect(revealed.kind).not.toBe("redacted"); + // A rebound evil.com -> 127.0.0.1 page's same-origin fetch still carries its + // own Host; a non-loopback (non-bind) Host must be refused with a 403. + const res = await fetch(`${base}/traces`, { + headers: { host: "evil.com" }, + }); + expect(res.status).toBe(403); + // A loopback Host is fine. + const ok = await fetch(`${base}/traces`, { + headers: { host: `127.0.0.1:${server.port}` }, + }); + expect(ok.status).toBe(200); } finally { server.stop(); } diff --git a/js/packages/truapi-debugger/src/server.ts b/js/packages/truapi-debugger/src/server.ts index 4186e08d3..190f6577f 100644 --- a/js/packages/truapi-debugger/src/server.ts +++ b/js/packages/truapi-debugger/src/server.ts @@ -7,10 +7,10 @@ * `ProtocolMessage` bytes (JSON can't carry binary; base64 keeps the envelope on * one line). Each message is decoded and grouped by {@link createDebugSession}. * `GET /traces` returns the grouped traces (payload-blind - raw bytes and - * decoded values are never serialized); `GET /frame?id=&i=` is the drill-down - * detail path, the only place a decoded value can surface, and only when level-2 - * decode is opted in (`TRUAPI_DEBUGGER_DECODE_VALUES`, off by default) and the - * frame is not sensitive; `GET /` serves a page that polls `/traces`. + * decoded values are never serialized); `GET /op` renders one op's drill-down + * with each frame's decoded value inline; `GET /frame?id=&i=` is the same + * decode as a programmatic JSON endpoint. Value decode is on by default (a + * dev-only tool decodes everything); `GET /` serves a page that polls `/op-list`. * * The exact host↔debugger framing is not yet standardized (envelope spec, track * T3); base64-in-JSON is what this server accepts today. Runs under Bun @@ -20,19 +20,14 @@ */ import { TRUAPI_CODEC_VERSION, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; -import { createDebugSession } from "./session.js"; +import { createDebugSession, decodeTraceFrames } from "./session.js"; import { DEFAULT_MAX_ID_CHARS, WIRE_ENVELOPE_VERSION, type DebugFrameEnvelope, } from "./ingest.js"; import { wireTraceToView, type TraceView } from "./trace-view.js"; -import type { CliStats } from "./trace-text.js"; -import { - renderFrameValueDetail, - renderOperationRow, - renderTraceDetail, -} from "./trace-render.js"; +import { renderOperationRow, renderTraceDetail } from "./trace-render.js"; import { detectRetryStorms } from "./retry-storm.js"; import { TRACE_DETAIL_CSS } from "./trace-styles.js"; @@ -61,10 +56,10 @@ interface WireMessage { codec?: number; /** * The host's wire-contract fingerprint (`TRUAPI_WIRE_SCHEMA_HASH`): a hash of - * every frame id, its method leg, and its sensitivity. Unlike `codec` (the - * coarse handshake number, bumped ~never), this changes whenever a frame id is - * reassigned or a `#[wire(sensitive)]` flag flips - the case where a - * host-sensitive frame could otherwise decode off this debugger's denylist. + * every frame id and its method leg. Unlike `codec` (the coarse handshake + * number, bumped ~never), this changes whenever a frame id is reassigned - the + * case where a frame could otherwise decode to the wrong method and value off + * this debugger's table. */ schema?: string; /** Frames this host dropped (link backlog full) before this one; surfaced in stats. */ @@ -126,6 +121,40 @@ function optionalInt(raw: string | null): number | null | undefined { return Number.isInteger(n) ? n : null; } +/** + * Whether `host` is a loopback name. The `Host`-header DNS-rebinding guard keys + * on this, so an exact allowlist - never a fuzzy match that could read + * `127.0.0.1.evil.com` as loopback - is the security-relevant classification, + * unit-tested separately. + */ +export function isLoopbackDebugHost(host: string): boolean { + return host === "127.0.0.1" || host === "localhost" || host === "::1"; +} + +/** + * Whether a request's `Host` header targets an address this server is willing to + * answer for: a loopback name. + * + * This is the DNS-rebinding guard. Binding to loopback keeps off-box peers out, + * but a page served from `evil.com` whose DNS has been rebound to `127.0.0.1` + * can issue same-origin `fetch`es to the debugger and read decoded frames; those + * requests still carry `Host: evil.com`. Requiring a loopback Host rejects them + * with a 403. A `Host`-less request (a non-browser client that omits it) is + * allowed, matching the WS Origin gate's posture. + */ +export function hostHeaderAllowed(hostHeader: string | null): boolean { + if (hostHeader === null || hostHeader === "") return true; + let hostname: string; + try { + hostname = new URL(`http://${hostHeader}`).hostname; + } catch { + return false; + } + // `new URL("http://[::1]").hostname` keeps the brackets; normalize to bare. + const normalized = hostname === "[::1]" ? "::1" : hostname; + return isLoopbackDebugHost(normalized); +} + /** Parse and validate one inbound WS text message, or `null`. */ function parseWireMessage(raw: string): ParsedWireMessage | null { let parsed: unknown; @@ -162,8 +191,6 @@ export interface DebugServer { readonly port: number; /** Whether level-2 value decode is enabled on the drill-down path. */ readonly decodeValues: boolean; - /** Whether the dev-only sensitive-reveal escape hatch is armed. */ - readonly revealSensitive: boolean; /** Stop listening and drop active connections. */ stop(): void; } @@ -197,26 +224,19 @@ export function startDebugServer( options: { port?: number; decodeValues?: boolean; - revealSensitive?: boolean; } = {}, ): DebugServer { - const decodeValues = options.decodeValues ?? false; - // The reveal escape hatch is meaningless without decode on; fold the master - // gate in here so a stray env var alone can never arm it. - const revealSensitive = decodeValues && (options.revealSensitive ?? false); - const session = createDebugSession({ decodeValues, revealSensitive }); + // Dev-only tool: decode everything by default. A caller can pass + // `decodeValues: false`. + const decodeValues = options.decodeValues ?? true; + const session = createDebugSession({ decodeValues }); - /** Adapt one trace to a view with the shared method map + denylist. */ + /** Adapt one trace to a view with the shared method map. */ const toView = ( trace: ReturnType[number], storms: ReturnType, ): TraceView => - wireTraceToView( - trace, - session.methodNames, - storms.get(trace) ?? [], - session.sensitiveIds, - ); + wireTraceToView(trace, session.methodNames, storms.get(trace) ?? []); /** * Compute the cross-op retry-storm signal once over a trace set, then adapt @@ -233,7 +253,8 @@ export function startDebugServer( function tracesJson(): string { // Payload-blind view: raw `bytes` and decoded values are deliberately never - // serialized here - decode lives only on the `/frame` drill-down. `method` + // serialized here - values surface only in the `/op` and `/frame` drill-downs. + // `method` // and `role` are public shape metadata derived from the frame id (the same // id→name map the op list already exposes), not payload, so they are safe. // Rendering each trace through the shared `wireTraceToView` also gives @@ -266,7 +287,6 @@ export function startDebugServer( const id = url.searchParams.get("id"); const rawIndex = url.searchParams.get("i"); const channel = url.searchParams.get("channel") ?? undefined; - const reveal = url.searchParams.get("reveal") === "1"; // `Number("")`/`Number(" ")` are both 0 and pass Number.isInteger, so an // empty or whitespace `?i=` or `?gen=` would otherwise resolve frame 0 / // generation 0 (the oldest recycled op) with a 200; optionalInt rejects them. @@ -285,7 +305,7 @@ export function startDebugServer( }); } if (!decodeTrusted(channel)) return codecRefusal("application/json"); - const detail = session.frameDetail(id, index, channel, reveal, generation); + const detail = session.frameDetail(id, index, channel, generation); if (!detail) { return new Response('{"error":"no such frame"}', { status: 404, @@ -298,9 +318,10 @@ export function startDebugServer( } /** - * The `/view` payload-blind level-1 fragment: every trace rendered by the - * shared {@link renderTraceDetail}, the same renderer dotli's panel mounts. - * No payloads here; decode controls appear per frame only when level-2 is on. + * The `/view` fragment: every trace rendered by the shared + * {@link renderTraceDetail}, the same renderer dotli's panel mounts. Each + * frame's value is decoded inline for a trusted channel; an untrusted (codec- + * mismatched) channel groups but shows no value. */ function viewHtml(): string { const entries = viewsFor(session.traceEngine.traces()); @@ -315,56 +336,17 @@ export function startDebugServer( `
` + renderTraceDetail(view, { offerDecode: session.decodeValues, - offerReveal: session.revealSensitive, + // Same codec/schema-drift guard the `/frame` endpoint enforces: an + // untrusted channel's frames group but never surface a decoded value. + decoded: decodeTrusted(view.channelId) + ? decodeTraceFrames(session, view) + : undefined, }) + `
`, ) .join(""); } - /** - * The `/frame-html?id=&i=` server-rendered level-2 fragment for one frame. - * Reuses the denylist-gated {@link DebugSession.frameDetail} and the shared - * value renderer, so a sensitive frame renders redacted here too. - */ - function frameHtmlResponse(url: URL): Response { - const htmlHeaders = { "content-type": "text/html; charset=utf-8" }; - const id = url.searchParams.get("id"); - const rawIndex = url.searchParams.get("i"); - const channel = url.searchParams.get("channel") ?? undefined; - const reveal = url.searchParams.get("reveal") === "1"; - const generation = optionalInt(url.searchParams.get("gen")); - const index = Number(rawIndex); - if ( - id === null || - rawIndex === null || - rawIndex.trim() === "" || - !Number.isInteger(index) || - generation === null - ) { - return new Response(`
bad request
`, { - status: 400, - headers: htmlHeaders, - }); - } - if (!decodeTrusted(channel)) { - return new Response( - `
decode refused — host wire codec mismatch
`, - { status: 409, headers: htmlHeaders }, - ); - } - const detail = session.frameDetail(id, index, channel, reveal, generation); - if (!detail) { - return new Response(`
no such frame
`, { - status: 404, - headers: htmlHeaders, - }); - } - return new Response(renderFrameValueDetail(detail), { - headers: htmlHeaders, - }); - } - // Per-channel liveness for the inspector's host dimension. The envelope // carries channelId; recording first/last-seen + frame count lets the UI show // which hosts have dialed in and whether they are still active. Grouping @@ -456,8 +438,8 @@ export function startDebugServer( * `schema` and never mismatched. * * This is a COMPATIBILITY guard against honest version drift - a host built - * against a different frame table, where a host-sensitive id could resolve off - * this debugger's `SENSITIVE_FRAME_IDS` - not authentication: + * against a different frame table, where an id could resolve to the wrong + * method and value off this debugger's table - not authentication: * `TRUAPI_WIRE_SCHEMA_HASH` is a public build constant, so a deliberate local * injector could stamp it. The WS Origin gate ({@link originAllowed}) is the * boundary against injection; this is defence in depth on top of it. @@ -506,6 +488,26 @@ export function startDebugServer( * strip (the "aggregate-level value"). */ function statsJson(channel: string | null): string { + /** The payload-blind aggregate shape `/stats` serializes. */ + interface StatsPayload { + ops: number; + frames: number; + bytes: number; + subscriptions: number; + liveSubscriptions: number; + malformed: number; + orphaned: number; + retryStorms: number; + truncated: number; + evictedTraces: number; + droppedByHost: number; + codecMismatch: boolean; + out: number; + in: number; + avgDurationMs: number; + maxDurationMs: number; + topMethods: { method: string; count: number }[]; + } const traces = channel === null ? session.traceEngine.traces() @@ -518,7 +520,6 @@ export function startDebugServer( let orphaned = 0; let retryStorms = 0; let truncated = 0; - let sensitive = 0; let out = 0; let inbound = 0; let durationTotal = 0; @@ -532,7 +533,6 @@ export function startDebugServer( if (view.badges.includes("orphaned")) orphaned += 1; if (view.badges.includes("retry-storm")) retryStorms += 1; if (view.badges.includes("truncated")) truncated += 1; - if (view.sensitive) sensitive += 1; if (view.frames.some((f) => SUBSCRIPTION_ROLES.has(f.role))) { subscriptions += 1; if (!view.frames.some((f) => f.role === "stop")) { @@ -569,8 +569,8 @@ export function startDebugServer( const droppedByHost = chanList.reduce((n, c) => n + c.dropped, 0); const codecMismatch = chanList.some((c) => !c.codecOk); // Typed so a dropped/renamed field is a compile error, not a silent gap in - // the payload the CLI parses back as CliStats. - const payload: CliStats = { + // the payload a client parses back. + const payload: StatsPayload = { ops, frames, bytes, @@ -583,7 +583,6 @@ export function startDebugServer( evictedTraces, droppedByHost, codecMismatch, - sensitive, out, in: inbound, avgDurationMs: ops === 0 ? 0 : Math.round(durationTotal / ops), @@ -692,24 +691,37 @@ export function startDebugServer( const storms = detectRetryStorms( session.traceEngine.tracesForChannel(trace.channelId), ); - return renderTraceDetail(toView(trace, storms), { + const view = toView(trace, storms); + return renderTraceDetail(view, { offerDecode: session.decodeValues, - offerReveal: session.revealSensitive, + // Codec/schema-drift guard, matching `/frame`: refuse to decode a channel + // whose wire schema did not affirmatively match this debugger's table. + decoded: decodeTrusted(channel ?? undefined) + ? decodeTraceFrames(session, view) + : undefined, }); } const server = Bun.serve({ port: options.port ?? DEFAULT_PORT, - // Loopback only. The debugger holds every trace (and, with decode on, decoded - // values), so it must not listen on all interfaces where a LAN peer could - // read them or inject frames. The CLI and same-origin inspector both target - // localhost, so nothing else changes. + // Loopback only: the debugger holds every trace (and, with decode on, + // decoded values), so it must not listen on all interfaces where a LAN peer + // could read or inject. hostname: "127.0.0.1", fetch(req, srv) { - // Reject cross-origin WebSocket upgrades (CSWSH): binding to 127.0.0.1 - // keeps off-box peers out, but a page open in the dev's own browser could - // still dial ws://127.0.0.1: to inject frames or drive the decoder - // over hostile bytes. A same-origin inspector and non-browser clients are + const url = new URL(req.url); + const htmlHeaders = { "content-type": "text/html; charset=utf-8" }; + // DNS-rebinding guard: the request's Host must be loopback. This blocks a + // rebound `evil.com -> 127.0.0.1` page from reading decoded frames over + // same-origin fetches, which binding to loopback alone does not prevent. + // Applies before any route dispatch. + if (!hostHeaderAllowed(req.headers.get("host"))) { + return new Response("forbidden host", { status: 403 }); + } + // Reject cross-origin WebSocket upgrades (CSWSH): binding to loopback keeps + // off-box peers out, but a page open in the dev's own browser could still + // dial ws://127.0.0.1: to inject frames or drive the decoder over + // hostile bytes. A same-origin inspector and non-browser clients are // allowed; a foreign browser Origin is not. if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { if (!originAllowed(req.headers.get("origin"))) { @@ -717,8 +729,6 @@ export function startDebugServer( } if (srv.upgrade(req)) return undefined; } - const url = new URL(req.url); - const htmlHeaders = { "content-type": "text/html; charset=utf-8" }; if (url.pathname === "/traces") { return new Response(tracesJson(), { headers: { "content-type": "application/json" }, @@ -765,15 +775,13 @@ export function startDebugServer( if (url.pathname === "/frame") { return frameResponse(url); } - if (url.pathname === "/frame-html") { - return frameHtmlResponse(url); - } - return new Response( - VIEW_HTML.replace("__DECODE_STATE__", decodeValues ? "on" : "off"), - { headers: htmlHeaders }, - ); + return new Response(VIEW_HTML, { headers: htmlHeaders }); }, websocket: { + // Cap one inbound frame at 1 MiB rather than Bun's 16 MiB default: a host + // dial is one small SCALE frame per message, so a larger payload is either + // a bug or an attempt to exhaust memory. Bun drops an over-cap message. + maxPayloadLength: 1024 * 1024, open() { openSockets += 1; }, @@ -804,7 +812,6 @@ export function startDebugServer( // Always a TCP port here; the `?? 0` only satisfies Bun's unix-socket union. port: server.port ?? 0, decodeValues, - revealSensitive, stop: () => server.stop(true), }; } @@ -818,11 +825,10 @@ export function startDebugServer( * * The client is a thin shell over server-rendered fragments: it polls * `/op-list` (the shared {@link renderOperationRow}) and `/channels`, and fetches - * `/op` and `/frame-html` on interaction. Every injected fragment is produced - * and escaped server-side, so `innerHTML` is safe. Payload-blind by default: - * `/op-list` and `/op` carry only shape/timing; a value appears only after an - * explicit per-frame decode, and a sensitive frame renders redacted, never its - * value. `td-*` classes are owned by the shared renderer. + * `/op` when an operation is selected. Every injected fragment is produced and + * escaped server-side, so `innerHTML` is safe. `/op-list` is payload-blind + * (shape/timing only); `/op` renders each frame's decoded value inline for a + * trusted channel. `td-*` classes are owned by the shared renderer. */ const VIEW_HTML = ` @@ -846,8 +852,6 @@ const VIEW_HTML = ` .ins-chan .dot { width: 6px; height: 6px; border-radius: 50%; background: #4b5563; } .ins-chan .dot.live { background: #4ade80; box-shadow: 0 0 4px #4ade80; } .ins-chan.active .dot.live { background: #0a0a0a; box-shadow: none; } - .ins-gate { color: #6b7280; white-space: nowrap; } - .ins-gate.on { color: #fbbf24; } .ins-body { display: grid; grid-template-columns: var(--list-w, 340px) 6px 1fr; min-height: 0; } .ins-list { overflow: auto; outline: none; } @@ -888,49 +892,16 @@ ${TRACE_DETAIL_CSS} .td-frame-decoded > * { margin: 0; } .td-frame-decoded .td-detail-pre { max-height: 240px; overflow: auto; margin: 0; white-space: pre; } - /* Blur-to-reveal placeholder: decorative blocks (no real bytes), revealed on - decode. Full width of the payload column so all placeholders line up. */ - .td-frame-decode-btn { display: flex; align-items: center; gap: 8px; width: 100%; - padding: 3px 8px; border: 1px solid rgba(255,255,255,.10); border-radius: 5px; - background: rgba(255,255,255,.03); color: #94a3b8; cursor: pointer; - font: inherit; text-align: left; transition: background .12s, border-color .12s; } - .td-frame-decode-btn:hover { background: rgba(74,222,128,.10); border-color: rgba(74,222,128,.4); color: #d1fae5; } - .td-frame-decode-btn:disabled { opacity: .5; cursor: progress; } - .td-enc-blur { flex: 1; min-width: 0; overflow: hidden; color: #64748b; - filter: blur(3px); user-select: none; letter-spacing: -1px; } - .td-enc-hint { white-space: nowrap; font-size: 10.5px; color: #6b7280; } - .td-frame-decode-btn:hover .td-enc-hint { color: #86efac; } - /* Bulk decode/encode controls in the top bar (shown only when decode is on). */ - .ins-bulk { display: none; gap: 6px; } - .ins-bulk.on { display: inline-flex; } - .ins-btn { padding: 1px 9px; border: 1px solid rgba(255,255,255,.14); border-radius: 5px; - background: transparent; color: #cbd5e1; cursor: pointer; font: inherit; white-space: nowrap; } - .ins-btn:hover { border-color: rgba(74,222,128,.5); color: #86efac; } - .ins-btn.primary { border-color: rgba(251,191,36,.45); color: #fbbf24; } - .ins-btn.primary:hover { background: rgba(251,191,36,.12); } - /* Top-bar filter / sort / sensitive-only controls. */ + /* Top-bar filter / sort controls. */ .ins-filter { width: 148px; padding: 2px 8px; border: 1px solid rgba(255,255,255,.14); border-radius: 5px; background: rgba(255,255,255,.03); color: #e0e0e0; font: inherit; } .ins-filter:focus { outline: none; border-color: rgba(74,222,128,.5); } .ins-sort { padding: 2px 6px; border: 1px solid rgba(255,255,255,.14); border-radius: 5px; background: #0a0a0a; color: #cbd5e1; font: inherit; cursor: pointer; } - .ins-sens-toggle.active { border-color: #f87171; color: #f87171; background: rgba(248,113,113,.10); } .td-op.filtered-out { display: none; } - /* Privacy markers on the op row and the frame. */ - .td-op-lock, .td-frame-lock { font-size: 10px; opacity: .9; } - .td-op-lock { margin-left: 3px; } - .td-frame-lock { margin-left: -3px; } - .ins-stat.lock .n { color: #fca5a5; } /* Clickable top-method pills. */ .ins-method { cursor: pointer; } .ins-method:hover { border-color: rgba(74,222,128,.5); color: #d1fae5; } - /* Sensitive-reveal escape hatch (dev-only, env-armed): danger styling. */ - .td-frame-reveal-btn { display: flex; align-items: center; gap: 6px; width: 100%; - padding: 3px 8px; border: 1px dashed rgba(248,113,113,.55); border-radius: 5px; - background: rgba(248,113,113,.06); color: #f87171; cursor: pointer; font: inherit; text-align: left; } - .td-frame-reveal-btn:hover { background: rgba(248,113,113,.15); border-style: solid; } - .td-detail-danger { border-color: rgba(248,113,113,.6) !important; - box-shadow: inset 3px 0 0 #f87171; } /* Aggregate summary strip: the "at a glance" row of metric tiles. */ .ins-summary { display: flex; gap: 6px; align-items: flex-start; flex-wrap: nowrap; padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,.08); @@ -971,22 +942,13 @@ ${TRACE_DETAIL_CSS} - - - - - - decode: __DECODE_STATE__
waiting for frames…
waiting for frames…
-
Select an operation to inspect its frames. ↑/↓ to move, Enter to open, d to decode a frame.
+
Select an operation to inspect its frames. ↑/↓ to move, Enter to open.
connecting…
`; +/** + * Whether value decode is on, from `TRUAPI_DEBUGGER_DECODE_VALUES`. + * + * On by default (dev-only tool); `0`/`false`/`no`/`off` in any case turns it off. + * TRIMMED first: this is the switch that stops full payload decode, so it must + * fail CLOSED on the shapes a shell or a `.env` file actually produces - + * `DECODE_VALUES="0 "` and `DECODE_VALUES=$'false\n'` are how a human writes + * "off", and an untrimmed match reads both as "on". + */ +export function decodeValuesFromEnv(raw: string | undefined): boolean { + return !/^(0|false|no|off)$/i.test((raw ?? "").trim()); +} + +/** + * The listen port from `TRUAPI_DEBUGGER_PORT`: the value, `DEFAULT_PORT` when + * unset/empty, or `null` when it is not a usable port. + * + * Rejects rather than coerces. `Number.isFinite(x) && x > 0` accepts `99999`, + * which the OS truncates to a DIFFERENT port (65535) that the host's debug URL + * will not be pointing at, and `1.5`, which crashes the process on port 1. A + * silently-wrong port on a debugger is indistinguishable from a host that never + * dialed - the single most expensive failure this tool can have. + */ +export function portFromEnv(raw: string | undefined): number | null { + const t = (raw ?? "").trim(); + if (t === "") return DEFAULT_PORT; + if (!/^\d+$/.test(t)) return null; + const port = Number(t); + // 0 would bind an ephemeral port nobody can predict; 65535 is the TCP ceiling. + return port >= 1 && port <= 65535 ? port : null; +} + // Entry point: `bun run src/server.ts` (or `npm run serve`) starts the server. // Port comes from TRUAPI_DEBUGGER_PORT, else the default. This is a DEV-ONLY, // loopback-only tool: value decode is ON by default (set // TRUAPI_DEBUGGER_DECODE_VALUES to 0/false/no/off to turn decode off for a demo). if (import.meta.main) { - const envPort = Number(Bun.env.TRUAPI_DEBUGGER_PORT); - // Dev-only tool: value decode is ON by default. Set TRUAPI_DEBUGGER_DECODE_VALUES - // to a falsy value (0/false/no/off) to turn decode off for a demo. - const decodeValues = !/^(0|false|no|off)$/i.test( - Bun.env.TRUAPI_DEBUGGER_DECODE_VALUES ?? "", - ); + const port = portFromEnv(Bun.env.TRUAPI_DEBUGGER_PORT); + if (port === null) { + console.error( + `[truapi-debugger] TRUAPI_DEBUGGER_PORT must be an integer in 1-65535,` + + ` got ${JSON.stringify(Bun.env.TRUAPI_DEBUGGER_PORT)}`, + ); + process.exit(1); + } const server = startDebugServer({ - port: Number.isFinite(envPort) && envPort > 0 ? envPort : DEFAULT_PORT, - decodeValues, + port, + decodeValues: decodeValuesFromEnv(Bun.env.TRUAPI_DEBUGGER_DECODE_VALUES), }); console.log( `[truapi-debugger] listening on http://127.0.0.1:${server.port}` + diff --git a/js/packages/truapi-debugger/src/session.ts b/js/packages/truapi-debugger/src/session.ts index c61b5c13f..527fd3be7 100644 --- a/js/packages/truapi-debugger/src/session.ts +++ b/js/packages/truapi-debugger/src/session.ts @@ -22,7 +22,12 @@ import { } from "./wire-debugger.js"; import { createDebugIngest, type DebugFrameEnvelope } from "./ingest.js"; import { createFrameDecoder, type FrameValueDetail } from "./decode.js"; -import type { TraceView } from "./trace-view.js"; +import { + isLiveSubscription, + isSubscription, + operationMethod, + type TraceView, +} from "./trace-view.js"; import * as W from "@parity/truapi/wire-table"; import { createClient, createTransport } from "@parity/truapi"; @@ -45,6 +50,164 @@ export interface DebugSessionOptions { * decoded values). When off, `frameDetail` reports byte length only. */ decodeValues?: boolean; + /** + * Cap on retained operations, LRU-evicted (see + * {@link WireDebuggerOptions.maxTraces}). Defaults to the engine's own default. + * A mount that shares a tab with the observed app should lower it: the product + * pays for whatever the panel retains. + */ + maxTraces?: number; + /** + * Cap on retained frames within one operation (see + * {@link WireDebuggerOptions.maxFramesPerTrace}). Defaults to the engine's own + * default. + */ + maxFramesPerTrace?: number; + /** + * Cap on retained payload bytes within one operation (see + * {@link WireDebuggerOptions.maxBytesPerTrace}); only bites while + * {@link DebugSessionOptions.decodeValues} retains bytes. Defaults to the + * engine's own default. + */ + maxBytesPerTrace?: number; +} + +/** How many methods the busiest-methods roll-up reports. */ +const TOP_METHOD_LIMIT = 5; + +/** What the busiest-methods roll-up calls an op whose ids were all off-table. */ +const UNKNOWN_METHOD = "(unknown)"; + +/** + * Facts about a session that no single {@link TraceView} can carry, supplied by + * the mount that owns the link: whole-op eviction, link-level drops, and whether + * a feeding host's wire contract disagrees with this debugger's. + */ +export interface TraceStatsExtras { + /** Whole operations LRU-evicted (`traceEngine.evictedTraces()`). */ + evictedTraces?: number; + /** Frames the feeding host reported dropping before delivery. */ + droppedByHost?: number; + /** Whether any feeding host declared a wire contract this debugger can't decode against. */ + codecMismatch?: boolean; +} + +/** + * The payload-blind aggregate roll-up behind a mount's summary strip: counts, + * byte totals, durations, health tallies, the direction split, and the busiest + * methods. Shape and timing only - never a byte or a decoded value. + */ +export interface TraceStats { + ops: number; + frames: number; + bytes: number; + subscriptions: number; + liveSubscriptions: number; + malformed: number; + orphaned: number; + retryStorms: number; + truncated: number; + evictedTraces: number; + droppedByHost: number; + codecMismatch: boolean; + out: number; + in: number; + avgDurationMs: number; + maxDurationMs: number; + topMethods: { method: string; count: number }[]; +} + +/** + * Roll a set of {@link TraceView}s up into the summary strip's numbers. + * + * This is THE aggregate computation for every mount. A second implementation is + * how the two mounts silently disagree about the same stream (one reporting + * `malformed 1`, the other reporting no malformed at all), so the standalone + * server's `/stats` and the in-app embed's strip both go through here rather than + * each summing views their own way. + * + * `avgDurationMs` averages over ALL ops, not only completed ones: an op that is + * still open contributes its elapsed span, so a stream full of hung calls does + * not read as a fast session. + */ +export function computeTraceStats( + views: readonly TraceView[], + extras: TraceStatsExtras = {}, +): TraceStats { + let frames = 0; + let bytes = 0; + let subscriptions = 0; + let liveSubscriptions = 0; + let malformed = 0; + let orphaned = 0; + let retryStorms = 0; + let truncated = 0; + let out = 0; + let inbound = 0; + let durationTotal = 0; + let durationMax = 0; + const methodCounts = new Map(); + for (const view of views) { + frames += view.frames.length; + durationTotal += view.durationMs; + if (view.durationMs > durationMax) durationMax = view.durationMs; + if (view.badges.includes("malformed")) malformed += 1; + if (view.badges.includes("orphaned")) orphaned += 1; + if (view.badges.includes("retry-storm")) retryStorms += 1; + if (view.badges.includes("truncated")) truncated += 1; + // Subscription liveness comes from the shared definitions rather than a + // local role test, so the strip's "subs · N live" can't disagree with the + // `live` marker the op rows show. + if (isSubscription(view)) { + subscriptions += 1; + if (isLiveSubscription(view)) liveSubscriptions += 1; + } + for (const f of view.frames) { + bytes += f.byteLength ?? 0; + if (f.direction === "out") out += 1; + else inbound += 1; + } + const method = operationMethod(view) ?? UNKNOWN_METHOD; + methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1); + } + const ops = views.length; + return { + ops, + frames, + bytes, + subscriptions, + liveSubscriptions, + malformed, + orphaned, + retryStorms, + truncated, + evictedTraces: extras.evictedTraces ?? 0, + droppedByHost: extras.droppedByHost ?? 0, + codecMismatch: extras.codecMismatch ?? false, + out, + in: inbound, + avgDurationMs: ops === 0 ? 0 : Math.round(durationTotal / ops), + maxDurationMs: Math.round(durationMax), + topMethods: [...methodCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP_METHOD_LIMIT) + .map(([method, count]) => ({ method, count })), + }; +} + +/** + * `512 B` / `1.4 KB` / `2.10 MB`, for a {@link TraceStats} byte total. Shared so + * the two mounts' summary strips read the same number the same way. + */ +export function formatStatBytes(n: number): string { + if (n < 1024) return `${String(n)} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(2)} MB`; +} + +/** `340ms` / `1.20s`, for a {@link TraceStats} duration. Shared, as above. */ +export function formatStatMs(ms: number): string { + return ms < 1000 ? `${String(Math.round(ms))}ms` : `${(ms / 1000).toFixed(2)}s`; } /** Live debug session: feed it envelopes, read back grouped traces. */ @@ -104,7 +267,22 @@ export function createDebugSession( // No `sink`: a session accumulates traces for the view/`/traces`; it must not // spam the server console with a line per frame (the sink default is // `console.debug`). Consumers read `traceEngine`, not stdout. - const wireDebugger = createWireDebugger({ methodNames, sink: () => {} }); + // + // The retention caps are the session's memory ceiling + // (`maxTraces × maxFramesPerTrace`, bounded in bytes by `maxBytesPerTrace`), so + // they are forwarded rather than left at the engine default: a mount that lives + // in the observed app's own tab has to be able to lower them. + const wireDebugger = createWireDebugger({ + methodNames, + sink: () => {}, + ...(options.maxTraces === undefined ? {} : { maxTraces: options.maxTraces }), + ...(options.maxFramesPerTrace === undefined + ? {} + : { maxFramesPerTrace: options.maxFramesPerTrace }), + ...(options.maxBytesPerTrace === undefined + ? {} + : { maxBytesPerTrace: options.maxBytesPerTrace }), + }); // Raw bytes are retained only when decode is on - they exist solely to feed // the drill-down decoder, and `/traces` never serializes them. `methodNames` // resolves each frame's role at ingest, so the engine and any forward hook see diff --git a/js/packages/truapi-debugger/src/trace-render.test.ts b/js/packages/truapi-debugger/src/trace-render.test.ts index b732a89e8..712730576 100644 --- a/js/packages/truapi-debugger/src/trace-render.test.ts +++ b/js/packages/truapi-debugger/src/trace-render.test.ts @@ -3,13 +3,66 @@ import { describe, expect, test } from "bun:test"; import type { FrameValueDetail } from "./decode.js"; +import type { FrameRole, ObservedFrame } from "./observed-frame.js"; import type { TraceView } from "./trace-view.js"; +import { wireTraceToView } from "./trace-view.js"; +import type { + TraceDropCounts, + WireMethodInfo, + WireTrace, +} from "./wire-debugger.js"; import { renderFrameValueDetail, renderOperationRow, renderTraceDetail, } from "./trace-render.js"; +/** Wire ids for one unary method and one subscription, as the wire table has them. */ +const WIRE: ReadonlyMap = new Map([ + [22, { method: "account.getAccount", kind: "request" }], + [23, { method: "account.getAccount", kind: "response" }], + [40, { method: "account.connectionStatus", kind: "start" }], + [41, { method: "account.connectionStatus", kind: "receive" }], + [42, { method: "account.connectionStatus", kind: "stop" }], + [43, { method: "account.connectionStatus", kind: "interrupt" }], +]); + +/** + * Build a view the way a mount does - through the wire adapter - so the badges + * under test are the ones the engine really assigns, not hand-written ones. + */ +function viewOf( + frames: readonly [number, number][], + dropped?: TraceDropCounts, +): TraceView { + const observed: ObservedFrame[] = frames.map(([frameId, timestamp]) => ({ + channelId: "localhost:3000", + // Real ingest cannot know the lifecycle role; the adapter resolves it from + // the frame id's wire-table kind. + role: "unknown" as FrameRole, + direction: "out", + requestId: "p:1", + frameId, + byteLength: 8, + timestamp, + })); + const trace: WireTrace = { + channelId: "localhost:3000", + requestId: "p:1", + generation: 0, + frames: observed, + startedAt: observed[0]?.timestamp ?? 0, + lastAt: observed[observed.length - 1]?.timestamp ?? 0, + truncated: dropped !== undefined, + dropped: dropped ?? { + framesByCount: 0, + framesByBytes: 0, + payloadsShed: 0, + }, + }; + return wireTraceToView(trace, WIRE); +} + const view: TraceView = { requestId: "req-1", startedAt: 1000, @@ -97,7 +150,10 @@ describe("renderTraceDetail", () => { }); test("op-level badges appear in the header", () => { - const html = renderTraceDetail({ ...view, badges: ["orphaned", "retry-storm"] }); + const html = renderTraceDetail({ + ...view, + badges: ["orphaned", "retry-storm"], + }); expect(html).toContain("td-badge-orphaned"); expect(html).toContain("retry storm"); }); @@ -193,4 +249,202 @@ describe("renderOperationRow — an unanswered op reports how long it has waited expect(html).toContain("150ms"); expect(html).not.toContain("waiting"); }); + + test("an unanswered subscribe (orphaned start) also counts up", () => { + // The true-positive on the `start` leg: a subscribe that never delivered. + const view = viewOf([[40, 1_000]]); + expect(view.frames[0].badges).toContain("orphaned"); + const html = renderOperationRow(view, { now: 6_000 }); + expect(html).toContain("waiting 5.00s"); + // It is a subscription with no terminator, so it is live AND waiting: the row + // carries both classes and the stylesheet's precedence rule decides the + // colour. The meta text reports the wait, not the span. + expect(html).toContain("td-op-live"); + expect(html).toContain("td-op-waiting"); + }); +}); + +describe("renderOperationRow — `waiting` needs an unanswered OPENER, not an orphan badge", () => { + // The op-level `orphaned` badge also fires on a closer with no opener. Reading + // it as "unanswered" pre-empts the honest duration with a nonsense wait. + + test("a receive that raced past the stop keeps the op's real duration", () => { + const view = viewOf([ + [40, 1_000], // start + [41, 1_100], // receive + [42, 1_200], // stop + [41, 1_205], // a receive already in flight lands after the stop + ]); + // The late receive is a closer with no opener left on the stack: orphaned. + expect(view.badges).toContain("orphaned"); + expect(view.durationMs).toBe(205); + const html = renderOperationRow(view, { now: 1_000 + 3_600_000 }); + expect(html).toContain("205ms"); + expect(html).not.toContain("waiting"); + expect(html).not.toContain("td-op-waiting"); + }); + + test("a subscription observed receive-only reports live, not a wait", () => { + // The debugger attached mid-session, so the `start` was never observed and + // every receive orphans. The sub is delivering a frame a second. + const view = viewOf([ + [41, 1_000], + [41, 2_000], + [41, 3_000], + ]); + expect(view.badges).toContain("orphaned"); + const html = renderOperationRow(view, { now: 301_000 }); + expect(html).not.toContain("waiting"); + expect(html).toContain("live"); + }); + + test("an off-table opener leaves a completed round trip reading as one", () => { + // Frame id 999 is not on this debugger's table, so the opener resolves to + // role "unknown" and its response orphans — but the call did complete. + const view = viewOf([ + [999, 1_000], + [23, 1_120], + ]); + expect(view.badges).toContain("orphaned"); + const html = renderOperationRow(view, { now: 1_000 + 3_600_000 }); + expect(html).toContain("120ms"); + expect(html).not.toContain("waiting"); + }); +}); + +describe("renderOperationRow — liveness", () => { + test("a subscription the host interrupted is not live", () => { + // `interrupt` is the host's terminator. Testing only for `stop` leaves every + // host-ended subscription reading live for the rest of the session. + const view = viewOf([ + [40, 1_000], + [41, 1_100], + [43, 1_200], // interrupt + ]); + const html = renderOperationRow(view); + expect(html).toContain("td-op-sub"); + expect(html).not.toContain("td-op-live"); + expect(html).not.toContain("live"); + }); + + test("a subscription with no terminator is still live", () => { + const html = renderOperationRow( + viewOf([ + [40, 1_000], + [41, 1_100], + ]), + ); + expect(html).toContain("td-op-live"); + }); +}); + +describe("truncation is reported per axis, not as one boolean", () => { + test("the badge carries the count and names the cap that took the frames", () => { + const view = viewOf([[40, 1_000]], { + framesByCount: 77, + framesByBytes: 0, + payloadsShed: 0, + }); + const html = renderOperationRow(view); + expect(html).toContain("td-badge-truncated"); + expect(html).toContain("truncated 77"); + expect(html).toContain("77 frames dropped (frame cap)"); + }); + + test("one frame lost does not render like seventy-seven", () => { + const one = renderOperationRow( + viewOf([[40, 1_000]], { + framesByCount: 1, + framesByBytes: 0, + payloadsShed: 0, + }), + ); + const many = renderOperationRow( + viewOf([[40, 1_000]], { + framesByCount: 77, + framesByBytes: 0, + payloadsShed: 0, + }), + ); + expect(one).toContain("truncated 1"); + expect(many).toContain("truncated 77"); + expect(one).not.toBe(many); + }); + + test("the byte axis is distinguishable from the frame axis", () => { + const html = renderTraceDetail( + viewOf([[40, 1_000]], { + framesByCount: 0, + framesByBytes: 4, + payloadsShed: 2, + }), + ); + expect(html).toContain("4 frames dropped (byte cap)"); + expect(html).toContain("2 payloads shed"); + expect(html).not.toContain("frame cap"); + }); +}); + +describe("duration formatting", () => { + test("a long wait reads in hours, not thousands of seconds", () => { + const view: TraceView = { + requestId: "p:9", + startedAt: 0, + lastAt: 0, + durationMs: 0, + frames: [ + { + seq: 0, + direction: "out", + role: "request", + method: "account.getAccount", + frameId: 22, + byteLength: 8, + timestamp: 0, + latencyFromStartMs: 0, + badges: ["orphaned"], + decodable: false, + }, + ], + badges: ["orphaned"], + }; + expect(renderOperationRow(view, { now: 10_800_000 })).toContain( + "waiting 3h 00m", + ); + expect(renderOperationRow(view, { now: 10_800_000 })).not.toContain( + "10800.00s", + ); + expect(renderOperationRow(view, { now: 205_000 })).toContain( + "waiting 3m 25s", + ); + // Under a minute still reads in seconds. + expect(renderOperationRow(view, { now: 45_000 })).toContain( + "waiting 45.00s", + ); + }); + + test("a multi-minute op's span reads in minutes", () => { + const html = renderOperationRow( + viewOf([ + [40, 0], + [41, 205_000], + ]), + ); + expect(html).toContain("3m 25s"); + }); +}); + +describe("method labels survive left-truncation", () => { + test("the method is emitted inside an explicit LTR isolate", () => { + // `.td-op-method` uses `direction: rtl` to put the ellipsis on the left, which + // reorders any label that is not a pure LTR identifier (`account.getAccount:` + // → `:account.getAccount`). The isolate keeps it one left-to-right run. + const html = renderOperationRow( + viewOf([ + [22, 1_000], + [23, 1_100], + ]), + ); + expect(html).toContain('account.getAccount'); + }); }); diff --git a/js/packages/truapi-debugger/src/trace-render.ts b/js/packages/truapi-debugger/src/trace-render.ts index 9eac4b517..3804a65ab 100644 --- a/js/packages/truapi-debugger/src/trace-render.ts +++ b/js/packages/truapi-debugger/src/trace-render.ts @@ -25,12 +25,18 @@ */ import type { FrameValueDetail } from "./decode.js"; +import { + isLiveSubscription, + isSubscription, + operationMethod, +} from "./trace-view.js"; import type { TraceBadge, TraceFrameBadge, TraceFrameView, TraceView, } from "./trace-view.js"; +import type { TraceDropCounts } from "./wire-debugger.js"; /** Options controlling a single drill-down render. */ export interface RenderTraceDetailOptions { @@ -65,10 +71,24 @@ function esc(value: string): string { }); } -/** `1234` → `1.23s`, `42` → `42ms`, for compact latency display. */ +/** + * Compact duration: `42` → `42ms`, `1234` → `1.23s`, `205_000` → `3m 25s`, + * `10_800_000` → `3h 00m`. + * + * Seconds cannot be the largest unit: this also formats how long an unanswered + * call has been waiting, and a session left open renders "10800.00s" - a number + * nobody reads as three hours. + */ function formatMs(ms: number): string { if (ms < 1000) return `${String(Math.round(ms))}ms`; - return `${(ms / 1000).toFixed(2)}s`; + if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`; + const pad = (n: number): string => String(n).padStart(2, "0"); + const totalSeconds = Math.floor(ms / 1000); + if (ms < 3_600_000) { + return `${String(Math.floor(totalSeconds / 60))}m ${pad(totalSeconds % 60)}s`; + } + const totalMinutes = Math.floor(totalSeconds / 60); + return `${String(Math.floor(totalMinutes / 60))}h ${pad(totalMinutes % 60)}m`; } const DIRECTION_GLYPH: Record = { @@ -89,9 +109,7 @@ export function renderTraceDetail( const header = renderHeader(view); const rows = view.frames - .map((frame) => - renderFrameRow(frame, offerDecode, decoded?.get(frame.seq)), - ) + .map((frame) => renderFrameRow(frame, offerDecode, decoded?.get(frame.seq))) .join(""); return ( @@ -103,7 +121,9 @@ export function renderTraceDetail( } function renderHeader(view: TraceView): string { - const badges = view.badges.map(renderOpBadge).join(""); + const badges = view.badges + .map((b) => renderOpBadge(b, view.dropped)) + .join(""); const frameCount = view.frames.length; return ( `
` + @@ -121,11 +141,41 @@ const OP_BADGE_LABEL: Record = { truncated: "truncated", }; -function renderOpBadge(badge: TraceBadge): string { - return `${esc(OP_BADGE_LABEL[badge])}`; +function renderOpBadge(badge: TraceBadge, dropped?: TraceDropCounts): string { + // `truncated` carries a count when the vantage supplies one, so "1 frame lost" + // and "77 lost" don't render identically. + const label = + badge === "truncated" && dropped !== undefined + ? `truncated ${String(droppedTotal(dropped))}` + : OP_BADGE_LABEL[badge]; + return `${esc(label)}`; } -function badgeTitle(badge: TraceBadge): string { +/** Frames missing plus payloads shed: everything the caps took from this op. */ +function droppedTotal(dropped: TraceDropCounts): number { + return dropped.framesByCount + dropped.framesByBytes + dropped.payloadsShed; +} + +/** Spell out which cap took what, so the two axes are distinguishable. */ +function truncationTitle(dropped: TraceDropCounts): string { + const parts: string[] = []; + if (dropped.framesByCount > 0) { + parts.push(`${String(dropped.framesByCount)} frames dropped (frame cap)`); + } + if (dropped.framesByBytes > 0) { + parts.push(`${String(dropped.framesByBytes)} frames dropped (byte cap)`); + } + if (dropped.payloadsShed > 0) { + parts.push( + `${String(dropped.payloadsShed)} payloads shed (single frame over the byte cap; frame kept)`, + ); + } + return parts.length === 0 + ? "Older frames were dropped to stay under the frame/byte cap" + : parts.join(" · "); +} + +function badgeTitle(badge: TraceBadge, dropped?: TraceDropCounts): string { switch (badge) { case "orphaned": return "An opening frame has no matching close, or a close has no opener"; @@ -134,7 +184,9 @@ function badgeTitle(badge: TraceBadge): string { case "retry-storm": return "This op is one of a burst of like ops in a short window"; case "truncated": - return "Older frames were dropped to stay under the frame/byte cap"; + return dropped === undefined + ? "Older frames were dropped to stay under the frame/byte cap" + : truncationTitle(dropped); } } @@ -253,38 +305,26 @@ function stringifyValue(value: unknown): string { } } -/** Roles that mark an op as a subscription rather than a request/response. */ -const SUBSCRIPTION_ROLES: ReadonlySet = new Set([ - "start", - "receive", - "stop", - "interrupt", -]); - -/** The op's method: the first opening frame's method, else the first known one. */ -function operationMethod(view: TraceView): string | undefined { - const opener = view.frames.find( - (f) => f.role === "request" || f.role === "start", - ); - if (opener?.method !== undefined) { - return opener.method; - } - return view.frames.find((f) => f.method !== undefined)?.method; -} - -/** Whether the op is a subscription (has a start/receive/stop/interrupt frame). */ -function isSubscription(view: TraceView): boolean { - return view.frames.some((f) => SUBSCRIPTION_ROLES.has(f.role)); -} - /** - * Whether the op went out and nothing came back: an `orphaned` opener that has - * not been answered. This is the shape a timed-out or hung call takes on the - * wire - there is no "timeout" frame to observe, only a request with no reply - - * so it is the signal the op list has to surface as elapsed time. + * Whether the op went out and nothing came back: an *opening* frame carrying the + * `orphaned` badge. This is the shape a timed-out or hung call takes on the wire + * - there is no "timeout" frame to observe, only a request with no reply - so it + * is the signal the op list has to surface as elapsed time. + * + * The op-level `orphaned` badge is NOT this predicate. It also fires on a closer + * with no opener, which is a different and often perfectly live shape: a + * `receive` that arrived after the `stop`, a subscription the debugger attached + * to mid-session and only ever saw receives of, an opener whose frame id was off + * this debugger's table. Reading the op badge as "unanswered" reports a + * subscription that is delivering a frame a second as "waiting 300s", and turns + * a completed 120ms round trip into "waiting 120s". */ function isUnanswered(view: TraceView): boolean { - return view.badges.includes("orphaned"); + return view.frames.some( + (f) => + (f.role === "request" || f.role === "start") && + f.badges.includes("orphaned"), + ); } /** @@ -302,15 +342,26 @@ export function renderOperationRow( ): string { const method = operationMethod(view); const sub = isSubscription(view); - const live = sub && !view.frames.some((f) => f.role === "stop"); + // Liveness comes from the canonical predicate: a subscription the host ended + // with an `interrupt` is not live either, and counting it as live inflates the + // live-subscription total for the rest of the session. + const live = isLiveSubscription(view); const kindGlyph = sub ? "⟳" : "▶"; const kindClass = sub ? "td-op-sub" : "td-op-req"; + // `.td-op-method` is truncated on the left (`direction: rtl`), which reorders + // any label that is not a pure LTR identifier: `account.getAccount:` renders as + // `:account.getAccount` and `22.getAccount` as `getAccount.22`, because `.`, + // `:` and digits are direction-neutral. An explicit LTR isolate around the + // method keeps it a single left-to-right run while the ellipsis stays on the + // left, where the whole point of the rtl trick is to put it. const methodHtml = method === undefined ? `(unknown)` - : `${esc(method)}`; - const badges = view.badges.map(renderOpBadge).join(""); + : `${esc(method)}`; + const badges = view.badges + .map((b) => renderOpBadge(b, view.dropped)) + .join(""); const count = view.frames.length; // An unanswered request has one frame, so `lastAt - startedAt` is 0 and the op // reads "0ms" - the opposite of the truth for the case a developer most needs @@ -322,7 +373,9 @@ export function renderOperationRow( Math.max(0, (options.now ?? 0) - view.startedAt), )}` : `${String(count)} frame${count === 1 ? "" : "s"} · ` + - (live ? `live · ${formatMs(view.durationMs)}` : formatMs(view.durationMs)); + (live + ? `live · ${formatMs(view.durationMs)}` + : formatMs(view.durationMs)); const channelAttr = view.channelId === undefined diff --git a/js/packages/truapi-debugger/src/trace-view.test.ts b/js/packages/truapi-debugger/src/trace-view.test.ts index 5736c07ab..d469bfc63 100644 --- a/js/packages/truapi-debugger/src/trace-view.test.ts +++ b/js/packages/truapi-debugger/src/trace-view.test.ts @@ -3,8 +3,17 @@ import { describe, expect, test } from "bun:test"; import type { ObservedFrame, FrameRole } from "./observed-frame.js"; -import type { WireMethodInfo, WireTrace } from "./wire-debugger.js"; -import { wireTraceToView } from "./trace-view.js"; +import type { + TraceDropCounts, + WireMethodInfo, + WireTrace, +} from "./wire-debugger.js"; +import { + isLiveSubscription, + isSubscription, + operationMethod, + wireTraceToView, +} from "./trace-view.js"; function frame( role: FrameRole, @@ -23,13 +32,25 @@ function frame( }; } -function traceOf(frames: ObservedFrame[]): WireTrace { +function traceOf( + frames: ObservedFrame[], + dropped?: TraceDropCounts, +): WireTrace { return { channelId: "test.dot", requestId: "req-1", frames, startedAt: frames[0]?.timestamp ?? 0, lastAt: frames[frames.length - 1]?.timestamp ?? 0, + generation: 0, + truncated: + dropped !== undefined && + dropped.framesByCount + dropped.framesByBytes + dropped.payloadsShed > 0, + dropped: dropped ?? { + framesByCount: 0, + framesByBytes: 0, + payloadsShed: 0, + }, }; } @@ -136,3 +157,156 @@ describe("wireTraceToView", () => { expect(view.badges).toContain("retry-storm"); }); }); + +describe("wireTraceToView — truncation", () => { + test("an op whose frames were evicted is truncated, never orphaned", () => { + // The engine dropped the frames that would have answered the opener, so + // "no close observed" no longer means "no close happened". Blaming the op for + // the engine's own eviction invents a dropped call (and, in the op list, a + // call still waiting) out of a completed one. + const view = wireTraceToView( + traceOf([frame("request", 22, 1000)], { + framesByCount: 0, + framesByBytes: 3, + payloadsShed: 0, + }), + methodNames, + ); + expect(view.badges).toContain("truncated"); + expect(view.badges).not.toContain("orphaned"); + expect(view.frames[0].badges).not.toContain("orphaned"); + }); + + test("a shed payload does not suppress the orphan verdict", () => { + // Shedding drops bytes, not frames: the sequence is complete, so a request + // with no response really is unanswered. + const view = wireTraceToView( + traceOf([frame("request", 22, 1000)], { + framesByCount: 0, + framesByBytes: 0, + payloadsShed: 1, + }), + methodNames, + ); + expect(view.badges).toContain("truncated"); + expect(view.badges).toContain("orphaned"); + }); + + test("a closer with no opener stays orphaned under eviction", () => { + // Caps only ever evict from index 1, so an opener is never the frame that + // disappears: a trace that starts with a response genuinely never had one. + const view = wireTraceToView( + traceOf([frame("response", 23, 1000)], { + framesByCount: 5, + framesByBytes: 0, + payloadsShed: 0, + }), + methodNames, + ); + expect(view.frames[0].badges).toContain("orphaned"); + }); + + test("per-axis drop counts reach the view for a mount to report", () => { + const view = wireTraceToView( + traceOf([frame("start", 40, 1000)], { + framesByCount: 77, + framesByBytes: 4, + payloadsShed: 1, + }), + ); + expect(view.dropped).toEqual({ + framesByCount: 77, + framesByBytes: 4, + payloadsShed: 1, + }); + }); + + test("an un-truncated trace carries neither the badge nor a nonzero count", () => { + const view = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + expect(view.badges).not.toContain("truncated"); + expect(view.dropped).toEqual({ + framesByCount: 0, + framesByBytes: 0, + payloadsShed: 0, + }); + }); +}); + +describe("operationMethod — the single definition of an op's name", () => { + test("the opener's method wins over a later frame's", () => { + const view = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + expect(operationMethod(view)).toBe("account.getAccount"); + }); + + test("falls back to the first frame that resolves a method", () => { + // The opener's id was off this debugger's table; a later frame's was not. + const view = wireTraceToView( + traceOf([frame("unknown", 999, 1000), frame("response", 23, 1100)]), + methodNames, + ); + expect(operationMethod(view)).toBe("account.getAccount"); + }); + + test("undefined when no frame resolves a method, so callers choose the placeholder", () => { + const view = wireTraceToView(traceOf([frame("unknown", 999, 1000)])); + expect(operationMethod(view)).toBeUndefined(); + }); +}); + +describe("isLiveSubscription — the single definition of a live sub", () => { + test("start + receives with no terminator is live", () => { + const view = wireTraceToView( + traceOf([frame("start", 40, 1000), frame("receive", 41, 1100)]), + ); + expect(isSubscription(view)).toBe(true); + expect(isLiveSubscription(view)).toBe(true); + }); + + test("a product stop ends it", () => { + const view = wireTraceToView( + traceOf([ + frame("start", 40, 1000), + frame("receive", 41, 1100), + frame("stop", 42, 1200), + ]), + ); + expect(isLiveSubscription(view)).toBe(false); + }); + + test("a host interrupt ends it too", () => { + // A host-terminated subscription that only `stop` closes out reads "live" + // forever, and every consumer counting live subs climbs monotonically. + const view = wireTraceToView( + traceOf([ + frame("start", 40, 1000), + frame("receive", 41, 1100), + frame("interrupt", 43, 1200), + ]), + ); + expect(isLiveSubscription(view)).toBe(false); + }); + + test("a request/response op is not a subscription and never live", () => { + const view = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + expect(isSubscription(view)).toBe(false); + expect(isLiveSubscription(view)).toBe(false); + }); + + test("receives with no observed start still count as a subscription", () => { + // The debugger attached mid-session: the `start` predates it. + const view = wireTraceToView( + traceOf([frame("receive", 41, 1000), frame("receive", 41, 2000)]), + ); + expect(isSubscription(view)).toBe(true); + expect(isLiveSubscription(view)).toBe(true); + }); +}); diff --git a/js/packages/truapi-debugger/src/trace-view.ts b/js/packages/truapi-debugger/src/trace-view.ts index 93940c529..fd6d6e261 100644 --- a/js/packages/truapi-debugger/src/trace-view.ts +++ b/js/packages/truapi-debugger/src/trace-view.ts @@ -25,7 +25,11 @@ */ import type { FrameDirection, FrameRole } from "./observed-frame.js"; -import type { WireMethodInfo, WireTrace } from "./wire-debugger.js"; +import type { + TraceDropCounts, + WireMethodInfo, + WireTrace, +} from "./wire-debugger.js"; /** * An op-level badge, surfaced against the whole trace in the drill-down header. @@ -39,7 +43,10 @@ import type { WireMethodInfo, WireTrace } from "./wire-debugger.js"; * own, so it is supplied by the caller (the list/engine layer) rather than * derived here. Left as a follow-up for the engine to compute. * - `truncated`: older frames of this op were dropped to stay under the engine's - * frame/byte cap, so the sequence shown is not the whole op. + * frame/byte cap, so the sequence shown is not the whole op. How many, and + * which cap took them, is in {@link TraceView.dropped}. Because the dropped + * frames may be the ones that answered the opener, a truncated op does not + * derive `orphaned`. */ export type TraceBadge = "orphaned" | "malformed" | "retry-storm" | "truncated"; @@ -114,6 +121,13 @@ export interface TraceView { frames: TraceFrameView[]; /** Op-level badges. */ badges: TraceBadge[]; + /** + * What the vantage's retention caps dropped from this op, per axis, when the + * vantage caps at all (the wire engine does; dotli's bridge does not, and + * leaves this unset). The `truncated` badge says only *that* frames are + * missing; these counts say how many and which cap took them. + */ + dropped?: TraceDropCounts; } /** Roles that open an op (expect a matching close later in the trace). */ @@ -130,6 +144,29 @@ const CLOSING_ROLES: ReadonlySet = new Set([ "stop", ]); +/** + * Roles that mark an op as a subscription rather than a request/response. A + * `receive`/`stop`/`interrupt` is enough on its own: the debugger can attach + * mid-session and never see the `start`. + */ +const SUBSCRIPTION_ROLES: ReadonlySet = new Set([ + "start", + "receive", + "stop", + "interrupt", +]); + +/** + * Roles that end a subscription for good, so no further `receive` is expected: + * the product's own `stop`, or the host's `interrupt`. Deliberately *not* + * {@link CLOSING_ROLES}, which also contains `receive` - a receive continues a + * subscription rather than ending it. + */ +const TERMINAL_ROLES: ReadonlySet = new Set([ + "stop", + "interrupt", +]); + /** * One frame described by a mount's adapter, before the view-level fields (`seq`, * latency, pairing, badges) are computed. The two vantages differ in what they @@ -160,9 +197,15 @@ export interface TraceViewInput { frames: readonly TraceFrameInput[]; /** * Op-level signals the caller computes across traces (e.g. `retry-storm`). - * Within-trace badges (`orphaned`, `malformed`) are derived here. + * Within-trace badges (`orphaned`, `malformed`, `truncated`) are derived here. */ extraBadges?: readonly TraceBadge[]; + /** + * What the vantage's retention caps dropped, when it caps. Drives the + * `truncated` badge, and suppresses the `orphaned` verdict on an opener whose + * answering frames may be among the evicted. + */ + dropped?: TraceDropCounts; } /** @@ -186,7 +229,20 @@ export function buildTraceView(input: TraceViewInput): TraceView { decodable: frame.decodable, })); - annotatePairing(frames); + // Frames actually missing from the sequence (a shed payload leaves its frame in + // place, so it does not count): the answering frames of an opener may be among + // them, which makes an "opener never answered" verdict unsound. + const framesEvicted = + (input.dropped?.framesByCount ?? 0) + (input.dropped?.framesByBytes ?? 0) > + 0; + const anythingDropped = + framesEvicted || (input.dropped?.payloadsShed ?? 0) > 0; + + annotatePairing(frames, framesEvicted); + + const extraBadges = anythingDropped + ? [...(input.extraBadges ?? []), "truncated" as const] + : (input.extraBadges ?? []); return { requestId: input.requestId, @@ -196,20 +252,45 @@ export function buildTraceView(input: TraceViewInput): TraceView { lastAt: input.lastAt, durationMs: input.lastAt - input.startedAt, frames, - badges: deriveOpBadges(frames, input.extraBadges ?? []), + badges: deriveOpBadges(frames, extraBadges), + dropped: input.dropped, }; } /** - * The op's method for display and filtering: the opening (request/start) frame's - * method, else the first frame that resolves one. Shared so both terminal - * frontends and the summary renderer agree on an op's name. + * THE definition of an op's method, for display, filtering, sorting and stats: + * the opening (request/start) frame's method, else the first frame that resolves + * one, else `undefined` when no frame's id was on the table. Every consumer - + * both mounts, the op row, the summary stats - must call this rather than + * re-deriving it, so an op is never named one thing in the list and another in + * the stats. Callers that need a placeholder supply their own (`?? "(unknown)"`). + */ +export function operationMethod(view: TraceView): string | undefined { + const opener = view.frames.find((f) => OPENING_ROLES.has(f.role)); + if (opener?.method !== undefined) return opener.method; + return view.frames.find((f) => f.method !== undefined)?.method; +} + +/** Whether the op is a subscription (has a start/receive/stop/interrupt frame). */ +export function isSubscription(view: TraceView): boolean { + return view.frames.some((f) => SUBSCRIPTION_ROLES.has(f.role)); +} + +/** + * THE definition of a live subscription: a subscription op that has not been + * terminated by either side. Every consumer - the op row's `live` marker, the + * standalone summary's `liveSubscriptions` tile, the in-app panel's "live sub" + * stat - must call this rather than re-deriving it, or the same session reports + * different numbers in different places. + * + * Termination is {@link TERMINAL_ROLES}: a product `stop` *or* a host + * `interrupt`. Testing only for `stop` leaves every host-terminated subscription + * reading "live" forever, which inflates the live count monotonically. */ -export function viewMethod(view: TraceView): string { - const opener = - view.frames.find((f) => f.role === "request" || f.role === "start") ?? - view.frames.find((f) => f.method !== undefined); - return opener?.method ?? "(unknown)"; +export function isLiveSubscription(view: TraceView): boolean { + return ( + isSubscription(view) && !view.frames.some((f) => TERMINAL_ROLES.has(f.role)) + ); } /** @@ -228,8 +309,10 @@ export function wireTraceToView( generation: trace.generation, startedAt: trace.startedAt, lastAt: trace.lastAt, - // Surface engine-level frame/byte-cap eviction as an op badge. - extraBadges: trace.truncated ? [...extraBadges, "truncated"] : extraBadges, + // Engine-level frame/byte-cap eviction: `dropped` drives the `truncated` + // badge and the orphan suppression in buildTraceView. + extraBadges, + dropped: trace.dropped, frames: trace.frames.map((frame): TraceFrameInput => { // A frame may still arrive `role: "unknown"` (a vantage with no wire // frameId, or an off-table id); the frameId's wire-table `kind` is the @@ -260,8 +343,18 @@ export function wireTraceToView( * delivered); a closer with no opener before it is orphaned. An opener that got * at least one close is not orphaned even if it stays open - a live * subscription (start + receives, no stop yet) is healthy, not dropped. + * + * `framesEvicted` says frames are missing from this sequence because a retention + * cap dropped them. Caps only ever evict from index 1, so the opener is still + * here but the frames that answered it may not be: "no close was observed" no + * longer implies "no close happened", and the opener is left unflagged rather + * than blamed for the engine's own eviction. A closer with no opener is still + * orphaned - an opener is never the frame that gets evicted. */ -function annotatePairing(views: TraceFrameView[]): void { +function annotatePairing( + views: TraceFrameView[], + framesEvicted: boolean, +): void { const openStack: number[] = []; const matched = new Set(); for (let i = 0; i < views.length; i++) { @@ -287,7 +380,9 @@ function annotatePairing(views: TraceFrameView[]): void { } } // Openers still open AND never answered are orphaned; a matched-but-open - // opener (live subscription) is not. + // opener (live subscription) is not. Under eviction the answering frames may + // simply have been dropped, so no opener verdict is sound. + if (framesEvicted) return; for (const openerIndex of openStack) { if (!matched.has(openerIndex)) { markOrphan(views[openerIndex]); diff --git a/js/packages/truapi-debugger/src/wire-debugger.test.ts b/js/packages/truapi-debugger/src/wire-debugger.test.ts index 3a5b21ef3..71c843751 100644 --- a/js/packages/truapi-debugger/src/wire-debugger.test.ts +++ b/js/packages/truapi-debugger/src/wire-debugger.test.ts @@ -22,6 +22,21 @@ function frame( }; } +/** The same frame, carrying `bytes` so the per-trace byte cap applies to it. */ +function withBytes( + requestId: string, + frameId: number, + timestamp: number, + bytes: number, + role: FrameRole = "unknown", +): ObservedFrame { + return { + ...frame("app.dot", requestId, frameId, timestamp, role), + byteLength: bytes, + bytes: new Uint8Array(bytes), + }; +} + describe("createWireDebugger grouping", () => { test("accumulates every frame of one op under (channel, requestId)", () => { // Regression guard: the request and its response share a channel + requestId @@ -154,41 +169,22 @@ describe("createWireDebugger grouping", () => { }); test("the byte cap evicts payload frames but keeps the opener", () => { - const withBytes = ( - requestId: string, - frameId: number, - timestamp: number, - bytes: number, - role: FrameRole = "unknown", - ): ObservedFrame => ({ - ...frame("app.dot", requestId, frameId, timestamp, role), - byteLength: bytes, - bytes: new Uint8Array(bytes), - }); const wd = createWireDebugger({ sink: () => {}, maxBytesPerTrace: 100 }); wd.observe(withBytes("s:9", 18, 1, 10, "start")); // opener, 10B for (let i = 0; i < 20; i++) { wd.observe(withBytes("s:9", 21, 2 + i, 40, "receive")); // 40B each } const [trace] = wd.traces(); - const retained = trace.frames.reduce((n, f) => n + (f.bytes?.length ?? 0), 0); + const retained = trace.frames.reduce( + (n, f) => n + (f.bytes?.length ?? 0), + 0, + ); expect(retained).toBeLessThanOrEqual(100); expect(trace.frames[0].frameId).toBe(18); // opener kept expect(trace.truncated).toBe(true); }); test("a single frame whose payload alone exceeds the byte cap sheds its bytes", () => { - const withBytes = ( - requestId: string, - frameId: number, - timestamp: number, - bytes: number, - role: FrameRole = "unknown", - ): ObservedFrame => ({ - ...frame("app.dot", requestId, frameId, timestamp, role), - byteLength: bytes, - bytes: new Uint8Array(bytes), - }); const wd = createWireDebugger({ sink: () => {}, maxBytesPerTrace: 100 }); // The opener alone is 500B — larger than the whole 100B budget. It must stay // resident as a frame (pairing/retry-storm key on frames[0]) but shed its @@ -199,11 +195,94 @@ describe("createWireDebugger grouping", () => { expect(trace.frames[0].frameId).toBe(18); // frame kept expect(trace.frames[0].byteLength).toBe(500); // metadata kept expect(trace.frames[0].bytes).toBeUndefined(); // oversized bytes shed - const retained = trace.frames.reduce((n, f) => n + (f.bytes?.length ?? 0), 0); + const retained = trace.frames.reduce( + (n, f) => n + (f.bytes?.length ?? 0), + 0, + ); expect(retained).toBeLessThanOrEqual(100); expect(trace.truncated).toBe(true); }); + test("a completed op under the byte cap keeps its response", () => { + // 700B request + 400B response under a 1000B cap: neither frame is over + // budget on its own, and the op is finished. Charging the opener's 700B to a + // budget the eviction loop reclaims from evicted the *response* of a + // completed op, which then read as `orphaned` (and, in the op list, as a + // call still waiting). + const wd = createWireDebugger({ sink: () => {}, maxBytesPerTrace: 1000 }); + wd.observe(withBytes("p:1", 22, 1, 700, "request")); + wd.observe(withBytes("p:1", 23, 2, 400, "response")); + + const [trace] = wd.traces(); + expect(trace.frames.map((f) => f.frameId)).toEqual([22, 23]); + expect(trace.frames[1].bytes?.length).toBe(400); + expect(trace.dropped.framesByBytes).toBe(0); + expect(trace.truncated).toBe(false); + }); + + test("an opener whose payload equals the byte cap does not evict every later frame", () => { + // The opener is exempt from eviction but used to be counted, so a trace whose + // opener alone filled the budget was permanently over it: every subsequent + // frame was evicted on arrival and the trace could never hold more than the + // opener. 20 receives observed, 1 frame retained, unrecoverable. + const wd = createWireDebugger({ sink: () => {}, maxBytesPerTrace: 100 }); + wd.observe(withBytes("s:2", 18, 1, 100, "start")); // opener exactly at the cap + for (let i = 0; i < 20; i++) { + wd.observe(withBytes("s:2", 21, 2 + i, 1, "receive")); // 1B each + } + const [trace] = wd.traces(); + expect(trace.frames).toHaveLength(21); + expect(trace.dropped.framesByBytes).toBe(0); + expect(trace.truncated).toBe(false); + }); + + test("dropped counts the two cap axes separately", () => { + // A boolean `truncated` renders "1 frame lost" and "77 lost" identically and + // cannot say which cap took them. + const byCount = createWireDebugger({ + sink: () => {}, + maxFramesPerTrace: 3, + }); + byCount.observe(frame("app.dot", "s:7", 18, 1, "start")); + for (let i = 0; i < 10; i++) { + byCount.observe(frame("app.dot", "s:7", 21, 2 + i, "receive")); + } + const counted = byCount.traces()[0]; + expect(counted.dropped).toEqual({ + framesByCount: 8, + framesByBytes: 0, + payloadsShed: 0, + }); + expect(counted.truncated).toBe(true); + + const byBytes = createWireDebugger({ + sink: () => {}, + maxBytesPerTrace: 100, + }); + byBytes.observe(withBytes("s:8", 18, 1, 10, "start")); + for (let i = 0; i < 20; i++) { + byBytes.observe(withBytes("s:8", 21, 2 + i, 40, "receive")); + } + const bytesTrace = byBytes.traces()[0]; + expect(bytesTrace.dropped.framesByCount).toBe(0); + expect(bytesTrace.dropped.framesByBytes).toBeGreaterThan(0); + expect(bytesTrace.dropped.payloadsShed).toBe(0); + }); + + test("a shed payload counts on its own axis, not as a lost frame", () => { + // The frame is still in the sequence with its metadata — nothing is missing + // from the op, so pairing stays sound even though bytes are gone. + const wd = createWireDebugger({ sink: () => {}, maxBytesPerTrace: 100 }); + wd.observe(withBytes("s:3", 18, 1, 500, "start")); + const [trace] = wd.traces(); + expect(trace.dropped).toEqual({ + framesByCount: 0, + framesByBytes: 0, + payloadsShed: 1, + }); + expect(trace.truncated).toBe(true); + }); + test("receives never rotate; a re-subscribe (second start) opens a new op", () => { const wd = createWireDebugger({ sink: () => {} }); wd.observe(frame("app.dot", "s:1", 18, 1, "start")); diff --git a/js/packages/truapi-debugger/src/wire-debugger.ts b/js/packages/truapi-debugger/src/wire-debugger.ts index e6fbcba6a..2d406f3ed 100644 --- a/js/packages/truapi-debugger/src/wire-debugger.ts +++ b/js/packages/truapi-debugger/src/wire-debugger.ts @@ -20,6 +20,29 @@ import type { ObservedFrame, TransportObserver } from "./observed-frame.js"; +/** + * What a trace's retention caps dropped, counted per axis. + * + * {@link WireTrace.truncated} collapses all of this to a boolean, which cannot + * tell "one frame lost" from "seventy-seven lost", nor which cap did it. The + * counts are the honest signal: `framesByCount` and `framesByBytes` are frames + * that no longer exist in the trace, `payloadsShed` frames that are still there + * with their metadata but without their payload bytes. + */ +export interface TraceDropCounts { + /** Frames evicted to stay under {@link WireDebuggerOptions.maxFramesPerTrace}. */ + framesByCount: number; + /** Frames evicted to stay under {@link WireDebuggerOptions.maxBytesPerTrace}. */ + framesByBytes: number; + /** + * Frames retained but stripped of their bytes because a single payload + * exceeded the whole byte budget. The frame, its `frameId` and its + * `byteLength` survive; only the bytes are gone, so no frame is *missing* + * from the sequence on this axis. + */ + payloadsShed: number; +} + /** * A single op's frames, in arrival order, grouped by their shared * `(channelId, requestId)`. `requestId` alone is not unique across channels - @@ -48,11 +71,18 @@ export interface WireTrace { */ generation: number; /** - * Whether older frames were dropped from this trace to stay under the frame or - * byte cap. Surfaced as a `truncated` op badge so the operator can tell "older - * frames dropped" from a genuinely short op. + * Whether anything was dropped from this trace to stay under the frame or byte + * cap: the boolean collapse of {@link WireTrace.dropped}. Surfaced as a + * `truncated` op badge so the operator can tell "older frames dropped" from a + * genuinely short op. */ truncated: boolean; + /** + * Per-axis counts behind {@link WireTrace.truncated}: how many frames each cap + * dropped, and how many payloads were shed. Lets a mount report "77 frames + * dropped (byte cap)" instead of a bare "truncated". + */ + dropped: TraceDropCounts; } /** Sink for fully-formatted debug lines (defaults to `console.debug`). */ @@ -152,15 +182,24 @@ export interface WireDebuggerOptions { */ maxFramesPerTrace?: number; /** - * Cap on total retained payload bytes within a single trace, opener included - - * a TRUE bound, so no single frame pins more than the cap. Only bites when the - * ingest retains bytes (level-2 decode); with decode off, frames carry no bytes - * and this never triggers. Without it, a burst of large payloads sharing one - * long-lived `requestId` grows memory unbounded even under - * {@link maxFramesPerTrace} (count-capped, not byte-capped). A single frame - * whose own payload exceeds the cap has its bytes shed (metadata + byteLength - * kept); otherwise oldest non-opener frames are evicted until under budget. - * Default 1 MiB. + * Cap on retained payload bytes across a trace's *evictable* frames - every + * frame but the opener. Only bites when the ingest retains bytes (level-2 + * decode); with decode off, frames carry no bytes and this never triggers. + * Without it, a burst of large payloads sharing one long-lived `requestId` + * grows memory unbounded even under {@link maxFramesPerTrace} (count-capped, + * not byte-capped). A single frame whose own payload exceeds the cap has its + * bytes shed (metadata + byteLength kept); otherwise oldest non-opener frames + * are evicted until under budget. Default 1 MiB. + * + * The opener (`frames[0]`) is never evicted - pairing (`orphaned`) and + * retry-storm both key on it - so its bytes are excluded from this budget + * rather than charged against it. Charging an un-evictable frame's bytes to a + * budget the eviction loop then tries to reclaim makes the loop evict frames + * that are not the problem: a 700B request plus a 400B response under a 1000B + * cap would evict the response of a *completed* op, and an opener whose own + * payload equals the cap would evict every frame that ever follows it. Bytes + * held by a trace are therefore bounded by the opener's own payload (itself + * capped by the shedding rule) plus this cap, not by this cap alone. */ maxBytesPerTrace?: number; /** @@ -217,9 +256,10 @@ function formatFrame( * `sink`, forwarded through `forward` (if set), and grouped into * per-`requestId` {@link WireTrace}s for correlation with product-sdk spans. */ -export function createWireDebugger(options: WireDebuggerOptions = {}): WireDebugger { - const sink: WireDebugSink = - options.sink ?? ((line) => console.debug(line)); +export function createWireDebugger( + options: WireDebuggerOptions = {}, +): WireDebugger { + const sink: WireDebugSink = options.sink ?? ((line) => console.debug(line)); const forward = options.forward; const maxTraces = options.maxTraces ?? 256; const maxFramesPerTrace = options.maxFramesPerTrace ?? 1024; @@ -276,6 +316,7 @@ export function createWireDebugger(options: WireDebuggerOptions = {}): WireDebug startedAt: frame.timestamp, lastAt: frame.timestamp, truncated: false, + dropped: { framesByCount: 0, framesByBytes: 0, payloadsShed: 0 }, }; } trace.frames.push(frame); @@ -284,12 +325,11 @@ export function createWireDebugger(options: WireDebuggerOptions = {}): WireDebug // it is the request/start the pairing (`orphaned`) and retry-storm signals // key on, so dropping it would falsely orphan a long-lived subscription // (e.g. account.connectionStatus). Ring-buffer from index 1 instead. - trace.frames.splice(1, trace.frames.length - maxFramesPerTrace); - trace.truncated = true; + const excess = trace.frames.length - maxFramesPerTrace; + trace.frames.splice(1, excess); + trace.dropped.framesByCount += excess; } - // Byte cap: only bites when bytes are retained (level-2 decode). A TRUE bound - // on retained payload, opener included, so no single frame pins more than the - // cap. + // Byte cap: only bites when bytes are retained (level-2 decode). if (frame.bytes !== undefined && maxBytesPerTrace !== Infinity) { // A single frame whose own payload exceeds the whole budget can never fit; // shed its bytes (keeping its metadata + byteLength) rather than evict every @@ -298,20 +338,31 @@ export function createWireDebugger(options: WireDebuggerOptions = {}): WireDebug for (const f of trace.frames) { if ((f.bytes?.length ?? 0) > maxBytesPerTrace) { f.bytes = undefined; - trace.truncated = true; + trace.dropped.payloadsShed += 1; } } - // Opener bytes count toward the budget too. Evict oldest non-opener frames - // (from index 1) until under budget, so one id's large payloads can't grow - // memory without bound even under the count cap. + // Budget the evictable frames only (index 1 and up). The opener can never be + // evicted, so charging its bytes to a budget this loop reclaims from would + // evict frames that are not the cause — up to and including the response of + // an already-completed op, or every frame after an opener that is itself at + // the cap. Evict oldest-first until the evictable frames are under budget. let retained = 0; - for (const f of trace.frames) retained += f.bytes?.length ?? 0; + for (let i = 1; i < trace.frames.length; i++) { + retained += trace.frames[i].bytes?.length ?? 0; + } while (retained > maxBytesPerTrace && trace.frames.length > 1) { const [removed] = trace.frames.splice(1, 1); retained -= removed?.bytes?.length ?? 0; - trace.truncated = true; + trace.dropped.framesByBytes += 1; } } + // `truncated` is the boolean collapse of the per-axis counts, so the two can + // never disagree. + trace.truncated = + trace.dropped.framesByCount + + trace.dropped.framesByBytes + + trace.dropped.payloadsShed > + 0; trace.lastAt = frame.timestamp; traces.set(key, trace); current.set(baseKey, key); diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 75da308f8..fb08bbbb5 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -161,17 +161,38 @@ const provider = await runtime.createProvider({ productId: "first.dot" }); The worker can stream every product↔core wire frame to the wire debugger. It is off by default and enabled purely from the host page — the product needs no -changes. Set a debugger URL in the host origin's `localStorage`, then run the -debugger (`@parity/truapi-debugger`, `npm run serve`, `:9231`): - -```js -localStorage.setItem("truapi:debugger", "ws://localhost:9231"); -``` - -On the next runtime boot the worker reads that URL, dials the debugger, and (via +changes. Two conditions must **both** hold or nothing dials, the core installs no +tap, and nothing is logged: + +1. **The host page is a dev build.** The `localStorage` read sits behind a hard + `import.meta.env.DEV` gate, which bundlers replace with a boolean literal: in + a production bundle it returns `null` unconditionally, so no stored key can + turn the tap on. A production build that shows no frames is this gate, not a + broken debugger. +2. **The host origin's `localStorage` carries a `ws://` loopback URL**, read on + the host page at runtime boot and forwarded to the worker in its `init` + message: + + ```js + localStorage.setItem("truapi:debugger", "ws://127.0.0.1:9231"); + ``` + +Run the debugger at the other end (`@parity/truapi-debugger`, `npm run serve`, +`127.0.0.1:9231`). On the next runtime boot the worker dials that URL and (via the Rust core's `DebugSink` tap) sends each frame as `{ channelId, dir, frame }`. -Unset in production, so nothing dials and the core installs no tap. Design: -`docs/design/wire-observability-debug-host.md`. + +The URL must be `ws://` on a loopback host. Anything else — `wss://`, `http://`, +a LAN or public address, a non-loopback hostname — yields an inert link and a +`wire debugger URL rejected` console warning; there is no certificate or `wss` +path. Prefer the literal `127.0.0.1` over `localhost`: `localhost` passes the +gate, but it resolves `::1` first on macOS while the debugger binds `127.0.0.1` +alone, so the same URL handed to a native host (`truapi-server`'s `WsDebugSink` +dials the first resolved address) silently never connects. + +The debugger owns all decoding and decodes every frame it can, including signing +and payment payloads; its safety is the dev-build gate above, not redaction. See +`js/packages/truapi-debugger/README.md` for the tap, the envelope, and the +host-dials-debugger topology. ## Publishing diff --git a/js/packages/truapi-host/src/wasm-module.ts b/js/packages/truapi-host/src/wasm-module.ts index 90e14232b..fd6ae3ca5 100644 --- a/js/packages/truapi-host/src/wasm-module.ts +++ b/js/packages/truapi-host/src/wasm-module.ts @@ -41,4 +41,12 @@ export interface WasmModuleShape { runtimeConfig: unknown, ) => WorkerProductRuntime; setLogLevel?: (level: string) => void; + /** + * The core's own `TRUAPI_WIRE_SCHEMA_HASH`, exported by `truapi-server`'s wasm + * bridge. Optional because `dist/wasm/web/` is gitignored and built by hand, so + * a stale bundle predating the export is a normal state to find at runtime; a + * core that cannot vouch for its table streams frames without a `schema` stamp + * and the debugger groups them without decoding. + */ + wireSchemaHash?: () => string; } diff --git a/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts b/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts index 0d94bbcc6..2e7ed4114 100644 --- a/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts +++ b/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts @@ -13,3 +13,4 @@ export default init; export const WasmPairingHostRuntime: WasmModuleShape["WasmPairingHostRuntime"]; export const WasmProductRuntime: WasmModuleShape["WasmProductRuntime"]; export const setLogLevel: (level: string) => void; +export const wireSchemaHash: () => string; diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index 14100b2f4..b5fc0af40 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -10,7 +10,7 @@ import type { WorkerToMain, } from "./worker-protocol.js"; import type { GenericError } from "@parity/truapi"; -import { TRUAPI_CODEC_VERSION, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; +import { TRUAPI_CODEC_VERSION } from "@parity/truapi"; import { createWorkerRawCallbacks, type CallbackName, @@ -218,13 +218,97 @@ export function isLoopbackWsUrl(url: string): boolean { } } +/** + * The wire-contract fingerprint of the core that *encodes* the frames, or + * `undefined` when this build of the core does not report one. + * + * The debugger decodes each frame against a `frameId → method` table, so the + * `schema` an envelope carries has to be the fingerprint of the table the bytes + * were encoded with. That is the WASM core's, not `@parity/truapi`'s: the client + * and the core are separate artifacts, and `dist/wasm/web/` is gitignored and + * built by hand (`make wasm`), so a stale core beside a fresh client is the + * everyday case rather than an exotic one. Stamping the client's hash there would + * make the debugger *confirm* identity on frames from a different table and decode + * them into the wrong methods and values, silently. + * + * When the core does not report a hash, the envelope carries none. The debugger + * treats an unstamped frame as unconfirmed: it still groups the op, but refuses + * to decode values. Losing decode until `make wasm` is rerun is the honest + * outcome; a confident wrong decode is not. + */ +export function coreWireSchemaHash(module: { + wireSchemaHash?: () => string; +}): string | undefined { + let hash: unknown; + try { + hash = module.wireSchemaHash?.(); + } catch { + hash = undefined; + } + if (typeof hash === "string" && hash.length > 0) return hash; + console.warn( + "[truapi] wire debugger: this WASM core does not report its wire-schema hash — frames will stream without a `schema` stamp and the debugger will group them but refuse to decode values (rebuild the core with `make wasm`)", + ); + return undefined; +} + +/** + * The socket surface the debugger link uses. A `WebSocket` satisfies it; tests + * substitute a fake to drive backpressure and reconnect timing without a network. + */ +export interface DebuggerSocket { + /** Bytes handed to the socket that it has not yet put on the wire. */ + readonly bufferedAmount: number; + send(data: string): void; + close(): void; + addEventListener(type: "open" | "close" | "error", listener: () => void): void; +} + +/** Construction options for {@link createDebuggerLink}. */ +export interface DebuggerLinkOptions { + /** + * The encoding core's wire-schema hash, from {@link coreWireSchemaHash}. When + * omitted, envelopes carry no `schema` and the debugger refuses value decode + * rather than trusting a hash the core never vouched for. + */ + schema?: string; + /** Socket factory. Defaults to a real `WebSocket`; tests inject a fake. */ + createSocket?: (url: string) => DebuggerSocket; + /** Deferred scheduler for reconnect backoff. Defaults to `setTimeout`. */ + schedule?: (run: () => void, delayMs: number) => void; +} + +/** Initial reconnect delay; doubles per failed dial up to {@link RECONNECT_MAX_MS}. */ +const RECONNECT_BASE_MS = 200; + +/** Cap on the reconnect backoff. Mirrors the native sink's `MAX_BACKOFF`. */ +const RECONNECT_MAX_MS = 5000; + +/** + * Ceiling on the socket's *own* unflushed send buffer before frames are shed. + * + * The queue caps below only bound what this module holds while the socket is + * down. A socket that is open but whose peer has stopped reading keeps + * `readyState === OPEN` while `bufferedAmount` grows without limit, and that + * growth is charged to the observed session's worker: handing frames to it + * unchecked is the same unbounded buffering the queue caps exist to prevent, one + * layer lower. Over this ceiling, frames are shed into the counted `dropped` + * instead. + */ +const MAX_SOCKET_BUFFERED_BYTES = 8 * 1024 * 1024; + /** * Dev-only link to the debugger the host dials. Fire-and-forget by construction: * it opens lazily, buffers a bounded backlog until the socket is up, retries a - * dropped connection, and swallows every error - a slow, absent, or crashed - * debugger only loses the trace, it can never throw into the frame path. + * dropped connection with capped backoff, sheds frames (counted) rather than + * buffering without bound at either layer, and swallows every error - a slow, + * absent, or crashed debugger only loses the trace, it can never throw into the + * frame path. */ -function createDebuggerLink(url: string): { +export function createDebuggerLink( + url: string, + options: DebuggerLinkOptions = {}, +): { emit(channelId: string, dir: string, frame: Uint8Array): void; } { // Loopback-only, dev-only: a non-loopback (or non-ws://) debugger URL yields an @@ -236,7 +320,15 @@ function createDebuggerLink(url: string): { ); return { emit() {} }; } - let socket: WebSocket | null = null; + const createSocket = + options.createSocket ?? ((target: string) => new WebSocket(target)); + const schedule = + options.schedule ?? + ((run: () => void, delayMs: number) => { + setTimeout(run, delayMs); + }); + const schema = options.schema; + let socket: DebuggerSocket | null = null; let open = false; const queue: string[] = []; // Count *and* byte caps: each queued item is a base64 ProtocolMessage (storage @@ -247,49 +339,86 @@ function createDebuggerLink(url: string): { const MAX_QUEUE_BYTES = 8 * 1024 * 1024; let queuedBytes = 0; let droppedSinceSend = 0; + let reconnectDelayMs = RECONNECT_BASE_MS; + let reconnectScheduled = false; + + /** + * Dial again after the current backoff, at most one dial in flight. + * + * Without the delay this ran once per frame: a busy session with no debugger + * listening dialed loopback hundreds of times a second (each refused + * immediately, each logging a console error), because every emit found + * `socket === null` and redialled. The native sink has always backed off; this + * mirrors it. + */ + function scheduleReconnect(): void { + if (socket !== null || reconnectScheduled) return; + reconnectScheduled = true; + const delayMs = reconnectDelayMs; + reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_MAX_MS); + try { + schedule(() => { + reconnectScheduled = false; + if (socket === null) connect(); + }, delayMs); + } catch { + // No timer available: fall back to redialling on the next emit. + reconnectScheduled = false; + } + } + + /** Drain the backlog onto a freshly opened socket. */ + function flush(): void { + const pending = queue.splice(0); + queuedBytes = 0; + // Deliver drops accumulated while disconnected by stamping the count on the + // first drained frame - a bare marker without channelId/dir/frame wouldn't + // parse server-side. Drops only happen once the queue is full, so when the + // count is nonzero there is always a pending frame to carry it; if not, it + // rides the next live emit. + if (pending.length > 0 && droppedSinceSend > 0) { + try { + const first = JSON.parse(pending[0]) as Record; + first.dropped = droppedSinceSend; + pending[0] = JSON.stringify(first); + droppedSinceSend = 0; + } catch { + // Leave the frame as-is; the count rides the next live emit. + } + } + for (const message of pending) send(message); + } function connect(): void { + let dialed: DebuggerSocket; try { - socket = new WebSocket(url); + dialed = createSocket(url); } catch { socket = null; + scheduleReconnect(); return; } - socket.addEventListener("open", () => { + socket = dialed; + dialed.addEventListener("open", () => { open = true; - const pending = queue.splice(0); - queuedBytes = 0; - // Deliver drops accumulated while disconnected by stamping the count on the - // first drained frame - a bare marker without channelId/dir/frame wouldn't - // parse server-side. Drops only happen once the queue is full, so when the - // count is nonzero there is always a pending frame to carry it; if not, it - // rides the next live emit. - if (pending.length > 0 && droppedSinceSend > 0) { - try { - const first = JSON.parse(pending[0]) as Record; - first.dropped = droppedSinceSend; - pending[0] = JSON.stringify(first); - droppedSinceSend = 0; - } catch { - // Leave the frame as-is; the count rides the next live emit. - } - } - for (const message of pending) send(message); + // A dial that reached the debugger earns the short delay back, so a + // debugger that restarts is picked up promptly rather than after the cap. + reconnectDelayMs = RECONNECT_BASE_MS; + flush(); }); - socket.addEventListener("close", () => { + dialed.addEventListener("close", () => { open = false; - socket = null; + if (socket === dialed) socket = null; }); - socket.addEventListener("error", () => { + dialed.addEventListener("error", () => { // A socket that fired `error` is dead: close it explicitly (tidiness), then - // null it so `emit`'s `if (!socket) connect()` reconnects. Without the null, - // a runtime that fires `error` without a following `close` would leave - // `socket` non-null and frames would buffer then drop. + // null it so the next emit schedules a redial. Without the null, a runtime + // that fires `error` without a following `close` would leave `socket` + // non-null and frames would buffer then drop. open = false; - const dead = socket; - socket = null; + if (socket === dialed) socket = null; try { - dead?.close(); + dialed.close(); } catch { // already closed / closing } @@ -307,6 +436,20 @@ function createDebuggerLink(url: string): { connect(); let warnedDrop = false; + /** Shed one frame into the counted backlog gap. */ + function shed(): void { + droppedSinceSend += 1; + if (!warnedDrop) { + // The link buffers a bounded backlog while the debugger is absent/slow, and + // stops handing frames to a socket that is not draining. Warn once so the + // gap is attributable to the link, not the host. + warnedDrop = true; + console.warn( + "[truapi] wire debugger link is not keeping up — dropping frames (counted in `dropped`) until it drains", + ); + } + } + return { emit(channelId, dir, frame) { // A debug tap must never throw into the observed frame path: toBase64 / @@ -314,15 +457,27 @@ function createDebuggerLink(url: string): { // limits), and only send() swallows its own errors. Losing a trace is fine; // breaking dispatch is not. try { + const live = open ? socket : null; + // Checked before encoding, so a shed frame costs no base64 either. + if (live !== null && live.bufferedAmount > MAX_SOCKET_BUFFERED_BYTES) { + shed(); + return; + } const base = { v: WIRE_ENVELOPE_VERSION, codec: TRUAPI_CODEC_VERSION, - schema: TRUAPI_WIRE_SCHEMA_HASH, + // Only when the core vouched for it: see coreWireSchemaHash. + ...(schema !== undefined ? { schema } : {}), channelId, dir, + // The producer is the only party that knows when the frame crossed. The + // debugger's own clock is the flush instant for anything that waited in + // the queue below, which collapses every duration in a backlog to 0ms + // and pulls ops minutes apart into one retry-storm window. + observedAt: Date.now(), frame: toBase64(frame), }; - if (open && socket) { + if (live !== null) { // Piggyback any frames dropped while the link was down onto the next // live frame, so the debugger attributes the gap to the link, not the // host. @@ -334,7 +489,12 @@ function createDebuggerLink(url: string): { droppedSinceSend = 0; return; } - const message = JSON.stringify(base); + // Nothing leaves the queue except through flush(), so everything that + // enters it is by definition replayed rather than live: mark it here and + // the debugger can tell a backlog gap from a quiet session. Its + // `observedAt` above is already the real crossing time, so the marker is + // provenance, not a correction. + const message = JSON.stringify({ ...base, buffered: true }); if ( queue.length < MAX_QUEUE && queuedBytes + message.length <= MAX_QUEUE_BYTES @@ -342,18 +502,9 @@ function createDebuggerLink(url: string): { queue.push(message); queuedBytes += message.length; } else { - droppedSinceSend += 1; - if (!warnedDrop) { - // The link buffers a bounded backlog while the debugger is - // absent/slow; once full (by count or bytes), frames are dropped. - // Warn once so the gap is attributable to the link, not the host. - warnedDrop = true; - console.warn( - "[truapi] wire debugger link queue full — dropping frames until it drains", - ); - } + shed(); } - if (!socket) connect(); + scheduleReconnect(); } catch { // Swallow: never let the tap disturb the frame path. } @@ -417,7 +568,12 @@ ctx.addEventListener("message", (ev: MessageEvent) => { } wasm.setLogLevel?.(msg.logLevel); if (msg.debuggerUrl && !debuggerLink) { - debuggerLink = createDebuggerLink(msg.debuggerUrl); + // The hash comes from the core that will encode the frames, not from this + // package's client constant: they are separate artifacts and the WASM + // bundle is built by hand. + debuggerLink = createDebuggerLink(msg.debuggerUrl, { + schema: coreWireSchemaHash(wasm), + }); } try { runtime = new wasm.WasmPairingHostRuntime( diff --git a/js/packages/truapi/README.md b/js/packages/truapi/README.md index 3b6a0ecbc..b2a34aab6 100644 --- a/js/packages/truapi/README.md +++ b/js/packages/truapi/README.md @@ -108,17 +108,25 @@ The debugger does not live in this package, and the product transport carries no its Rust core (`truapi-server`'s `DebugSink`) and streams each one - as `{ channelId, dir, frame: bytes }`, opaque bytes - to a separate debugger app, which decodes and groups them. -- Architecture (the tap, the envelope, the host-dials-debugger topology, `wss`/cert setup): - `docs/design/wire-observability-debug-host.md`. -- The debugger app itself (trace + envelope-decode engines + the WS server): `@parity/truapi-debugger`. +- The tap: `DebugSink` in `rust/crates/truapi-server/src/host_core.rs`, unset by default. It is read + at two choke points — inbound before the frame is decoded, outbound after the product's copy is + sent — and is fire-and-forget, so an absent or slow debugger loses traces, never a session. +- Topology: the host always dials the debugger, over `ws://` on a loopback host **only**. `wss://`, + certificates, and non-loopback targets are rejected by both the native + (`truapi-server/src/native_debug.rs`) and web (`@parity/truapi-host`'s worker) dial gates. +- The debugger app itself (trace, envelope-decode, and value-decode engines; the standalone WS + server and the in-app embed): `@parity/truapi-debugger`, documented in + `js/packages/truapi-debugger/README.md`. The generated `WIRE_DECODE_TABLE` on the `./wire-decode` subpath (raw SCALE bytes → typed value) stays here, since it is generated from this package's contract. It is the decode source the [`@parity/truapi-debugger`](../truapi-debugger/) app uses to render frame values. That app is a -strictly dev-only tool: it decodes every frame by default (there is no redaction), and its safety -is that it is compiled out of production, not that it hides fields. `TRUAPI_DEBUGGER_DECODE_VALUES=0` -turns decode off for a payload-blind demo. `@parity/truapi` itself never decodes payloads — the -envelope decode it does expose (`decodeWireMessage`: `requestId`, frame id) carries no payload value. +strictly dev-only tool: it decodes every frame by default (there is no redaction, no denylist, and no +reveal toggle), and its safety is that a host must opt the tap in — which the web host's +`import.meta.env.DEV` gate makes impossible in a production bundle — not that it hides fields. +`TRUAPI_DEBUGGER_DECODE_VALUES=0` turns decode off for a payload-blind demo. `@parity/truapi` itself +never decodes payloads — the envelope decode it does expose (`decodeWireMessage`: `requestId`, frame +id) carries no payload value. ## Wire format diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index c0ee624a7..56bc0e810 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -662,17 +662,26 @@ fn generate_wire_table(api: &ApiDefinition, target_version: u32) -> Result Result> { +/// One row of the wire contract: a frame id, its method leg, whether the method +/// is `sensitive`, and the structural signature of the payload that frame +/// carries. +type WireIdRow = (u8, String, bool, String); + +fn wire_id_rows(api: &ApiDefinition, target_version: u32) -> Result> { let wrappers = collect_versioned_wrappers(api); - let mut seen: BTreeMap = BTreeMap::new(); + let types = types_by_name(api); + let mut seen: BTreeMap = BTreeMap::new(); for trait_def in &api.traits { for method in &trait_def.methods { if !method_is_included(trait_def, method, &wrappers, target_version)? { continue; } let wire_ids = wire_ids_for_method(trait_def, method)?; + let payload = method_payload_signature(method, &types); for (id, tag) in wire_ids.entries(&method.name) { - if let Some((existing, _)) = seen.insert(id, (tag.clone(), method.wire.sensitive)) { + if let Some((existing, _, _)) = + seen.insert(id, (tag.clone(), method.wire.sensitive, payload.clone())) + { bail!("wire id {id} reused: `{existing}` and `{tag}` collide"); } } @@ -680,10 +689,169 @@ fn wire_id_rows(api: &ApiDefinition, target_version: u32) -> Result HashMap<&str, &TypeDef> { + api.types + .iter() + .map(|def| (def.name.as_str(), def)) + .collect() +} + +/// Structural signature of everything a method puts on the wire: its parameters +/// (the request/start payload) and its return shape (the response/item payload). +/// +/// Folded into the wire schema hash so the fingerprint moves when a payload's +/// *layout* changes, not only when a frame id or method name does. +fn method_payload_signature(method: &MethodDef, types: &HashMap<&str, &TypeDef>) -> String { + let mut out = String::new(); + for param in &method.params { + let sig = type_signature(¶m.type_ref, types, &mut Vec::new()); + let _ = write!(out, "{}:{sig},", param.name); + } + out.push_str("->"); + match &method.return_type { + ReturnType::Result { ok, err } => { + let _ = write!( + out, + "res<{},{}>", + type_signature(ok, types, &mut Vec::new()), + type_signature(err, types, &mut Vec::new()) + ); + } + ReturnType::Subscription(item) => { + let _ = write!(out, "sub<{}>", type_signature(item, types, &mut Vec::new())); + } + ReturnType::ResultSubscription { item, err } => { + let _ = write!( + out, + "ressub<{},{}>", + type_signature(item, types, &mut Vec::new()), + type_signature(err, types, &mut Vec::new()) + ); + } + } + out +} + +/// Canonical structural rendering of a type: field order and field types for a +/// struct, positional variant indices and payloads for an enum, resolved +/// transitively. +/// +/// Two layouts that encode differently under SCALE cannot render the same +/// string: field order, field types, variant order, and arity all appear. A type +/// this crate does not own (external or generic) degrades to its name, which is +/// the most that is knowable from rustdoc. `seen` guards recursive types. +fn type_signature( + type_ref: &TypeRef, + types: &HashMap<&str, &TypeDef>, + seen: &mut Vec, +) -> String { + match type_ref { + TypeRef::Primitive(name) => name.clone(), + TypeRef::Unit => "()".to_string(), + TypeRef::Generic(name) => format!("generic:{name}"), + TypeRef::Vec(inner) => format!("vec<{}>", type_signature(inner, types, seen)), + TypeRef::Option(inner) => format!("opt<{}>", type_signature(inner, types, seen)), + TypeRef::Array(inner, len) => { + format!("[{};{len}]", type_signature(inner, types, seen)) + } + TypeRef::Tuple(items) => { + let inner: Vec = items + .iter() + .map(|item| type_signature(item, types, seen)) + .collect(); + format!("({})", inner.join(",")) + } + TypeRef::Named { name, args } => { + let rendered_args: Vec = args + .iter() + .map(|arg| type_signature(arg, types, seen)) + .collect(); + let suffix = if rendered_args.is_empty() { + String::new() + } else { + format!("<{}>", rendered_args.join(",")) + }; + // A type already on the walk stack is recursive; naming it closes the + // cycle without losing that the edge exists. + if seen.iter().any(|entry| entry == name) { + return format!("rec:{name}{suffix}"); + } + let Some(def) = types.get(name.as_str()) else { + return format!("{name}{suffix}"); + }; + seen.push(name.clone()); + let body = match &def.kind { + TypeDefKind::Alias(inner) => { + format!("={}", type_signature(inner, types, seen)) + } + TypeDefKind::Struct(fields) => { + let rendered: Vec = fields + .iter() + .map(|field| { + format!( + "{}:{}", + field.name, + type_signature(&field.type_ref, types, seen) + ) + }) + .collect(); + format!("{{{}}}", rendered.join(",")) + } + TypeDefKind::TupleStruct(items) => { + let rendered: Vec = items + .iter() + .map(|item| type_signature(item, types, seen)) + .collect(); + format!("({})", rendered.join(",")) + } + TypeDefKind::Enum(variants) => { + let rendered: Vec = variants + .iter() + .enumerate() + .map(|(index, variant)| { + let payload = match &variant.fields { + VariantFields::Unit => String::new(), + VariantFields::Unnamed(items) => { + let inner: Vec = items + .iter() + .map(|item| type_signature(item, types, seen)) + .collect(); + format!("({})", inner.join(",")) + } + VariantFields::Named(fields) => { + let inner: Vec = fields + .iter() + .map(|field| { + format!( + "{}:{}", + field.name, + type_signature(&field.type_ref, types, seen) + ) + }) + .collect(); + format!("{{{}}}", inner.join(",")) + } + }; + // The positional index is the SCALE discriminant, so a + // reorder must change the signature. + format!("{index}:{}{payload}", variant.name) + }) + .collect(); + format!("|{}|", rendered.join(";")) + } + }; + seen.pop(); + format!("{name}{suffix}{body}") + } + } +} + /// A stable fingerprint of the wire contract: every frame id, the method leg it /// resolves to, and its sensitivity, folded together with the codec version. /// Two builds whose frame tables differ - a reassigned id, a renamed or @@ -698,9 +866,9 @@ pub(crate) fn wire_schema_hash( codec_version: u8, ) -> Result { let mut canonical = format!("codec={codec_version}\n"); - for (id, tag, sensitive) in wire_id_rows(api, target_version)? { + for (id, tag, sensitive, payload) in wire_id_rows(api, target_version)? { let flag = u8::from(sensitive); - canonical.push_str(&format!("{id}:{tag}:{flag}\n")); + canonical.push_str(&format!("{id}:{tag}:{flag}:{payload}\n")); } // FNV-1a 64-bit: deterministic across platforms and Rust versions (unlike // `DefaultHasher`), dependency-free, and ample for a contract fingerprint. @@ -2756,6 +2924,191 @@ mod tests { } } + /// Build a one-method API whose request payload is `struct Payload`, with the + /// given named fields, so a test can vary only the payload layout. + fn api_with_payload_fields(fields: Vec<(&str, TypeRef)>) -> ApiDefinition { + let payload = TypeDef { + name: "Payload".to_string(), + module_path: Vec::new(), + generic_params: Vec::new(), + kind: TypeDefKind::Struct( + fields + .into_iter() + .map(|(name, type_ref)| FieldDef { + name: name.to_string(), + type_ref, + docs: None, + }) + .collect(), + ), + docs: None, + }; + let method = MethodDef { + name: "do_thing".to_string(), + kind: MethodKind::Request, + params: vec![ParamDef { + name: "request".to_string(), + type_ref: TypeRef::Named { + name: "Payload".to_string(), + args: Vec::new(), + }, + }], + return_type: ReturnType::Result { + ok: TypeRef::Unit, + err: TypeRef::Unit, + }, + wire: request_wire(Some(7)), + docs: None, + }; + ApiDefinition { + traits: vec![TraitDef { + name: "Thing".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Thing".to_string()], + types: vec![payload], + } + } + + #[test] + fn schema_hash_moves_when_a_payload_field_type_changes() { + // The drift class this fingerprint exists to catch: same frame ids, same + // method names, same sensitivity - only a field's width changed. A newer + // host's bytes would otherwise decode on the old table without throwing, + // silently yielding wrong values (the shape of the getAccount P0). + let before = api_with_payload_fields(vec![ + ("ring_index", TypeRef::Primitive("u32".to_string())), + ("ring_revision", TypeRef::Primitive("u32".to_string())), + ]); + let after = api_with_payload_fields(vec![ + ("ring_index", TypeRef::Primitive("u64".to_string())), + ("ring_revision", TypeRef::Primitive("u32".to_string())), + ]); + + assert_ne!( + wire_schema_hash(&before, 1, 1).unwrap(), + wire_schema_hash(&after, 1, 1).unwrap(), + ); + } + + #[test] + fn schema_hash_moves_when_same_width_payload_fields_are_reordered() { + // Nastier than a width change: the frame length is identical, so no + // arithmetic check can see it and the decode cannot fail - the values + // simply swap. + let before = api_with_payload_fields(vec![ + ("ring_index", TypeRef::Primitive("u32".to_string())), + ("ring_revision", TypeRef::Primitive("u32".to_string())), + ]); + let after = api_with_payload_fields(vec![ + ("ring_revision", TypeRef::Primitive("u32".to_string())), + ("ring_index", TypeRef::Primitive("u32".to_string())), + ]); + + assert_ne!( + wire_schema_hash(&before, 1, 1).unwrap(), + wire_schema_hash(&after, 1, 1).unwrap(), + ); + } + + #[test] + fn schema_hash_is_stable_for_an_unchanged_contract() { + // The fingerprint must not be noisy: an identical contract hashes + // identically, or every host would look drifted. + let api = + api_with_payload_fields(vec![("ring_index", TypeRef::Primitive("u32".to_string()))]); + + assert_eq!( + wire_schema_hash(&api, 1, 1).unwrap(), + wire_schema_hash(&api, 1, 1).unwrap(), + ); + } + + #[test] + fn type_signature_terminates_on_a_recursive_type() { + // `struct Node { next: Option }` must not recurse forever. + let node = TypeDef { + name: "Node".to_string(), + module_path: Vec::new(), + generic_params: Vec::new(), + kind: TypeDefKind::Struct(vec![FieldDef { + name: "next".to_string(), + type_ref: TypeRef::Option(Box::new(TypeRef::Named { + name: "Node".to_string(), + args: Vec::new(), + })), + docs: None, + }]), + docs: None, + }; + let types: HashMap<&str, &TypeDef> = [("Node", &node)].into_iter().collect(); + + let sig = type_signature( + &TypeRef::Named { + name: "Node".to_string(), + args: Vec::new(), + }, + &types, + &mut Vec::new(), + ); + + assert!(sig.contains("rec:Node"), "unexpected signature: {sig}"); + } + + #[test] + fn schema_hash_moves_when_an_enum_variant_is_reordered() { + // Variant position is the SCALE discriminant, so a reorder silently + // renumbers every variant on the wire. + let variant = |name: &str| VariantDef { + name: name.to_string(), + fields: VariantFields::Unit, + docs: None, + }; + let build = |names: [&str; 2]| { + let enum_def = TypeDef { + name: "Choice".to_string(), + module_path: Vec::new(), + generic_params: Vec::new(), + kind: TypeDefKind::Enum(names.iter().map(|n| variant(n)).collect()), + docs: None, + }; + let method = MethodDef { + name: "do_thing".to_string(), + kind: MethodKind::Request, + params: vec![ParamDef { + name: "choice".to_string(), + type_ref: TypeRef::Named { + name: "Choice".to_string(), + args: Vec::new(), + }, + }], + return_type: ReturnType::Result { + ok: TypeRef::Unit, + err: TypeRef::Unit, + }, + wire: request_wire(Some(7)), + docs: None, + }; + ApiDefinition { + traits: vec![TraitDef { + name: "Thing".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Thing".to_string()], + types: vec![enum_def], + } + }; + + assert_ne!( + wire_schema_hash(&build(["Allow", "Deny"]), 1, 1).unwrap(), + wire_schema_hash(&build(["Deny", "Allow"]), 1, 1).unwrap(), + ); + } + #[test] fn service_display_name_formats_known_acronyms() { let json_rpc = TraitDef { diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index cd1df9b32..2134f08f6 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -50,7 +50,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "06adc386fa1a18a3"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "f6e1362d4bdb4b9f"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { diff --git a/rust/crates/truapi-macros/src/lib.rs b/rust/crates/truapi-macros/src/lib.rs index ed3caa1f2..649a97575 100644 --- a/rust/crates/truapi-macros/src/lib.rs +++ b/rust/crates/truapi-macros/src/lib.rs @@ -85,9 +85,12 @@ impl Parse for WireArgs { } args.host_initiated = true; } else if key == "sensitive" { - // `sensitive` is a bare flag with no `= N` value: it marks the - // method's payloads as carrying key material or bearer secrets, - // so the wire debugger never decodes them. + // `sensitive` is a bare flag with no `= N` value: it classifies + // the method's payloads as carrying key material or bearer + // secrets. The classification is folded into the wire + // schema-hash fingerprint, so a change in a frame's sensitivity + // is caught as contract drift. It suppresses no decoding: it + // reaches neither the generated TS nor any runtime. if args.sensitive { return Err(syn::Error::new(key.span(), "duplicate `sensitive`")); } @@ -152,9 +155,12 @@ fn set_id(args: &mut WireArgs, key: &Ident, value: u8) -> syn::Result<()> { /// #[wire(start_id = 42)] /// async fn host_account_connection_status_subscribe(...) -> ...; /// -/// // Mark a method whose payloads carry key material or bearer secrets. The -/// // flag is folded into the wire schema-hash fingerprint, so a change in a -/// // frame's sensitivity classification is caught as contract drift. +/// // Classify a method whose payloads carry key material or bearer secrets. +/// // The flag is folded into the wire schema-hash fingerprint, so a change in a +/// // frame's sensitivity classification is caught as contract drift. It is a +/// // classification only, and grants no confidentiality: it reaches neither the +/// // generated TypeScript nor any runtime, and nothing suppresses decoding of +/// // the payload. /// #[wire(request_id = 114, sensitive)] /// async fn sign_raw(...) -> ...; /// ``` diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index cd1df9b32..2134f08f6 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -50,7 +50,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "06adc386fa1a18a3"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "f6e1362d4bdb4b9f"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 2ce00ad68..3195992bb 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -53,10 +53,14 @@ pub trait FrameSink: Send + Sync { pub trait DebugSink: Send + Sync { /// Hand one event to the sink. /// - /// Must not block, and must not panic: `emit` is called from inside the - /// inbound and outbound frame paths, so a panic here would unwind into a - /// live dispatch. Serialize and enqueue only; never do fallible work that - /// can `unwrap`/panic on the caller's thread. + /// Must not block, and must not panic. This is a contract on the + /// implementor, not something the core can enforce: `emit` is called from + /// inside the inbound and outbound frame paths, and every profile that + /// ships a host aborts on panic (`panic = "abort"` for `release`, which + /// `codegen` inherits, and `wasm32` cannot unwind at all), so a panic here + /// takes the whole host process down rather than losing one trace. Serialize + /// and enqueue only; never do fallible work that can `unwrap`/panic on the + /// caller's thread. fn emit(&self, event: DebugEvent); } @@ -89,22 +93,6 @@ impl FrameDirection { } } -/// Hand one event to a [`DebugSink`] without letting a misbehaving out-of-repo -/// implementation take down a live dispatch. -/// -/// The trait contract forbids `emit` from panicking, but the trait is `pub`, so -/// this guards the two in-path call sites: a panic is caught, logged, and -/// swallowed. `DebugEvent` is `UnwindSafe` (a `ChannelId`/`Vec`), so the -/// caught closure carries no broken invariant across the boundary. -fn emit_debug(sink: &dyn DebugSink, event: DebugEvent) { - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { - sink.emit(event); - })); - if result.is_err() { - tracing::error!("truapi debug sink panicked in emit; frame dropped, session unaffected"); - } -} - /// One observable host debug event. Frame bytes are the untouched /// `ProtocolMessage`; the debugger decodes them, so the core never does. The /// enum leaves room for host-internal events (e.g. SSO) that have no wire frame, @@ -961,14 +949,11 @@ impl ProductRuntime { // Tap inbound before decode, so a corrupt frame is still observed. if let Some((channel_id, debug)) = self.transport.debug() { - emit_debug( - debug.as_ref(), - DebugEvent::Frame { - channel_id, - dir: FrameDirection::In, - bytes: frame.clone(), - }, - ); + debug.emit(DebugEvent::Frame { + channel_id, + dir: FrameDirection::In, + bytes: frame.clone(), + }); } let message = ProtocolMessage::decode(&mut frame.as_slice()).map_err(|err| { @@ -1121,14 +1106,11 @@ impl Transport for SinkTransport { match self.debug() { Some((channel_id, debug)) => { self.sink.emit_frame(encoded.clone()); - emit_debug( - debug.as_ref(), - DebugEvent::Frame { - channel_id, - dir: FrameDirection::Out, - bytes: encoded, - }, - ); + debug.emit(DebugEvent::Frame { + channel_id, + dir: FrameDirection::Out, + bytes: encoded, + }); } None => self.sink.emit_frame(encoded), } @@ -1292,45 +1274,39 @@ mod tests { ); } - struct PanickingDebugSink; - - impl DebugSink for PanickingDebugSink { - fn emit(&self, _event: DebugEvent) { - panic!("misbehaving out-of-repo debug sink"); - } - } - + /// [`DebugSink::emit`] documents its no-panic rule as caller-enforced + /// because every profile that ships a host aborts on panic, so no in-process + /// guard is possible. A `catch_unwind` around the two tap call sites could + /// never fire there, and no unit test could show that: Cargo ignores the + /// `panic` setting for test profiles, so a "the guard protects dispatch" test + /// passes even under `--release`. + /// + /// What *is* checkable is the premise. This fails if the profiles stop + /// aborting, which is the point at which the doc comment on `DebugSink::emit` + /// needs revisiting (and a guard becomes worth its cost). #[test] - fn a_panicking_debug_sink_does_not_take_down_the_dispatch() { - // The trait forbids panicking, but it is `pub`, so a bad out-of-repo sink - // could. `emit_debug` catches it: `receive_frame` must still succeed. - let (host_config, product) = runtime_config("myapp.dot"); - let runtime = ProductRuntime::from_platform_with_config( - Arc::new(StubPlatform::default()), - host_config, - product, - test_spawner(), - Arc::new(RecordingSink::default()), - ); - runtime.set_debug_sink( - ChannelId("myapp.dot".to_string()), - Arc::new(PanickingDebugSink), + fn shipping_profiles_abort_on_panic_so_the_sink_contract_is_caller_enforced() { + let workspace_manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .expect("crate lives at /rust/crates/truapi-server") + .join("Cargo.toml"); + let manifest = std::fs::read_to_string(&workspace_manifest) + .expect("workspace manifest is readable from the crate directory"); + let release = manifest + .split("[profile.release]") + .nth(1) + .expect("workspace defines [profile.release]") + .split("\n[") + .next() + .expect("release profile section"); + assert!( + release.contains("panic = \"abort\""), + "release no longer aborts on panic: revisit DebugSink::emit's contract docs" ); - - let ids = subscription_ids("theme_subscribe").expect("known subscription"); - let raw = ProtocolMessage { - request_id: "theme:1".to_string(), - payload: Payload { - id: ids.start_id, - value: Vec::new(), - }, - } - .encode(); - // The inbound tap panics inside receive_frame; the guard swallows it. - let result = futures::executor::block_on(runtime.receive_frame(raw)); assert!( - result.is_ok(), - "a panicking sink must not fail the dispatch" + manifest.contains("[profile.codegen]") && manifest.contains("inherits = \"release\""), + "codegen no longer inherits release: recheck what the ws-bridge artifacts build with" ); } diff --git a/rust/crates/truapi-server/src/native_debug.rs b/rust/crates/truapi-server/src/native_debug.rs index 42c9054cc..5fe122dcf 100644 --- a/rust/crates/truapi-server/src/native_debug.rs +++ b/rust/crates/truapi-server/src/native_debug.rs @@ -12,6 +12,9 @@ //! serializes and pushes onto a bounded queue; a background task owns the socket, //! reconnects with capped backoff, and drops frames (counted) when the queue is //! full. A slow, absent, or crashed debugger loses traces, never a session. +//! Dropped frames are reported on the wire: the count shed since the previous +//! envelope rides the next one as `dropped`, so the debugger attributes the gap +//! to the link instead of reading it as a host that never answered. //! //! Localhost only: the target URL must be `ws://` on a loopback host. No `wss`, //! no certificates, no LAN. Construct via [`WsDebugSink::connect`] from within a @@ -32,8 +35,8 @@ use thiserror::Error; use tokio::net::TcpStream; use tokio::runtime::Handle; use tokio::sync::mpsc; -use tokio_tungstenite::client_async; use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{WebSocketStream, client_async}; use tracing::debug; use crate::generated::wire_table::TRUAPI_WIRE_SCHEMA_HASH; @@ -58,6 +61,11 @@ const WIRE_ENVELOPE_VERSION: u32 = 1; /// its own, rather than resolving `u8` frame ids against the wrong contract. const WIRE_CODEC_VERSION: u32 = 1; +/// Port the debugger's server listens on (`@parity/truapi-debugger`'s +/// `npm run serve`), used when the debug URL omits one so `ws://localhost` +/// reaches the debugger instead of HTTP's port 80. +const DEBUGGER_DEFAULT_PORT: u16 = 9231; + /// Initial reconnect delay; doubles on each failed dial up to [`MAX_BACKOFF`]. const INITIAL_BACKOFF: Duration = Duration::from_millis(200); @@ -89,11 +97,21 @@ pub enum DebugSinkError { /// over a WebSocket, using the same `{channelId, dir, frame: base64}` envelope /// the browser host sends. pub struct WsDebugSink { - outbound: mpsc::Sender, + outbound: mpsc::Sender, dropped: Arc, + pending_dropped: Arc, queued_bytes: Arc, } +/// One serialized envelope on its way to the writer task, plus the number of +/// shed frames stamped on it. Carrying the count alongside the line lets the +/// writer put it back if this envelope dies with the socket, so a drop is +/// reported exactly once and never silently swallowed. +struct QueuedFrame { + line: String, + shed: u64, +} + /// The wire envelope, matching the debugger's `parseWireMessage` / ingest /// `DebugFrameEnvelope`: `dir` is product-vantage, `frame` is base64 SCALE bytes. /// `v`/`codec` are the identity the debugger checks before decoding. @@ -106,6 +124,46 @@ struct WireMessage<'a> { channel_id: &'a str, dir: &'a str, frame: String, + /// Frames this link shed since the previous envelope. Omitted when zero, as + /// the web link omits it, so the common envelope is unchanged; the debugger + /// sums it per channel into `droppedByHost`. + #[serde(skip_serializing_if = "is_zero")] + dropped: u64, +} + +fn is_zero(count: &u64) -> bool { + *count == 0 +} + +/// Validate a debug URL and resolve it to the addresses to dial, in resolver +/// order. +/// +/// Requires `ws://`, then RESOLVES the host and requires *every* resolved +/// address to be loopback. Resolving (rather than string-matching the host) +/// accepts all genuine loopback forms - 127.0.0.0/8, ::1, and a `localhost` that +/// resolves to them - and rejects anything resolving off-loopback, closing the +/// "validate one string, dial another" gap. `Url::socket_addrs` also handles +/// IPv6 bracket-stripping. +/// +/// The port default is applied by hand rather than through `socket_addrs`'s +/// fallback closure: `ws` is a *special* scheme in the URL spec with a known +/// default of 80, so the closure is never consulted and a portless +/// `ws://127.0.0.1` would dial :80 instead of the debugger. +fn resolve_loopback_target(url: &str) -> Result, DebugSinkError> { + let mut parsed = url::Url::parse(url)?; + if parsed.scheme() != "ws" { + return Err(DebugSinkError::NotLoopback(url.to_string())); + } + if parsed.port().is_none() { + parsed + .set_port(Some(DEBUGGER_DEFAULT_PORT)) + .map_err(|()| DebugSinkError::NotLoopback(url.to_string()))?; + } + let addrs = parsed.socket_addrs(|| Some(DEBUGGER_DEFAULT_PORT))?; + if addrs.is_empty() || !addrs.iter().all(|addr| addr.ip().is_loopback()) { + return Err(DebugSinkError::NotLoopback(url.to_string())); + } + Ok(addrs) } impl WsDebugSink { @@ -115,32 +173,12 @@ impl WsDebugSink { /// immediately even if the debugger is not yet listening; the writer task /// dials lazily and reconnects. Must be called from within a Tokio runtime. pub fn connect(url: &str) -> Result, DebugSinkError> { - // Require ws://, then RESOLVE the host and require every resolved - // address to be loopback. Resolving (rather than string-matching the - // host) accepts all genuine loopback forms - 127.0.0.0/8, ::1, and a - // `localhost` that resolves to them - and rejects anything resolving - // off-loopback, closing the "validate one string, dial another" gap. - let parsed = url::Url::parse(url)?; - if parsed.scheme() != "ws" { - return Err(DebugSinkError::NotLoopback(url.to_string())); - } - // `Url::socket_addrs` resolves the host (IP literal or DNS) and handles - // IPv6 bracket-stripping and the default port; requiring every resolved - // address to be loopback accepts all genuine loopback forms (127.0.0.0/8, - // ::1, a `localhost` that resolves to them) and rejects anything that - // resolves off-loopback. - let addrs = parsed.socket_addrs(|| Some(80))?; - if !addrs.iter().all(|addr| addr.ip().is_loopback()) { - return Err(DebugSinkError::NotLoopback(url.to_string())); - } - // Capture the resolved loopback address and dial *it* directly (in - // `writer_loop`), rather than re-resolving the URL string on every dial. - // The WS handshake is therefore only ever sent to this checked loopback + // Capture *every* resolved loopback address and dial those directly (in + // `writer_loop`), rather than re-resolving the URL string on each dial. + // The WS handshake is therefore only ever sent to a checked loopback // peer - closing the resolve-then-dial gap where a mid-session resolver // change could send the handshake off-box. - let Some(addr) = addrs.first().copied() else { - return Err(DebugSinkError::NotLoopback(url.to_string())); - }; + let addrs = resolve_loopback_target(url)?; // Return a Result rather than panicking inside tokio::spawn when called // outside a runtime. @@ -148,19 +186,22 @@ impl WsDebugSink { return Err(DebugSinkError::NoRuntime); } - let (outbound, inbox) = mpsc::channel::(QUEUE_CAPACITY); + let (outbound, inbox) = mpsc::channel::(QUEUE_CAPACITY); let dropped = Arc::new(AtomicU64::new(0)); + let pending_dropped = Arc::new(AtomicU64::new(0)); let queued_bytes = Arc::new(AtomicUsize::new(0)); tokio::spawn(writer_loop( url.to_string(), - addr, + addrs, inbox, Arc::clone(&dropped), + Arc::clone(&pending_dropped), Arc::clone(&queued_bytes), )); Ok(Arc::new(Self { outbound, dropped, + pending_dropped, queued_bytes, })) } @@ -170,6 +211,16 @@ impl WsDebugSink { pub fn dropped(&self) -> u64 { self.dropped.load(Ordering::Relaxed) } + + /// Account for one lost frame: `carried` drops had been drained onto the + /// envelope that never made it, so they go back on the pending count + /// alongside this one and ride the next envelope instead. Returns the new + /// lifetime total, for logging. + fn count_drop(&self, carried: u64) -> u64 { + self.pending_dropped + .fetch_add(carried + 1, Ordering::Relaxed); + self.dropped.fetch_add(1, Ordering::Relaxed) + 1 + } } impl DebugSink for WsDebugSink { @@ -179,6 +230,12 @@ impl DebugSink for WsDebugSink { dir, bytes, } = event; + // Drain the drops accumulated since the previous envelope and stamp them + // on this one, as the web link does: a shed frame must reach the debugger + // as a counted gap in the link, not as a host that never answered. If + // this envelope is itself lost, `count_drop` puts the count back so it + // rides the next one. + let shed = self.pending_dropped.swap(0, Ordering::Relaxed); let message = WireMessage { v: WIRE_ENVELOPE_VERSION, codec: WIRE_CODEC_VERSION, @@ -187,9 +244,10 @@ impl DebugSink for WsDebugSink { // Product-vantage string; never hand-mapped, so it cannot invert. dir: dir.wire_str(), frame: BASE64.encode(&bytes), + dropped: shed, }; let Ok(line) = serde_json::to_string(&message) else { - self.dropped.fetch_add(1, Ordering::Relaxed); + self.count_drop(shed); return; }; // Byte budget on top of the channel's count cap: one frame can be MBs, so @@ -203,53 +261,60 @@ impl DebugSink for WsDebugSink { if self.queued_bytes.fetch_add(len, Ordering::Relaxed) + len > MAX_QUEUE_BYTES { // This reservation pushed us past the budget: back it out and drop. self.queued_bytes.fetch_sub(len, Ordering::Relaxed); - let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1; + let dropped = self.count_drop(shed); debug!("truapi debug sink: byte budget full, frame dropped (total {dropped})"); return; } - if self.outbound.try_send(line).is_err() { + if self.outbound.try_send(QueuedFrame { line, shed }).is_err() { // Not enqueued after all: release the reservation. The frame is lost // (never the session); count it and log so the gap is attributable to // the link, not to the host. self.queued_bytes.fetch_sub(len, Ordering::Relaxed); - let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1; + let dropped = self.count_drop(shed); debug!("truapi debug sink: outbound queue full, frame dropped (total {dropped})"); } } } +/// Dial the pre-validated loopback candidates in resolver order and return the +/// first socket that completes the WS handshake. +/// +/// Trying every candidate is what makes `ws://localhost:9231` work: `localhost` +/// commonly resolves to `::1` first while the debugger binds v4 only, so pinning +/// the first address would retry an address that can never deliver, forever. +/// Every candidate was checked as loopback in [`resolve_loopback_target`], the +/// addresses are not re-resolved, and the handshake runs over the +/// already-connected socket, so it can never reach an off-box peer. Each attempt +/// is bounded so a TCP-accepting but non-upgrading port can't park the task. +async fn dial(url: &str, addrs: &[SocketAddr]) -> Option> { + for addr in addrs { + let dialed = tokio::time::timeout(HANDSHAKE_TIMEOUT, async { + let tcp = TcpStream::connect(addr).await.ok()?; + client_async(url, tcp).await.ok() + }) + .await; + match dialed { + Ok(Some((stream, _response))) => return Some(stream), + Ok(None) => debug!("truapi debug sink: dial/handshake to {addr} failed"), + Err(_) => debug!("truapi debug sink: handshake to {addr} timed out"), + } + } + None +} + /// Own the socket for the sink's lifetime: dial with capped backoff, then drain /// the queue to the wire until the sink is dropped. async fn writer_loop( url: String, - addr: SocketAddr, - mut inbox: mpsc::Receiver, + addrs: Vec, + mut inbox: mpsc::Receiver, dropped: Arc, + pending_dropped: Arc, queued_bytes: Arc, ) { let mut backoff = INITIAL_BACKOFF; loop { - // Dial the pre-validated loopback address directly, then run the WS - // handshake over that socket. The address is not re-resolved, so the - // handshake can never reach an off-box peer. The whole dial+handshake is - // bounded so a TCP-accepting but non-upgrading port can't park the task. - let dialed = tokio::time::timeout(HANDSHAKE_TIMEOUT, async { - let tcp = TcpStream::connect(addr).await.ok()?; - client_async(url.as_str(), tcp).await.ok() - }) - .await; - let stream = match dialed { - Ok(Some((stream, _response))) => Some(stream), - Ok(None) => { - debug!("truapi debug sink: dial/handshake failed, retrying"); - None - } - Err(_) => { - debug!("truapi debug sink: handshake timed out, retrying"); - None - } - }; - let Some(stream) = stream else { + let Some(stream) = dial(url.as_str(), &addrs).await else { tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(MAX_BACKOFF); // The sink was dropped while we were retrying: give up. @@ -267,7 +332,7 @@ async fn writer_loop( loop { tokio::select! { queued = inbox.recv() => match queued { - Some(line) => { + Some(QueuedFrame { line, shed }) => { // Off the queue now: release its bytes from the budget // before the (moving) send so the counter can't drift. queued_bytes.fetch_sub(line.len(), Ordering::Relaxed); @@ -277,6 +342,11 @@ async fn writer_loop( debug!("truapi debug sink: socket closed, reconnecting"); // The in-flight line is lost across this reconnect. dropped.fetch_add(1, Ordering::Relaxed); + // It carried `shed` earlier drops that therefore + // never reached the debugger: make them pending + // again (with this frame) so the next delivered + // envelope still reports the whole gap. + pending_dropped.fetch_add(shed + 1, Ordering::Relaxed); break; } } @@ -341,7 +411,11 @@ mod tests { let value: serde_json::Value = serde_json::from_str(&text).unwrap(); assert_eq!(value["channelId"], "myapp.dot"); - // Identity the debugger checks before decoding. + // Identity the debugger checks before decoding. Asserted as literals: a + // constant on both sides would agree with itself even if the value the + // debugger expects changed. + assert_eq!(value["v"], 1); + assert_eq!(value["codec"], 1); assert_eq!(value["v"], WIRE_ENVELOPE_VERSION); assert_eq!(value["codec"], WIRE_CODEC_VERSION); assert_eq!(value["schema"], TRUAPI_WIRE_SCHEMA_HASH); @@ -349,6 +423,84 @@ mod tests { assert_eq!(value["dir"], FrameDirection::In.wire_str()); assert_eq!(value["dir"], "out"); assert_eq!(value["frame"], BASE64.encode([1, 2, 3, 4])); + // Nothing was shed, so the envelope stays exactly as the web link's: + // `dropped` is absent rather than a noisy zero. + assert!( + value.get("dropped").is_none(), + "a frame with no preceding drops must not carry a dropped count" + ); + } + + /// A shed frame must reach the debugger as a counted gap in the link. The + /// debugger sums `dropped` per channel into `/stats.droppedByHost`, so + /// without it a 4096-frame or 8 MiB shed reads as a host that never answered. + #[tokio::test] + async fn a_shed_frame_is_reported_as_dropped_on_the_next_envelope() { + // Reserve a loopback port, then free it: with nothing listening the queue + // cannot drain, so the byte budget sheds a frame deterministically. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let sink = WsDebugSink::connect(&format!("ws://127.0.0.1:{port}")).unwrap(); + // 4 MiB → ~5.6 MiB of base64 per envelope: the first fits the 8 MiB + // budget, the second pushes past it and is shed. + let big = vec![0u8; 4 * 1024 * 1024]; + for _ in 0..2 { + sink.emit(DebugEvent::Frame { + channel_id: ChannelId("myapp.dot".to_string()), + dir: FrameDirection::Out, + bytes: big.clone(), + }); + } + assert_eq!(sink.dropped(), 1, "the byte budget must shed exactly one"); + + // Bring the debugger up on that port and let the writer connect. + let listener = TcpListener::bind(("127.0.0.1", port)).await.unwrap(); + let (tx, rx) = oneshot::channel::(); + tokio::spawn(async move { + let (stream, _peer) = listener.accept().await.unwrap(); + let ws = accept_async(stream).await.unwrap(); + let (_write, mut read) = ws.split(); + // Read until an envelope carries a drop count. + while let Some(Ok(message)) = read.next().await { + let Ok(text) = message.into_text() else { + continue; + }; + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + if let Some(dropped) = value["dropped"].as_u64() { + tx.send(dropped).unwrap(); + return; + } + } + }); + + // The shed happened while the queue held an already-serialized envelope, + // so the count rides the next frame emitted after it - exactly the web + // link's "piggyback onto the next live frame". + let deadline = tokio::time::Instant::now() + Duration::from_secs(20); + let mut rx = rx; + loop { + sink.emit(DebugEvent::Frame { + channel_id: ChannelId("myapp.dot".to_string()), + dir: FrameDirection::Out, + bytes: vec![7], + }); + match tokio::time::timeout(Duration::from_millis(250), &mut rx).await { + Ok(received) => { + assert_eq!( + received.unwrap(), + 1, + "the shed frame must be reported once, on the wire" + ); + return; + } + Err(_) => assert!( + tokio::time::Instant::now() < deadline, + "no envelope ever carried the shed frame's drop count" + ), + } + } } #[test] @@ -361,7 +513,7 @@ mod tests { } #[tokio::test] - async fn accepts_loopback_forms() { + async fn accepts_loopback_forms_at_validation() { for url in [ "ws://127.0.0.1:9231", "ws://localhost:9231", @@ -371,6 +523,114 @@ mod tests { } } + /// Accepting a URL is not the same as being able to deliver on it: on macOS + /// `localhost` resolves to `::1` first while the debugger binds v4 only, so a + /// sink that pins the first resolved address retries an address that can + /// never deliver, forever. Every candidate must be tried. + #[tokio::test] + async fn delivers_through_localhost_to_a_v4_only_debugger() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + // The bug only exists when the name resolves to something before the v4 + // address; on a v4-only resolver this still passes, it just proves less. + let resolved = resolve_loopback_target(&format!("ws://localhost:{port}")).unwrap(); + assert!( + !resolved.is_empty(), + "localhost must resolve to at least one loopback address" + ); + + let (tx, rx) = oneshot::channel::(); + tokio::spawn(async move { + let (stream, _peer) = listener.accept().await.unwrap(); + let ws = accept_async(stream).await.unwrap(); + let (_write, mut read) = ws.split(); + let message = read.next().await.unwrap().unwrap(); + tx.send(message.into_text().unwrap()).unwrap(); + }); + + let sink = WsDebugSink::connect(&format!("ws://localhost:{port}")).unwrap(); + sink.emit(DebugEvent::Frame { + channel_id: ChannelId("myapp.dot".to_string()), + dir: FrameDirection::Out, + bytes: vec![9], + }); + + let text = tokio::time::timeout(Duration::from_secs(20), rx) + .await + .expect("a v4-only debugger never received the frame via localhost") + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(value["frame"], BASE64.encode([9])); + } + + /// A port-less debug URL must target the debugger, not HTTP's port 80. + #[test] + fn a_url_without_a_port_targets_the_debugger_port() { + let addrs = resolve_loopback_target("ws://127.0.0.1").unwrap(); + assert_eq!(addrs.first().unwrap().port(), 9231); + for addr in resolve_loopback_target("ws://localhost").unwrap() { + assert_eq!(addr.port(), 9231, "every candidate uses the default port"); + } + } + + /// The codec version stamped on the envelope is hand-mirrored from the + /// generated TS `TRUAPI_CODEC_VERSION` (codegen emits only the schema hash to + /// Rust). Bind it to the Rust-side authority on the same number: the codec + /// version this host accepts in the handshake. A `--codec-version` bump that + /// forgets this constant then fails here instead of stamping a frame the + /// debugger reads as a foreign contract. + #[test] + fn stamped_codec_version_is_the_one_the_host_negotiates() { + use truapi::api::System; + use truapi::versioned::system::{ + HostFeatureSupportedError, HostFeatureSupportedRequest, HostFeatureSupportedResponse, + HostHandshakeRequest, HostNavigateToError, HostNavigateToRequest, + HostNavigateToResponse, + }; + use truapi::{CallContext, CallError, v01}; + + /// Exercises only `System::handshake`'s default (host-side) codec check. + struct HandshakeOnly; + + #[truapi::async_trait] + impl System for HandshakeOnly { + async fn feature_supported( + &self, + _cx: &CallContext, + _request: HostFeatureSupportedRequest, + ) -> Result> + { + unreachable!("handshake-only host") + } + + async fn navigate_to( + &self, + _cx: &CallContext, + _request: HostNavigateToRequest, + ) -> Result> { + unreachable!("handshake-only host") + } + } + + let handshake = |codec: u32| { + let cx = CallContext::with_request_id("codec:1".to_string()); + let codec_version = u8::try_from(codec).expect("codec version fits a u8"); + futures::executor::block_on(HandshakeOnly.handshake( + &cx, + HostHandshakeRequest::V1(v01::HostHandshakeRequest { codec_version }), + )) + }; + + assert!( + handshake(WIRE_CODEC_VERSION).is_ok(), + "the host must accept the codec version its debug envelopes stamp" + ); + assert!( + handshake(WIRE_CODEC_VERSION + 1).is_err(), + "the stamped codec version must be the newest one the host accepts" + ); + } + #[tokio::test] async fn emit_is_nonblocking_and_counts_drops_when_debugger_absent() { // A loopback port with nothing listening: dials never succeed, so the diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index de639be17..9a896988d 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -71,6 +71,20 @@ impl FrameSink for WasmFrameSink { } } +/// This core's wire-contract fingerprint, for a host to stamp on each debug +/// envelope it forwards to the debugger. +/// +/// The frames a web host taps are encoded by *this* core, so the identity the +/// debugger checks has to come from here. A host that stamped its JS client's +/// hash instead would attest to a table it did not encode with: `dist/wasm/web/` +/// is a hand-built, gitignored artifact, so a stale core paired with a fresh +/// client would pass the identity check while emitting frames from a different +/// contract - exactly the silent mis-decode the fingerprint exists to stop. +#[wasm_bindgen(js_name = wireSchemaHash)] +pub fn wire_schema_hash() -> String { + crate::generated::wire_table::TRUAPI_WIRE_SCHEMA_HASH.to_string() +} + /// Streams tapped debug frames out to a JS `debugEmit(channelId, dir, frame)` /// callback so the host worker can forward them to the debugger it dials. /// Dev-only: installed only when the host provides the callback, and From 33729d9a667d7d207741b7c28ed344aa3089f624 Mon Sep 17 00:00:00 2001 From: Nidish Date: Fri, 14 Aug 2026 14:34:13 +0530 Subject: [PATCH 15/17] fix(truapi-debugger): gate decode on wire identity --- js/packages/truapi-debugger/src/in-app.ts | 20 +++--- js/packages/truapi-debugger/src/index.ts | 9 +++ .../truapi-debugger/src/retry-storm.ts | 10 +++ .../truapi-debugger/src/server.test.ts | 61 +++++++++++++++++ js/packages/truapi-debugger/src/server.ts | 39 +++++++---- .../truapi-debugger/src/wire-debugger.test.ts | 26 +++++++ .../truapi-debugger/src/wire-debugger.ts | 14 +++- rust/crates/truapi-codegen/src/rust.rs | 14 ++++ rust/crates/truapi-codegen/src/rustdoc.rs | 35 ++++++++++ rust/crates/truapi-codegen/src/ts.rs | 66 +++++++++++++++++- .../truapi-codegen/tests/golden/wire_table.rs | 2 +- .../truapi-server/src/generated/wire_table.rs | 2 +- rust/crates/truapi-server/src/host_core.rs | 68 ++++++++++++++----- 13 files changed, 320 insertions(+), 46 deletions(-) diff --git a/js/packages/truapi-debugger/src/in-app.ts b/js/packages/truapi-debugger/src/in-app.ts index 96a7a19fa..9e10ff954 100644 --- a/js/packages/truapi-debugger/src/in-app.ts +++ b/js/packages/truapi-debugger/src/in-app.ts @@ -46,7 +46,7 @@ import { type TraceStats, } from "./session.js"; import type { DebugSession, DebugSessionOptions } from "./session.js"; -import { DEFAULT_MAX_ID_CHARS, WIRE_ENVELOPE_VERSION } from "./ingest.js"; +import { normalizeId, WIRE_ENVELOPE_VERSION } from "./ingest.js"; import { wireTraceToView, type TraceView } from "./trace-view.js"; import { renderOperationRow, renderTraceDetail } from "./trace-render.js"; import { detectRetryStorms } from "./retry-storm.js"; @@ -251,10 +251,6 @@ export function createInAppDebugger( maxBytesPerTrace: options.maxBytesPerTrace ?? EMBED_MAX_BYTES_PER_TRACE, }); - // Clamp to the same bound ingest uses so this registry's key matches the - // trace-engine key the panel filters by. - const clampChannelId = (id: string): string => - id.length > DEFAULT_MAX_ID_CHARS ? id.slice(0, DEFAULT_MAX_ID_CHARS) : id; const channels = new Map(); // Sticky: some frame arrived unattested (or mismatched) this session. The // no-channel decode query keys on this rather than scanning the registry, whose @@ -276,11 +272,17 @@ export function createInAppDebugger( // "omit the identity and decode anyway" is the hole this closes. const confirmed = identity?.schema === TRUAPI_WIRE_SCHEMA_HASH; if (!confirmed || mismatch) sawUnconfirmed = true; + // Same validation the standalone applies: a non-finite or fractional count + // would otherwise render as "Infinity" or a rounded lie in the strip, and the + // two mounts would disagree about the same feeder. + const droppedRaw = identity?.dropped; const dropped = - typeof identity?.dropped === "number" && identity.dropped > 0 - ? identity.dropped + typeof droppedRaw === "number" && + Number.isSafeInteger(droppedRaw) && + droppedRaw > 0 + ? droppedRaw : 0; - const key = clampChannelId(channelId); + const key = normalizeId(channelId); const existing = channels.get(key); if (existing) { if (mismatch) existing.codecOk = false; @@ -303,7 +305,7 @@ export function createInAppDebugger( // Payload-blind mode never decodes, so the gate has nothing to guard. if (!session.decodeValues) return true; if (channelId !== undefined) { - const c = channels.get(clampChannelId(channelId)); + const c = channels.get(normalizeId(channelId)); return c !== undefined && c.codecOk && c.schemaOk; } // No channel to key on: refuse once anything unattested has been seen. diff --git a/js/packages/truapi-debugger/src/index.ts b/js/packages/truapi-debugger/src/index.ts index 0fce56f12..57ac2aeec 100644 --- a/js/packages/truapi-debugger/src/index.ts +++ b/js/packages/truapi-debugger/src/index.ts @@ -47,3 +47,12 @@ export { } from "./inspector-styles.js"; export { createInAppDebugger } from "./in-app.js"; export type { InAppDebugger } from "./in-app.js"; +export { + operationMethod, + isSubscription, + isLiveSubscription, +} from "./trace-view.js"; +export type { TraceDropCounts } from "./wire-debugger.js"; +export { computeTraceStats } from "./session.js"; +export type { TraceStats } from "./session.js"; +export type { InAppFrameIdentity } from "./in-app.js"; diff --git a/js/packages/truapi-debugger/src/retry-storm.ts b/js/packages/truapi-debugger/src/retry-storm.ts index ca7e64893..13e0fa1a8 100644 --- a/js/packages/truapi-debugger/src/retry-storm.ts +++ b/js/packages/truapi-debugger/src/retry-storm.ts @@ -65,6 +65,16 @@ export function detectRetryStorms( const groups = new Map(); for (const trace of traces) { + // A replayed backlog arrives in one burst. When the producer stamped its own + // observation time the spacing is real and a genuine storm still shows, so + // only the case with no producer clock is excluded: those ops all carry the + // flush instant, and six calls a genuine ten seconds apart would otherwise + // land inside the window and every one be badged "the product is hammering + // this method" on a completely calm session. + const opener = trace.frames[0]; + if (opener?.buffered === true && opener.timestampFromProducer !== true) { + continue; + } const sig = signature(trace); if (sig === undefined) continue; const group = groups.get(sig); diff --git a/js/packages/truapi-debugger/src/server.test.ts b/js/packages/truapi-debugger/src/server.test.ts index 067df7796..d9fa8f3b2 100644 --- a/js/packages/truapi-debugger/src/server.test.ts +++ b/js/packages/truapi-debugger/src/server.test.ts @@ -2,11 +2,13 @@ import { expect, test } from "bun:test"; import { encodeWireMessage, + TRUAPI_CODEC_VERSION, TRUAPI_WIRE_SCHEMA_HASH, VersionedHostSignRawRequest, } from "@parity/truapi"; import * as W from "@parity/truapi/wire-table"; +import { WIRE_ENVELOPE_VERSION } from "./ingest.js"; import { decodeValuesFromEnv, hostHeaderAllowed, @@ -1172,3 +1174,62 @@ test("TRUAPI_DEBUGGER_PORT is validated, not silently clamped or coerced", () => expect(portFromEnv(bad)).toBeNull(); } }); + +test("a replayed backlog keeps the producer's clock through the real socket", async () => { + // The seam that hid the original bug: the producer stamped `observedAt` and + // ingest honoured it, but the server built its envelope without the field, so + // the fix was invisible through the only mount a host actually dials. Drive it + // end to end rather than unit-testing either half. + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const send = ( + requestId: string, + id: number, + dir: "in" | "out", + observedAt: number, + ): string => { + const encoded = encodeWireMessage({ + requestId, + payload: { id, value: new Uint8Array([0]) }, + }); + if (encoded.isErr()) throw encoded.error; + return JSON.stringify({ + channelId: "myapp.dot", + dir, + frame: Buffer.from(encoded.value).toString("base64"), + schema: TRUAPI_WIRE_SCHEMA_HASH, + codec: TRUAPI_CODEC_VERSION, + v: WIRE_ENVELOPE_VERSION, + observedAt, + buffered: true, + }); + }; + + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + // A 900ms round trip, replayed long after the fact in one burst. + const origin = 1_700_000_000_000; + ws.send(send("p:1", W.ACCOUNT_GET_ACCOUNT.request, "out", origin)); + ws.send(send("p:1", W.ACCOUNT_GET_ACCOUNT.response, "in", origin + 900)); + + let traces: { requestId: string; startedAt: number; lastAt: number }[] = []; + for (let i = 0; i < 50 && traces.length === 0; i++) { + await new Promise((r) => setTimeout(r, 20)); + traces = (await (await fetch(`${base}/traces`)).json()) as typeof traces; + } + ws.close(); + + const op = traces.find((t) => t.requestId === "p:1"); + expect(op).toBeDefined(); + // The producer's own span, not the 0ms a flush-instant clock would report. + expect((op?.lastAt ?? 0) - (op?.startedAt ?? 0)).toBe(900); + // And the op is anchored to when it really happened, not to now. + expect(op?.startedAt).toBe(origin); + } finally { + server.stop(); + } +}); diff --git a/js/packages/truapi-debugger/src/server.ts b/js/packages/truapi-debugger/src/server.ts index cbf91d5cf..50936e641 100644 --- a/js/packages/truapi-debugger/src/server.ts +++ b/js/packages/truapi-debugger/src/server.ts @@ -22,7 +22,7 @@ import { TRUAPI_CODEC_VERSION, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; import { createDebugSession, decodeTraceFrames } from "./session.js"; import { - DEFAULT_MAX_ID_CHARS, + normalizeId, WIRE_ENVELOPE_VERSION, type DebugFrameEnvelope, } from "./ingest.js"; @@ -73,6 +73,15 @@ interface WireMessage { channelId: string; dir: "in" | "out"; frame: string; + /** + * When the producer *observed* the frame, as opposed to when this server + * received it. A host that buffered a backlog replays it in one burst, so + * without this every op in the flush collapses to a 0 ms span and ops that + * were seconds apart land inside the retry-storm window. + */ + observedAt?: number; + /** `true` when the producer replayed this frame out of its backlog. */ + buffered?: boolean; /** Envelope version; see {@link WIRE_ENVELOPE_VERSION}. */ v?: number; /** The host's wire codec version (`TRUAPI_CODEC_VERSION`). */ @@ -288,8 +297,11 @@ function parseWireMessage(raw: string): WireParseResult { const droppedRaw = m.dropped; const droppedValid = droppedRaw === undefined || + // `isSafeInteger`, not `isInteger`: 1e308 is an integer, so it passed, and + // two channels summing to Infinity serialize as JSON `null` - the UI then + // renders "0 dropped" for the whole session with nothing counted as invalid. (typeof droppedRaw === "number" && - Number.isInteger(droppedRaw) && + Number.isSafeInteger(droppedRaw) && droppedRaw >= 0); return { ok: true, @@ -298,6 +310,12 @@ function parseWireMessage(raw: string): WireParseResult { channelId: m.channelId, dir: m.dir, frame: new Uint8Array(Buffer.from(m.frame, "base64")), + // Provenance travels with the frame: ingest decides whether to trust + // `observedAt` as the timestamp, and the trace engine suppresses + // retry-storm detection for a replayed backlog. Dropping these here made + // the fix invisible through the only mount a host actually dials. + ...(typeof m.observedAt === "number" ? { observedAt: m.observedAt } : {}), + ...(m.buffered === true ? { buffered: true as const } : {}), }, identityMismatch, identityConfirmed: schema === TRUAPI_WIRE_SCHEMA_HASH, @@ -512,11 +530,6 @@ export function startDebugServer( // frames under many distinct channelIds can't grow it without bound; when // full, evict the least-recently-seen channel. const MAX_CHANNELS = 256; - // Clamp channelId to the same bound ingest uses so this registry's key matches - // the trace-engine key the UI filters by, and an over-long attacker-chosen id - // can't bloat the map (256 entries * an unbounded key would otherwise grow it). - const clampChannelId = (id: string): string => - id.length > DEFAULT_MAX_ID_CHARS ? id.slice(0, DEFAULT_MAX_ID_CHARS) : id; const channels = new Map< string, { @@ -573,7 +586,7 @@ export function startDebugServer( if (!parsed.identityConfirmed || parsed.identityMismatch) sawUntrusted = true; if (parsed.droppedFieldInvalid) invalidDroppedFields += 1; const now = Date.now(); - const key = clampChannelId(channelId); + const key = normalizeId(channelId); const existing = channels.get(key); if (existing) { existing.lastSeen = now; @@ -621,7 +634,7 @@ export function startDebugServer( function decodeTrusted(channel: string | undefined): boolean { if (!decodeValues) return true; if (channel !== undefined) { - const c = channels.get(clampChannelId(channel)); + const c = channels.get(normalizeId(channel)); return c !== undefined && c.codecOk && c.schemaOk; } // No channel disambiguator: refuse once any host has been untrusted this @@ -698,7 +711,7 @@ export function startDebugServer( const traces = channel === null ? session.traceEngine.traces() - : session.traceEngine.tracesForChannel(clampChannelId(channel)); + : session.traceEngine.tracesForChannel(normalizeId(channel)); let frames = 0; let bytes = 0; let subscriptions = 0; @@ -751,7 +764,7 @@ export function startDebugServer( channel === null ? [...channels.values()] : [...channels.values()].filter( - (c) => c.channelId === clampChannelId(channel), + (c) => c.channelId === normalizeId(channel), ); const droppedByHost = chanList.reduce((n, c) => n + c.dropped, 0); const codecMismatch = chanList.some((c) => !c.codecOk); @@ -833,7 +846,7 @@ export function startDebugServer( const base = channel === null ? session.traceEngine.traces() - : session.traceEngine.tracesForChannel(clampChannelId(channel)); + : session.traceEngine.tracesForChannel(normalizeId(channel)); // Retry-storm is per-channel (a burst of like ops from one host), so it is // detected over exactly the traces being listed - before any reorder, since // the storm map is keyed by the trace object, not its position. @@ -855,7 +868,7 @@ export function startDebugServer( ); const notice = mismatched.size > 0 && - rows.some((t) => mismatched.has(clampChannelId(t.channelId))) + rows.some((t) => mismatched.has(normalizeId(t.channelId))) ? `
⚠ a connected host's wire contract differs from this debugger's — method names below may be wrong
` : ""; return ( diff --git a/js/packages/truapi-debugger/src/wire-debugger.test.ts b/js/packages/truapi-debugger/src/wire-debugger.test.ts index 71c843751..886c4d4c4 100644 --- a/js/packages/truapi-debugger/src/wire-debugger.test.ts +++ b/js/packages/truapi-debugger/src/wire-debugger.test.ts @@ -297,3 +297,29 @@ describe("createWireDebugger grouping", () => { expect(traces.map((t) => t.generation)).toEqual([0, 1]); }); }); + +test("a cap below 1 falls back instead of counting phantom drops", () => { + // `maxFramesPerTrace: 0` is reachable: the embed forwards caller caps straight + // through. At 0 each push makes length 1, excess 1, and `splice(1, 1)` removes + // nothing — so the drop counter climbed once per frame while the trace still + // held exactly its opener, and the badge claimed thousands dropped. + const wd = createWireDebugger({ sink: () => {}, maxFramesPerTrace: 0 }); + // One subscription: a `start` opener then 49 `receive`s, so this is a single + // trace rather than 50 generation-rotated ones. + for (let i = 0; i < 50; i++) { + wd.observe({ + channelId: "app.dot", + direction: i === 0 ? "out" : "in", + requestId: "p:1", + frameId: i === 0 ? 40 : 41, + role: i === 0 ? "start" : "receive", + byteLength: 1, + timestamp: 1000 + i, + }); + } + const trace = wd.traces()[0]; + expect(trace).toBeDefined(); + expect(trace?.dropped.framesByCount).toBe(0); + expect(trace?.truncated).toBe(false); + expect(trace?.frames.length).toBe(50); +}); diff --git a/js/packages/truapi-debugger/src/wire-debugger.ts b/js/packages/truapi-debugger/src/wire-debugger.ts index 2d406f3ed..6f83c2253 100644 --- a/js/packages/truapi-debugger/src/wire-debugger.ts +++ b/js/packages/truapi-debugger/src/wire-debugger.ts @@ -261,9 +261,17 @@ export function createWireDebugger( ): WireDebugger { const sink: WireDebugSink = options.sink ?? ((line) => console.debug(line)); const forward = options.forward; - const maxTraces = options.maxTraces ?? 256; - const maxFramesPerTrace = options.maxFramesPerTrace ?? 1024; - const maxBytesPerTrace = options.maxBytesPerTrace ?? 1024 * 1024; + // Floor every cap at 1. A cap of 0 (or negative) evicts nothing - `splice(1, n)` + // has no index 1 to remove - while still counting a drop per frame, so the + // badge climbs forever against a trace that never lost anything. The embed + // forwards caller-supplied caps straight through, so this is reachable. + const atLeastOne = (value: number | undefined, fallback: number): number => + value === undefined || !Number.isFinite(value) || value < 1 + ? fallback + : Math.floor(value); + const maxTraces = atLeastOne(options.maxTraces, 256); + const maxFramesPerTrace = atLeastOne(options.maxFramesPerTrace, 1024); + const maxBytesPerTrace = atLeastOne(options.maxBytesPerTrace, 1024 * 1024); const methodNames = options.methodNames; // Insertion-ordered; re-inserting on activity keeps the map LRU-ordered. // Keyed by `(channelId, requestId)` since requestId is per-channel only. diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index 120ab6287..9187fca8d 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -277,6 +277,7 @@ mod tests { }], public_trait_order: vec!["Account".to_string()], types: vec![], + framework_types: Vec::new(), }; let src = generate_wire_table(&api, "testhash").expect("generate_wire_table"); @@ -315,6 +316,7 @@ mod tests { ], public_trait_order: vec!["StatementStore".to_string(), "Preimage".to_string()], types: versioned_request_test_types(), + framework_types: Vec::new(), }; let dispatcher = generate_dispatcher(&api).expect("dispatcher"); @@ -367,6 +369,7 @@ mod tests { ], public_trait_order: vec!["Foo".to_string(), "FooBar".to_string()], types: vec![], + framework_types: Vec::new(), }; let err = generate_wire_table(&api, "testhash") .expect_err("duplicate wire method name must error"); @@ -397,6 +400,7 @@ mod tests { }], public_trait_order: vec!["Permissions".to_string()], types: versioned_request_test_types(), + framework_types: Vec::new(), }; let dispatcher_a = generate_dispatcher(&api).expect("dispatcher a"); @@ -426,6 +430,7 @@ mod tests { }], public_trait_order: vec!["Permissions".to_string()], types: vec![], + framework_types: Vec::new(), }; let err = generate_wire_table(&api, "testhash").expect_err("duplicate ids must error"); let msg = format!("{err}"); @@ -481,6 +486,7 @@ mod tests { }], public_trait_order: vec!["Permissions".to_string()], types: vec![], + framework_types: Vec::new(), }; let err = generate_wire_table(&api, "testhash").expect_err("request kind + start_id must error"); @@ -505,6 +511,7 @@ mod tests { }], public_trait_order: vec!["Account".to_string()], types: vec![], + framework_types: Vec::new(), }; let err = generate_wire_table(&api, "testhash") .expect_err("subscription kind + request_id must error"); @@ -530,6 +537,7 @@ mod tests { }], public_trait_order: vec!["Permissions".to_string()], types: vec![], + framework_types: Vec::new(), }; let err = generate_wire_table(&api, "testhash") .expect_err("missing request_id annotation must error"); @@ -554,6 +562,7 @@ mod tests { }], public_trait_order: vec!["Account".to_string()], types: vec![], + framework_types: Vec::new(), }; let err = generate_wire_table(&api, "testhash") .expect_err("missing start_id annotation must error"); @@ -586,6 +595,7 @@ mod tests { }], public_trait_order: vec!["Permissions".to_string()], types: vec![], + framework_types: Vec::new(), }; let err = generate_dispatcher(&api).expect_err("two-param method must error"); let msg = format!("{err}"); @@ -619,6 +629,7 @@ mod tests { }], public_trait_order: vec!["Permissions".to_string()], types: vec![], + framework_types: Vec::new(), }; let err = generate_dispatcher(&api).expect_err("primitive response must error"); let msg = format!("{err}"); @@ -656,6 +667,7 @@ mod tests { versioned_test_type("ReqWrapper"), versioned_test_type("RespWrapper"), ], + framework_types: Vec::new(), }; let err = generate_dispatcher(&api).expect_err("raw error wrapper must error"); @@ -685,6 +697,7 @@ mod tests { versioned_test_type("RespWrapper"), versioned_test_type("ErrWrapper"), ], + framework_types: Vec::new(), }; let err = generate_dispatcher(&api).expect_err("missing target version must error"); @@ -721,6 +734,7 @@ mod tests { }], public_trait_order: vec!["Account".to_string()], types: vec![versioned_test_type("ItemWrapper")], + framework_types: Vec::new(), }; let err = generate_dispatcher(&api).expect_err("raw result subscription error must error"); diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 1e32dd86e..dfdc64f56 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -58,6 +58,12 @@ pub struct ApiDefinition { pub public_trait_order: Vec, /// Data types referenced by the trait surface. pub types: Vec, + /// Framework types that are deliberately not emitted, but whose own shape is + /// still on the wire - `CallError`'s variants are the discriminant of every + /// error response. Kept so the wire schema hash can see them: excluding them + /// from the fingerprint let a variant be inserted, renumbering every error + /// discriminant, with no signal anywhere. + pub framework_types: Vec, } /// Trait extracted from the rustdoc index: name, methods, and rustdoc. @@ -340,9 +346,35 @@ pub fn extract_api(krate: &Crate) -> Result { } let mut types = Vec::new(); + let mut framework_types = Vec::new(); let mut generated_names = BTreeMap::new(); for (name, candidates) in type_candidates { if should_skip_type_name(&name) { + // Not emitted, but still fingerprinted: a shape change here changes + // the wire. Parse failures are ignored - several skipped names are + // markers or lifetimes with no data shape to record. + for candidate in &candidates { + let Some(item) = krate.index.get(&candidate.item_id) else { + continue; + }; + let module_path: Vec = candidate + .path + .iter() + .take(candidate.path.len().saturating_sub(1)) + .cloned() + .collect(); + let extracted = if candidate.kind == "struct" { + extract_struct(&candidate.item_id, item, krate, &names, module_path) + } else if candidate.kind == "enum" { + extract_enum(&candidate.item_id, item, krate, &names, module_path) + } else { + continue; + }; + if let Ok(def) = extracted { + framework_types.push(def); + break; + } + } continue; } @@ -392,10 +424,13 @@ pub fn extract_api(krate: &Crate) -> Result { traits.sort_by(|a, b| a.name.cmp(&b.name)); types.sort_by(|a, b| a.name.cmp(&b.name)); + framework_types.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(ApiDefinition { traits, public_trait_order, types, + framework_types, }) } diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index 56bc0e810..4e921b901 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -696,8 +696,14 @@ fn wire_id_rows(api: &ApiDefinition, target_version: u32) -> Result HashMap<&str, &TypeDef> { + // Framework types are included even though they are never emitted: their + // shape is still on the wire. `CallError` is the one that matters - it wraps + // every error leg, so its variant list is the discriminant of every error + // response, and leaving it out let a variant be inserted (renumbering every + // discriminant on every error) without moving the fingerprint at all. api.types .iter() + .chain(api.framework_types.iter()) .map(|def| (def.name.as_str(), def)) .collect() } @@ -783,7 +789,13 @@ fn type_signature( return format!("rec:{name}{suffix}"); } let Some(def) = types.get(name.as_str()) else { - return format!("{name}{suffix}"); + // Degrading silently to the bare name is what let a payload's + // shape change without moving the fingerprint - the type's own + // fields or variants simply stop being hashed. Marking it keeps + // the blind spot visible in the canonical string, and + // `every_wire_reachable_type_resolves` fails the build if a new + // one ever appears. + return format!("UNRESOLVED<{name}>{suffix}"); }; seen.push(name.clone()); let body = match &def.kind { @@ -2969,6 +2981,7 @@ mod tests { }], public_trait_order: vec!["Thing".to_string()], types: vec![payload], + framework_types: Vec::new(), } } @@ -3100,6 +3113,7 @@ mod tests { }], public_trait_order: vec!["Thing".to_string()], types: vec![enum_def], + framework_types: Vec::new(), } }; @@ -3109,6 +3123,46 @@ mod tests { ); } + #[test] + fn every_wire_reachable_type_resolves_in_the_signature() { + // A type that does not resolve contributes only its NAME to the wire + // schema hash, so its own fields or variants can change with no signal. + // `CallError` was exactly that: skipped at extraction, yet sitting on + // every error leg (62 of 168 rows), so inserting a variant renumbered + // every error discriminant and left the fingerprint - and the whole + // generated tree - byte-identical. + // + // This walks the real API surface and fails if ANY payload-reachable + // name degrades, so a future addition to the extractor's skip list + // cannot silently re-open the hole. + let Ok(json) = std::env::var("TRUAPI_RUSTDOC_JSON").map(std::fs::read_to_string) else { + // Not wired in this run; the golden test covers the same ground. + return; + }; + let Ok(json) = json else { return }; + let krate = crate::rustdoc::parse(&json).unwrap(); + let api = crate::rustdoc::extract_api(&krate).unwrap(); + let types = types_by_name(&api); + + let mut unresolved: std::collections::BTreeSet = Default::default(); + for trait_def in &api.traits { + for method in &trait_def.methods { + for part in method_payload_signature(method, &types) + .split("UNRESOLVED<") + .skip(1) + { + unresolved.insert(part.chars().take_while(|c| *c != '>').collect()); + } + } + } + + assert!( + unresolved.is_empty(), + "these types are on the wire but contribute only their name to the \ + schema hash, so their shape can change undetected: {unresolved:?}" + ); + } + #[test] fn service_display_name_formats_known_acronyms() { let json_rpc = TraitDef { @@ -3163,6 +3217,7 @@ mod tests { }], public_trait_order: Vec::new(), types: Vec::new(), + framework_types: Vec::new(), } } @@ -3356,6 +3411,7 @@ mod tests { traits: Vec::new(), public_trait_order: Vec::new(), types: Vec::new(), + framework_types: Vec::new(), }; assert_eq!(latest_wire_version(&api), 1); } @@ -3370,6 +3426,7 @@ mod tests { versioned_tuple_wrapper_variants("TwoWrapper", &[(1, "Legacy"), (3, "Latest")]), versioned_tuple_wrapper_variants("ThreeWrapper", &[(2, "Middle")]), ], + framework_types: Vec::new(), }; assert_eq!(latest_wire_version(&api), 3); } @@ -3414,6 +3471,7 @@ mod tests { }], public_trait_order: vec!["Example".to_string()], types: Vec::new(), + framework_types: Vec::new(), }; let source = generate_decode_table(&api, 2).expect("generate decode table"); @@ -3534,6 +3592,7 @@ mod tests { versioned_tuple_wrapper_variants("FutureError", &[(2, "FutureErrorV2")]), versioned_tuple_wrapper_variants("FutureItem", &[(2, "FutureItemV2")]), ], + framework_types: Vec::new(), }; let source = generate_wire_table(&api, 1).expect("generate wire table"); @@ -3582,6 +3641,7 @@ mod tests { versioned_tuple_wrapper_variants("FutureResponse", &[(2, "FutureResponseV2")]), versioned_tuple_wrapper_variants("FutureError", &[(2, "FutureErrorV2")]), ], + framework_types: Vec::new(), }; let source = generate_client(&api, 1, 1).expect("generate client"); @@ -3627,6 +3687,7 @@ mod tests { versioned_tuple_wrapper("ExampleRequest", "LegacyRequest", "LatestRequest"), versioned_tuple_wrapper("ExampleResponse", "LegacyResponse", "LatestResponse"), ], + framework_types: Vec::new(), }; let client_source = generate_client(&api, 2, 1).expect("generate client"); @@ -3694,6 +3755,7 @@ mod tests { single_field_struct("V01ExampleError", "legacy_code", "u8"), single_field_struct("V02ExampleError", "latest_code", "u32"), ], + framework_types: Vec::new(), }; let source = generate_types(&api, 2).expect("generate types"); @@ -3743,6 +3805,7 @@ mod tests { versioned_tuple_wrapper_variants("ExampleRequest", &[(1, "LegacyRequest")]), versioned_tuple_wrapper("ExampleResponse", "LegacyResponse", "LatestResponse"), ], + framework_types: Vec::new(), }; let client_source = generate_client(&api, 2, 1).expect("generate client"); @@ -3789,6 +3852,7 @@ mod tests { named_field_versioned_wrapper("ExampleRequest"), versioned_tuple_wrapper("ExampleResponse", "LegacyResponse", "LatestResponse"), ], + framework_types: Vec::new(), }; let err = generate_client(&api, 2, 1).expect_err("named field wrapper rejected"); diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 2134f08f6..9806528fd 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -50,7 +50,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "f6e1362d4bdb4b9f"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "11e091e3d9b0b08f"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index 2134f08f6..9806528fd 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -50,7 +50,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "f6e1362d4bdb4b9f"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "11e091e3d9b0b08f"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 3195992bb..4f43f1d75 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -53,17 +53,43 @@ pub trait FrameSink: Send + Sync { pub trait DebugSink: Send + Sync { /// Hand one event to the sink. /// - /// Must not block, and must not panic. This is a contract on the - /// implementor, not something the core can enforce: `emit` is called from - /// inside the inbound and outbound frame paths, and every profile that - /// ships a host aborts on panic (`panic = "abort"` for `release`, which - /// `codegen` inherits, and `wasm32` cannot unwind at all), so a panic here - /// takes the whole host process down rather than losing one trace. Serialize - /// and enqueue only; never do fallible work that can `unwrap`/panic on the - /// caller's thread. + /// Must not block, and must not panic: `emit` is called from inside the + /// inbound and outbound frame paths, so a panic here would otherwise unwind + /// into a live dispatch. The core contains a panic at both tap sites + /// ([`emit_debug`]) rather than trusting the contract, because the trait is + /// public and implementable out-of-repo, and because the profiles that can + /// unwind are exactly the ones a developer runs: the workspace defines no + /// `[profile.dev]`, so `dev` keeps Cargo's default `panic = "unwind"`, and + /// `truapi-host-cli` - the host that installs a `WsDebugSink` - is built + /// without `--release`. Serialize and enqueue only; never do fallible work + /// that can `unwrap`/panic on the caller's thread. fn emit(&self, event: DebugEvent); } +/// Hand one event to a sink, containing a panic rather than letting it unwind +/// into the frame path that called it. +/// +/// `catch_unwind` is a no-op under `panic = "abort"` (the shipping `release` +/// profile, which `codegen` inherits, and `wasm32`, which cannot unwind at all). +/// It is not dead code, because the profiles that *do* unwind are the ones the +/// debugger is used from: the workspace defines no `[profile.dev]`, so `dev` +/// keeps the default `panic = "unwind"`, and the Makefile builds +/// `truapi-host-cli` without `--release`. It also protects any downstream crate +/// that compiles this one under its own unwinding profile. +/// +/// No in-process test can prove the protection - Cargo ignores the `panic` +/// setting for test profiles, so a test asserting "the guard saved the dispatch" +/// would pass even with the guard removed. The guard is kept because it costs +/// nothing when nothing panics, not because a test can demonstrate it. +fn emit_debug(sink: &dyn DebugSink, event: DebugEvent) { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + sink.emit(event); + })); + if result.is_err() { + tracing::warn!("debug sink panicked; frame dropped, dispatch unaffected"); + } +} + /// Identifies which product channel on a host a debug event belongs to, so one /// debugger app can demultiplex several channels. #[derive(Debug, Clone, PartialEq, Eq)] @@ -949,11 +975,14 @@ impl ProductRuntime { // Tap inbound before decode, so a corrupt frame is still observed. if let Some((channel_id, debug)) = self.transport.debug() { - debug.emit(DebugEvent::Frame { - channel_id, - dir: FrameDirection::In, - bytes: frame.clone(), - }); + emit_debug( + debug.as_ref(), + DebugEvent::Frame { + channel_id, + dir: FrameDirection::In, + bytes: frame.clone(), + }, + ); } let message = ProtocolMessage::decode(&mut frame.as_slice()).map_err(|err| { @@ -1106,11 +1135,14 @@ impl Transport for SinkTransport { match self.debug() { Some((channel_id, debug)) => { self.sink.emit_frame(encoded.clone()); - debug.emit(DebugEvent::Frame { - channel_id, - dir: FrameDirection::Out, - bytes: encoded, - }); + emit_debug( + debug.as_ref(), + DebugEvent::Frame { + channel_id, + dir: FrameDirection::Out, + bytes: encoded, + }, + ); } None => self.sink.emit_frame(encoded), } From a728c92195e6821c08159873aaceb7b27953fed1 Mon Sep 17 00:00:00 2001 From: Nidish Date: Fri, 14 Aug 2026 15:53:43 +0530 Subject: [PATCH 16/17] test(truapi-host): guard the producer debugger link --- .../truapi-debugger/src/server.test.ts | 47 ++++ js/packages/truapi-debugger/src/server.ts | 81 +----- .../truapi-host/src/worker-runtime.test.ts | 249 +++++++++++++++++- js/packages/truapi-host/src/worker-runtime.ts | 43 ++- 4 files changed, 340 insertions(+), 80 deletions(-) diff --git a/js/packages/truapi-debugger/src/server.test.ts b/js/packages/truapi-debugger/src/server.test.ts index d9fa8f3b2..0f8f619d3 100644 --- a/js/packages/truapi-debugger/src/server.test.ts +++ b/js/packages/truapi-debugger/src/server.test.ts @@ -1233,3 +1233,50 @@ test("a replayed backlog keeps the producer's clock through the real socket", as server.stop(); } }); + +test("a host-terminated subscription stops counting as live on /stats", async () => { + // `interrupt` ends a subscription just as `stop` does — a chain switch or a + // revoked permission is ordinary lifecycle, not an anomaly. Testing only for + // `stop` left every such subscription "live" forever, so the tile climbed all + // session while the op list beside it showed nothing live. The two mounts + // disagreed because each had its own aggregation; both now share one. + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const send = (id: number, dir: "in" | "out"): string => { + const encoded = encodeWireMessage({ + requestId: "p:1", + payload: { id, value: new Uint8Array([0]) }, + }); + if (encoded.isErr()) throw encoded.error; + return JSON.stringify({ + channelId: "myapp.dot", + dir, + frame: Buffer.from(encoded.value).toString("base64"), + schema: TRUAPI_WIRE_SCHEMA_HASH, + codec: TRUAPI_CODEC_VERSION, + v: WIRE_ENVELOPE_VERSION, + }); + }; + + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + ws.send(send(W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, "out")); + ws.send(send(W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.interrupt, "in")); + + let stats = { subscriptions: 0, liveSubscriptions: -1 }; + for (let i = 0; i < 50 && stats.subscriptions === 0; i++) { + await new Promise((r) => setTimeout(r, 20)); + stats = (await (await fetch(`${base}/stats`)).json()) as typeof stats; + } + ws.close(); + + expect(stats.subscriptions).toBe(1); + expect(stats.liveSubscriptions).toBe(0); + } finally { + server.stop(); + } +}); diff --git a/js/packages/truapi-debugger/src/server.ts b/js/packages/truapi-debugger/src/server.ts index 50936e641..cfea8f1ad 100644 --- a/js/packages/truapi-debugger/src/server.ts +++ b/js/packages/truapi-debugger/src/server.ts @@ -20,7 +20,11 @@ */ import { TRUAPI_CODEC_VERSION, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; -import { createDebugSession, decodeTraceFrames } from "./session.js"; +import { + computeTraceStats, + createDebugSession, + decodeTraceFrames, +} from "./session.js"; import { normalizeId, WIRE_ENVELOPE_VERSION, @@ -58,13 +62,6 @@ const VIEW_MAX_LIMIT = 100; const MAX_INBOUND_MESSAGE_BYTES = 9 * 1024 * 1024; /** Frame roles that make an op a subscription rather than a request/response. */ -const SUBSCRIPTION_ROLES = new Set([ - "start", - "receive", - "stop", - "interrupt", -]); - /** * The text message a host sends per frame: the envelope with a base64 frame, * plus the optional identity fields (`v`, `codec`) a versioned host stamps. @@ -712,53 +709,12 @@ export function startDebugServer( channel === null ? session.traceEngine.traces() : session.traceEngine.tracesForChannel(normalizeId(channel)); - let frames = 0; - let bytes = 0; - let subscriptions = 0; - let liveSubscriptions = 0; - let malformed = 0; - let orphaned = 0; - let retryStorms = 0; - let truncated = 0; - let out = 0; - let inbound = 0; - let durationTotal = 0; - let durationMax = 0; - const methodCounts = new Map(); - for (const { view } of viewsFor(traces)) { - frames += view.frames.length; - durationTotal += view.durationMs; - if (view.durationMs > durationMax) durationMax = view.durationMs; - if (view.badges.includes("malformed")) malformed += 1; - if (view.badges.includes("orphaned")) orphaned += 1; - if (view.badges.includes("retry-storm")) retryStorms += 1; - if (view.badges.includes("truncated")) truncated += 1; - if (view.frames.some((f) => SUBSCRIPTION_ROLES.has(f.role))) { - subscriptions += 1; - if (!view.frames.some((f) => f.role === "stop")) { - liveSubscriptions += 1; - } - } - for (const f of view.frames) { - bytes += f.byteLength ?? 0; - if (f.direction === "out") out += 1; - else inbound += 1; - } - const opener = - view.frames.find((f) => f.role === "request" || f.role === "start") ?? - view.frames.find((f) => f.method !== undefined); - const method = opener?.method ?? "(unknown)"; - methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1); - } - const ops = traces.length; - const topMethods = [...methodCounts.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([method, count]) => ({ method, count })); - // Whole-op eviction (session-wide) and host-reported drops are loss the ops - // list can't show: `ops` counts only the survivors, so without these a - // 10k-op session that kept 256 reads as "256 ops" with no sign the rest were - // dropped. `codecMismatch` flags a host whose wire contract differs. + // ONE aggregate for both mounts. A second implementation here is exactly how + // the two silently disagreed: this block tested `!some(role === "stop")` for + // liveness, ignoring `interrupt`, so every host-terminated subscription + // (chain switch, revoked permission) counted as live forever and the tile + // climbed all session above an op list showing nothing live. + const stats = computeTraceStats(viewsFor(traces).map(({ view }) => view)); const evictedTraces = session.traceEngine.evictedTraces(); const chanList = channel === null @@ -771,23 +727,10 @@ export function startDebugServer( // Typed so a dropped/renamed field is a compile error, not a silent gap in // the payload a client parses back. const payload: StatsPayload = { - ops, - frames, - bytes, - subscriptions, - liveSubscriptions, - malformed, - orphaned, - retryStorms, - truncated, + ...stats, evictedTraces, droppedByHost, codecMismatch, - out, - in: inbound, - avgDurationMs: ops === 0 ? 0 : Math.round(durationTotal / ops), - maxDurationMs: Math.round(durationMax), - topMethods, sockets: openSockets, envelopeRejects: [...rejectCounts.values()].reduce((n, c) => n + c, 0), envelopeRejectReasons: Object.fromEntries(rejectCounts), diff --git a/js/packages/truapi-host/src/worker-runtime.test.ts b/js/packages/truapi-host/src/worker-runtime.test.ts index 7529c89f5..d26015aa6 100644 --- a/js/packages/truapi-host/src/worker-runtime.test.ts +++ b/js/packages/truapi-host/src/worker-runtime.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { isLoopbackWsUrl } from "./worker-runtime.js"; +import { + coreWireSchemaHash, + createDebuggerLink, + isLoopbackWsUrl, + type DebuggerSocket, +} from "./worker-runtime.js"; /** * The gate mirrors the native sink's (`native_debug.rs`) three cases — loopback @@ -107,3 +112,245 @@ describe("isLoopbackWsUrl", () => { expect(isLoopbackWsUrl("ws://evil.com@127.0.0.1:9231")).toBe(true); }); }); + +/** + * A socket the tests drive: records what was sent, lets a test stall the peer by + * holding `bufferedAmount` high, and can fail a send the way a dead socket does. + */ +class FakeSocket implements DebuggerSocket { + sent: string[] = []; + bufferedAmount = 0; + failSends = false; + closed = false; + private listeners = new Map void)[]>(); + + send(data: string): void { + if (this.failSends) throw new Error("socket is dead"); + this.sent.push(data); + } + close(): void { + this.closed = true; + } + addEventListener(type: "open" | "close" | "error", listener: () => void): void { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + /** Drive the lifecycle the real socket would. */ + fire(type: "open" | "close" | "error"): void { + for (const l of this.listeners.get(type) ?? []) l(); + } + /** Every envelope sent so far, parsed. */ + envelopes(): Record[] { + return this.sent.map((s) => JSON.parse(s) as Record); + } +} + +/** A link wired to a fake socket and a manual clock. */ +function harness(options: { schema?: string } = {}) { + const sockets: FakeSocket[] = []; + const timers: { run: () => void; delayMs: number }[] = []; + const link = createDebuggerLink("ws://127.0.0.1:9231", { + ...options, + createSocket: () => { + const s = new FakeSocket(); + sockets.push(s); + return s; + }, + schedule: (run, delayMs) => timers.push({ run, delayMs }), + }); + return { + link, + sockets, + timers, + /** The socket currently in use. */ + live: () => sockets[sockets.length - 1]!, + /** Run every pending timer once, as the scheduler would. */ + tick: () => { + const due = timers.splice(0); + for (const t of due) t.run(); + }, + }; +} + +const FRAME = new Uint8Array([1, 2, 3]); + +describe("debugger link: envelope contents", () => { + test("stamps the core's schema only when the core vouched for one", () => { + const attested = harness({ schema: "deadbeefdeadbeef" }); + attested.live().fire("open"); + attested.link.emit("app.dot", "out", FRAME); + expect(attested.live().envelopes()[0]?.schema).toBe("deadbeefdeadbeef"); + + // No schema from the core: the envelope must carry none, so the debugger + // groups but refuses to decode rather than trusting a hash nobody vouched + // for. Fabricating one here is the silent mis-decode this exists to prevent. + const bare = harness(); + bare.live().fire("open"); + bare.link.emit("app.dot", "out", FRAME); + expect(bare.live().envelopes()[0]).not.toHaveProperty("schema"); + }); + + test("every frame carries the producer's own observation time", () => { + const h = harness(); + h.live().fire("open"); + const before = Date.now(); + h.link.emit("app.dot", "out", FRAME); + const observedAt = h.live().envelopes()[0]?.observedAt; + expect(typeof observedAt).toBe("number"); + expect(observedAt as number).toBeGreaterThanOrEqual(before); + }); + + test("frames queued while the socket is down replay marked as buffered", () => { + const h = harness(); + // Socket not open yet: these go to the queue. + h.link.emit("app.dot", "out", FRAME); + h.link.emit("app.dot", "in", FRAME); + expect(h.live().sent).toHaveLength(0); + + h.live().fire("open"); + const flushed = h.live().envelopes(); + expect(flushed).toHaveLength(2); + // Without the marker the debugger cannot tell a replayed backlog from a live + // stream, and every op in the flush lands in one retry-storm window. + expect(flushed.every((e) => e.buffered === true)).toBe(true); + }); + + test("a live frame is not marked buffered", () => { + const h = harness(); + h.live().fire("open"); + h.link.emit("app.dot", "out", FRAME); + expect(h.live().envelopes()[0]).not.toHaveProperty("buffered"); + }); +}); + +describe("debugger link: backpressure and drop accounting", () => { + test("sheds when the socket's own buffer is over the ceiling", () => { + const h = harness(); + h.live().fire("open"); + // Peer stopped reading: readyState stays OPEN while bufferedAmount grows, so + // handing frames over unchecked is unbounded buffering in the observed + // session's worker. + h.live().bufferedAmount = 9 * 1024 * 1024; + h.link.emit("app.dot", "out", FRAME); + expect(h.live().sent).toHaveLength(0); + + // The shed is counted and reported on the next frame that gets through. + h.live().bufferedAmount = 0; + h.link.emit("app.dot", "out", FRAME); + expect(h.live().envelopes()[0]?.dropped).toBe(1); + }); + + test("sheds a single over-cap message instead of killing the stream", () => { + const h = harness(); + h.live().fire("open"); + // One oversized frame on an IDLE socket: the cumulative ceiling never trips, + // but the debugger closes the connection on an over-cap message, so an + // unshed frame costs every later frame too. + h.link.emit("app.dot", "out", new Uint8Array(7 * 1024 * 1024)); + expect(h.live().sent).toHaveLength(0); + + h.link.emit("app.dot", "out", FRAME); + const envelopes = h.live().envelopes(); + expect(envelopes).toHaveLength(1); + expect(envelopes[0]?.dropped).toBe(1); + }); + + test("a failed send keeps the drop count instead of clearing it", () => { + const h = harness(); + h.live().fire("open"); + h.live().bufferedAmount = 9 * 1024 * 1024; + h.link.emit("app.dot", "out", FRAME); // shed, dropped = 1 + h.live().bufferedAmount = 0; + + h.live().failSends = true; + h.link.emit("app.dot", "out", FRAME); // send throws + h.live().failSends = false; + + h.link.emit("app.dot", "out", FRAME); + // Both the shed frame and the one whose send failed are still reported: a gap + // the host really caused must not be reported as no gap at all. + expect(h.live().envelopes()[0]?.dropped).toBeGreaterThanOrEqual(1); + }); +}); + +describe("debugger link: reconnect", () => { + test("a dead link redials on a timer, not once per frame", () => { + const h = harness(); + h.live().fire("close"); + const dialsBefore = h.sockets.length; + + // Reconnect is scheduled lazily by the next emit. The property that matters + // is that N further frames do NOT produce N dials: before the backoff, a busy + // session with no debugger listening dialed loopback hundreds of times a + // second, each refused, each logging a console error. + for (let i = 0; i < 10; i++) h.link.emit("app.dot", "out", FRAME); + expect(h.sockets.length).toBe(dialsBefore); + expect(h.timers.length).toBe(1); + expect(h.timers[0]?.delayMs ?? 0).toBeGreaterThan(0); + + h.tick(); + expect(h.sockets.length).toBe(dialsBefore + 1); + }); + + test("the backoff grows across repeated failed dials", () => { + const h = harness(); + const delays: number[] = []; + for (let i = 0; i < 3; i++) { + h.live().fire("close"); + h.link.emit("app.dot", "out", FRAME); + delays.push(h.timers[0]?.delayMs ?? 0); + h.tick(); + } + expect(delays[0]).toBeGreaterThan(0); + expect(delays[1]).toBeGreaterThan(delays[0]!); + }); + + test("a dial that reaches the debugger earns the short delay back", () => { + const h = harness(); + // Fail twice so the backoff has grown. + for (let i = 0; i < 2; i++) { + h.live().fire("close"); + h.link.emit("app.dot", "out", FRAME); + h.tick(); + } + // Now a dial succeeds, then dies again: the next wait is the base delay, so a + // debugger that restarts is picked up promptly rather than after the cap. + h.live().fire("open"); + h.live().fire("close"); + h.link.emit("app.dot", "out", FRAME); + expect(h.timers[0]?.delayMs).toBe(200); + }); +}); + +describe("coreWireSchemaHash: attest only what the core vouched for", () => { + test("returns the core's hash when it reports one", () => { + expect(coreWireSchemaHash({ wireSchemaHash: () => "abc123abc123abc1" })).toBe( + "abc123abc123abc1", + ); + }); + + test("returns undefined for a core that does not report one", () => { + // `dist/wasm/web/` is gitignored and hand-built, so a stale bundle predating + // the export is a normal state to find at runtime. Inventing a hash here + // would attest to a table this core did not encode with — the debugger would + // then decode a foreign contract confidently, which is precisely the silent + // mis-decode the fingerprint exists to stop. Grouping without decode is the + // correct degradation. + expect(coreWireSchemaHash({})).toBeUndefined(); + }); + + test("returns undefined when the core's accessor throws or lies", () => { + expect( + coreWireSchemaHash({ + wireSchemaHash: () => { + throw new Error("stale bundle"); + }, + }), + ).toBeUndefined(); + // A non-string or empty answer is not an attestation either. + expect( + coreWireSchemaHash({ wireSchemaHash: () => "" as unknown as string }), + ).toBeUndefined(); + }); +}); diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index b5fc0af40..a107903fe 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -175,10 +175,11 @@ function toBase64(bytes: Uint8Array): string { const WIRE_ENVELOPE_VERSION = 1; /** - * Is `url` a `ws://` URL on a loopback host? The debug tap forwards raw frames - * (including sensitive payloads, before the debugger's denylist runs), so it is - * loopback-only: refuse to stream them off the local machine. `ws://` only, - * matching the native sink (`native_debug.rs`), which is also ws-only. + * Is `url` a `ws://` URL on a loopback host? The debug tap forwards every frame + * verbatim, including payloads carrying key material: there is no denylist and + * nothing is redacted anywhere in this pipeline, so the loopback requirement is + * the whole confinement story - refuse to stream them off the local machine. + * `ws://` only, matching the native sink (`native_debug.rs`), also ws-only. * * Cleartext is the right call *because* the target is loopback-only. TLS defends * against a party on the path, and a loopback socket has no path: the frames @@ -297,6 +298,19 @@ const RECONNECT_MAX_MS = 5000; */ const MAX_SOCKET_BUFFERED_BYTES = 8 * 1024 * 1024; +/** + * Ceiling on a SINGLE encoded message, checked on the live path as well as the + * queued one. + * + * `MAX_SOCKET_BUFFERED_BYTES` bounds the socket's cumulative backlog, which one + * oversized frame passes straight through on an otherwise idle socket. The + * debugger closes the connection on an over-cap message rather than dropping it + * (Bun: close 1006, "Received too big message"), so an unshed frame costs the + * whole stream. Base64 inflates 4/3, so this sits below the server's own limit + * with room for the envelope's other fields. + */ +const MAX_MESSAGE_BYTES = 6 * 1024 * 1024; + /** * Dev-only link to the debugger the host dials. Fire-and-forget by construction: * it opens lazily, buffers a bounded backlog until the socket is up, retries a @@ -425,11 +439,15 @@ export function createDebuggerLink( }); } - function send(message: string): void { + function send(message: string): boolean { try { socket?.send(message); + return true; } catch { - // A dead socket must never break the frame path. + // A dead socket must never break the frame path. The caller keeps its + // pending drop count rather than clearing it against a send that failed - + // otherwise a gap the host really did cause is reported as no gap at all. + return false; } } @@ -481,12 +499,17 @@ export function createDebuggerLink( // Piggyback any frames dropped while the link was down onto the next // live frame, so the debugger attributes the gap to the link, not the // host. - send( + const message = droppedSinceSend > 0 ? JSON.stringify({ ...base, dropped: droppedSinceSend }) - : JSON.stringify(base), - ); - droppedSinceSend = 0; + : JSON.stringify(base); + // One over-cap message closes the debugger's socket, taking the whole + // stream with it. Shedding this frame keeps the rest. + if (message.length > MAX_MESSAGE_BYTES) { + shed(); + return; + } + if (send(message)) droppedSinceSend = 0; return; } // Nothing leaves the queue except through flush(), so everything that From 409cd0746f5989b07dfe4f7f53840fbb51008017 Mon Sep 17 00:00:00 2001 From: Nidish Date: Fri, 14 Aug 2026 18:00:58 +0530 Subject: [PATCH 17/17] fix(truapi-codegen): hash payload layout and Compact width --- js/packages/truapi-debugger/src/in-app.ts | 43 ++++- js/packages/truapi-debugger/src/ingest.ts | 6 +- .../truapi-host/src/worker-runtime.test.ts | 44 +++++- js/packages/truapi-host/src/worker-runtime.ts | 47 +++++- package-lock.json | 95 +++++++++++ rust/crates/truapi-codegen/src/rustdoc.rs | 14 +- rust/crates/truapi-codegen/src/ts.rs | 147 +++++++++++++----- .../truapi-codegen/tests/golden/wire_table.rs | 2 +- .../truapi-server/src/generated/wire_table.rs | 2 +- 9 files changed, 348 insertions(+), 52 deletions(-) diff --git a/js/packages/truapi-debugger/src/in-app.ts b/js/packages/truapi-debugger/src/in-app.ts index 9e10ff954..139a70a20 100644 --- a/js/packages/truapi-debugger/src/in-app.ts +++ b/js/packages/truapi-debugger/src/in-app.ts @@ -113,6 +113,8 @@ export interface InAppFrameIdentity { interface ChannelIdentity { /** `false` once a frame declared a `v`/`codec`/`schema` that differs. Sticky. */ codecOk: boolean; + /** Monotonic counter of the last frame seen, so eviction can pick the LRU. */ + lastSeen: number; /** `true` once a frame affirmatively declared a matching `schema`. */ schemaOk: boolean; /** Frames the feeding tap reported dropping. */ @@ -252,6 +254,15 @@ export function createInAppDebugger( }); const channels = new Map(); + /** + * Channels evicted while carrying a mismatch verdict. Keys only, so this is + * bounded by the number of distinct channels that ever declared a foreign + * contract - and a channel that did so must never be able to buy back trust + * simply by being forgotten. + */ + const distrusted = new Set(); + /** Monotonic sequence for LRU ordering. */ + let seq = 0; // Sticky: some frame arrived unattested (or mismatched) this session. The // no-channel decode query keys on this rather than scanning the registry, whose // records can be evicted while the frames they described survive. @@ -288,16 +299,42 @@ export function createInAppDebugger( if (mismatch) existing.codecOk = false; if (confirmed) existing.schemaOk = true; existing.dropped += dropped; + existing.lastSeen = seq++; + // Re-insert so map order tracks recency: without this the map stays in + // insertion order and the busiest, longest-lived channel is the FIRST + // evicted under pressure. + channels.delete(key); + channels.set(key, existing); return; } if (channels.size >= MAX_CHANNELS) { - const oldest = channels.keys().next().value; - if (oldest !== undefined) channels.delete(oldest); + // Evict the least recently seen, matching the standalone's registry. + let oldestKey: string | undefined; + let oldestSeen = Infinity; + for (const [candidate, entry] of channels) { + if (entry.lastSeen < oldestSeen) { + oldestSeen = entry.lastSeen; + oldestKey = candidate; + } + } + if (oldestKey !== undefined) { + const evicted = channels.get(oldestKey); + channels.delete(oldestKey); + // A mismatch verdict is sticky FOR THE SESSION, not for as long as the + // entry survives. Forgetting it let a flood of distinct channelIds + // launder a channel that had already declared a foreign wire contract: + // it re-registered clean on its next frame and the panel decoded its + // frames — wrong methods and wrong values, presented as truth. + if (evicted !== undefined && !evicted.codecOk) { + distrusted.add(oldestKey); + } + } } channels.set(key, { - codecOk: !mismatch, + codecOk: !mismatch && !distrusted.has(key), schemaOk: confirmed, dropped, + lastSeen: seq++, }); }; diff --git a/js/packages/truapi-debugger/src/ingest.ts b/js/packages/truapi-debugger/src/ingest.ts index 928b8e63e..c81983703 100644 --- a/js/packages/truapi-debugger/src/ingest.ts +++ b/js/packages/truapi-debugger/src/ingest.ts @@ -162,7 +162,11 @@ declare module "./observed-frame.js" { */ function producerTimestamp(observedAt: number | undefined): number | undefined { if (typeof observedAt !== "number") return undefined; - if (!Number.isFinite(observedAt) || observedAt <= 0) return undefined; + // `isSafeInteger`, not merely finite: `1e308` is a finite positive number and + // was accepted as an epoch-ms timestamp, which made `durationMs` overflow to + // `Infinity` and serialize as JSON `null` on /stats - a hole in the payload a + // client parses back. An epoch-ms value is a safe integer by construction. + if (!Number.isSafeInteger(observedAt) || observedAt <= 0) return undefined; return observedAt; } diff --git a/js/packages/truapi-host/src/worker-runtime.test.ts b/js/packages/truapi-host/src/worker-runtime.test.ts index d26015aa6..7d378e711 100644 --- a/js/packages/truapi-host/src/worker-runtime.test.ts +++ b/js/packages/truapi-host/src/worker-runtime.test.ts @@ -268,9 +268,47 @@ describe("debugger link: backpressure and drop accounting", () => { h.live().failSends = false; h.link.emit("app.dot", "out", FRAME); - // Both the shed frame and the one whose send failed are still reported: a gap - // the host really caused must not be reported as no gap at all. - expect(h.live().envelopes()[0]?.dropped).toBeGreaterThanOrEqual(1); + // EXACT, not >=1: the shed frame AND the frame whose send failed are both + // losses. `>=1` was satisfied by the seeded shed alone, so it could not see + // the frame that vanished on the failed send. + expect(h.live().envelopes()[0]?.dropped).toBe(2); + }); + + test("a failed backlog drain reports the gap instead of swallowing it", () => { + const h = harness(); + // Queue a backlog and shed past the cap, then fail every send on drain. The + // count was cleared before the loop, so a fully-failed flush reported a + // clean session while every frame in it was lost. + for (let i = 0; i < 1200; i++) h.link.emit("app.dot", "out", FRAME); + h.live().failSends = true; + h.live().fire("open"); + h.live().failSends = false; + + h.link.emit("app.dot", "out", FRAME); + const reported = h.live().envelopes().at(-1)?.dropped; + expect(typeof reported).toBe("number"); + expect(reported as number).toBeGreaterThan(1000); + }); + + test("the backlog drain respects the per-message cap", () => { + const h = harness(); + // Queued while the socket is down, drained after it opens. The cap is + // documented as applying to both paths; only the live one enforced it, so an + // over-cap message reached the debugger on reconnect and closed the stream. + h.link.emit("app.dot", "out", new Uint8Array(5 * 1024 * 1024)); + h.live().fire("open"); + for (const raw of h.live().sent) { + expect(raw.length).toBeLessThanOrEqual(6 * 1024 * 1024); + } + }); + + test("the backlog drain does not force-feed a wedged socket", () => { + const h = harness(); + for (let i = 0; i < 50; i++) h.link.emit("app.dot", "out", FRAME); + // Peer stopped reading before the drain begins. + h.live().bufferedAmount = 9 * 1024 * 1024; + h.live().fire("open"); + expect(h.live().sent).toHaveLength(0); }); }); diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index a107903fe..2ca44cf0d 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -299,8 +299,8 @@ const RECONNECT_MAX_MS = 5000; const MAX_SOCKET_BUFFERED_BYTES = 8 * 1024 * 1024; /** - * Ceiling on a SINGLE encoded message, checked on the live path as well as the - * queued one. + * Ceiling on a SINGLE encoded message, enforced on the live path and again when + * the backlog drains. * * `MAX_SOCKET_BUFFERED_BYTES` bounds the socket's cumulative backlog, which one * oversized frame passes straight through on an otherwise idle socket. The @@ -390,17 +390,39 @@ export function createDebuggerLink( // parse server-side. Drops only happen once the queue is full, so when the // count is nonzero there is always a pending frame to carry it; if not, it // rides the next live emit. + // The count is only cleared once a frame carrying it is actually handed to + // the socket. Clearing it up front lost the whole gap whenever the drain + // failed - 1100 shed frames reported as a clean session. + let carried = 0; if (pending.length > 0 && droppedSinceSend > 0) { try { const first = JSON.parse(pending[0]) as Record; first.dropped = droppedSinceSend; pending[0] = JSON.stringify(first); - droppedSinceSend = 0; + carried = droppedSinceSend; } catch { // Leave the frame as-is; the count rides the next live emit. } } - for (const message of pending) send(message); + for (const [index, message] of pending.entries()) { + // The drained path is subject to the same two ceilings as the live one: a + // wedged socket must not be force-fed the backlog, and an over-cap message + // closes the debugger's connection and costs every frame after it. + const open = socket; + if ( + open === null || + open.bufferedAmount > MAX_SOCKET_BUFFERED_BYTES || + message.length > MAX_MESSAGE_BYTES + ) { + shed(); + continue; + } + if (send(message)) { + if (index === 0) droppedSinceSend -= carried; + } else { + shed(); + } + } } function connect(): void { @@ -440,8 +462,14 @@ export function createDebuggerLink( } function send(message: string): boolean { + // A null socket is NOT a success: returning true there would clear the drop + // count against a frame that went nowhere. Note the residual limit - per + // WHATWG, `WebSocket.send()` on a CLOSING/CLOSED socket discards silently + // without throwing, so a `true` here means "handed over", not "delivered". + const live = socket; + if (live === null) return false; try { - socket?.send(message); + live.send(message); return true; } catch { // A dead socket must never break the frame path. The caller keeps its @@ -509,7 +537,14 @@ export function createDebuggerLink( shed(); return; } - if (send(message)) droppedSinceSend = 0; + if (send(message)) { + droppedSinceSend = 0; + } else { + // The frame just handed over is lost as well, not only the earlier + // ones: counting the prior gap but not this frame under-reports by + // exactly the frames whose send failed. + shed(); + } return; } // Nothing leaves the queue except through flush(), so everything that diff --git a/package-lock.json b/package-lock.json index fcfeccc64..d8cc23181 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ }, "devDependencies": { "@types/bun": "^1.3.0", + "happy-dom": "^20.11.2", "typescript": "^6.0" } }, @@ -503,6 +504,23 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "dev": true, @@ -554,6 +572,19 @@ "node": ">=8" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/bun-types": { "version": "1.3.14", "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz", @@ -613,6 +644,19 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esprima": { "version": "4.0.1", "dev": true, @@ -724,6 +768,25 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.11.2", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.2.tgz", + "integrity": "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/human-id": { "version": "4.1.3", "dev": true, @@ -1255,6 +1318,16 @@ "node": ">= 4.0.0" } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, @@ -1268,6 +1341,28 @@ "engines": { "node": ">= 8" } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index dfdc64f56..cad34c19a 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -1090,8 +1090,18 @@ pub(crate) fn resolve_type(ty: &serde_json::Value, names: &NameContext) -> Resul "Option", args, )?))), "Compact" => { - expect_single_arg("Compact", args)?; - Ok(TypeRef::Primitive("compact".to_string())) + // The width is carried in the primitive's NAME, not discarded. + // Emission still keys on the `compact` prefix, so generated + // output is unchanged - but the wire schema hash can now see the + // difference between `Compact` and `Compact`. Dropping + // it made every compact site render identically, so widening one + // left the fingerprint byte-identical while changing which values + // a peer can decode. + let inner = expect_single_arg("Compact", args)?; + let TypeRef::Primitive(width) = &inner else { + bail!("Compact must wrap a primitive integer, found {inner:?}"); + }; + Ok(TypeRef::Primitive(format!("compact<{width}>"))) } "OptionBool" => Ok(TypeRef::Primitive("optionBool".to_string())), "String" => { diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index 4e921b901..2f62c7d6f 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -878,10 +878,30 @@ pub(crate) fn wire_schema_hash( codec_version: u8, ) -> Result { let mut canonical = format!("codec={codec_version}\n"); + let mut unresolved: BTreeSet = BTreeSet::new(); for (id, tag, sensitive, payload) in wire_id_rows(api, target_version)? { let flag = u8::from(sensitive); + for marker in payload.split("UNRESOLVED<").skip(1) { + unresolved.insert(marker.chars().take_while(|c| *c != '>').collect()); + } canonical.push_str(&format!("{id}:{tag}:{flag}:{payload}\n")); } + // Fail the BUILD, not a test. A type that does not resolve contributes only + // its name, so its own fields or variants stop being fingerprinted and can + // change undetected - `CallError` sat on every error leg exactly that way, + // and inserting a variant renumbered every error discriminant while the hash + // and the whole generated tree stayed byte-identical. Enforcing it here means + // a future addition to the extractor's skip list cannot re-open the hole, and + // does not depend on a test being wired up to notice. + if !unresolved.is_empty() { + bail!( + "wire schema hash cannot see the shape of {unresolved:?}: these types are \ + reachable from a wire payload but are not in the API definition, so a \ + change to their fields or variants would not move the fingerprint. Add \ + them to `ApiDefinition::framework_types` rather than letting the \ + signature degrade to a bare name." + ); + } // FNV-1a 64-bit: deterministic across platforms and Rust versions (unlike // `DefaultHasher`), dependency-free, and ample for a contract fingerprint. let mut hash: u64 = 0xcbf2_9ce4_8422_2325; @@ -2649,7 +2669,7 @@ fn codec_expr_mode( "u32" => Ok("S.u32".to_string()), "u64" => Ok("S.u64".to_string()), "u128" => Ok("S.u128".to_string()), - "compact" => Ok("S.compact".to_string()), + name if name.starts_with("compact") => Ok("S.compact".to_string()), "optionBool" => Ok("S.OptionBool".to_string()), "i8" => Ok("S.i8".to_string()), "i16" => Ok("S.i16".to_string()), @@ -2734,7 +2754,7 @@ fn ts_type_with_named(ty: &TypeRef, qualified: bool, mode: NameMode<'_>) -> Resu "bool" => Ok("boolean".to_string()), "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => Ok("number".to_string()), "u64" | "u128" | "i64" | "i128" => Ok("bigint".to_string()), - "compact" => Ok("number | bigint".to_string()), + name if name.starts_with("compact") => Ok("number | bigint".to_string()), "optionBool" => Ok("boolean | undefined".to_string()), "str" => Ok("string".to_string()), _ => bail!("Unsupported primitive type `{name}` in TypeScript type generation"), @@ -3070,6 +3090,26 @@ mod tests { assert!(sig.contains("rec:Node"), "unexpected signature: {sig}"); } + #[test] + fn schema_hash_moves_when_a_compact_width_changes() { + // `Compact` and `Compact` encode the same small values the same + // way, so the frame length does not change - but the wider type accepts + // values the narrower decoder rejects. The extractor used to discard the + // argument entirely, collapsing every compact site to one token, so a + // widening left the fingerprint byte-identical. + let build = |width: &str| { + api_with_payload_fields(vec![( + "size", + TypeRef::Primitive(format!("compact<{width}>")), + )]) + }; + + assert_ne!( + wire_schema_hash(&build("u32"), 1, 1).unwrap(), + wire_schema_hash(&build("u64"), 1, 1).unwrap(), + ); + } + #[test] fn schema_hash_moves_when_an_enum_variant_is_reordered() { // Variant position is the SCALE discriminant, so a reorder silently @@ -3124,45 +3164,58 @@ mod tests { } #[test] - fn every_wire_reachable_type_resolves_in_the_signature() { - // A type that does not resolve contributes only its NAME to the wire - // schema hash, so its own fields or variants can change with no signal. - // `CallError` was exactly that: skipped at extraction, yet sitting on - // every error leg (62 of 168 rows), so inserting a variant renumbered - // every error discriminant and left the fingerprint - and the whole - // generated tree - byte-identical. - // - // This walks the real API surface and fails if ANY payload-reachable - // name degrades, so a future addition to the extractor's skip list - // cannot silently re-open the hole. - let Ok(json) = std::env::var("TRUAPI_RUSTDOC_JSON").map(std::fs::read_to_string) else { - // Not wired in this run; the golden test covers the same ground. - return; + fn an_unresolvable_wire_reachable_type_fails_the_build() { + // The guard that replaced an env-gated test which asserted nothing when + // the variable was unset. `Missing` is referenced by the payload but is + // absent from both `types` and `framework_types`, so its shape cannot be + // fingerprinted - exactly the state `CallError` was in. + let method = MethodDef { + name: "do_thing".to_string(), + kind: MethodKind::Request, + params: vec![ParamDef { + name: "request".to_string(), + type_ref: TypeRef::Named { + name: "Missing".to_string(), + args: Vec::new(), + }, + }], + return_type: ReturnType::Result { + ok: TypeRef::Unit, + err: TypeRef::Unit, + }, + wire: request_wire(Some(7)), + docs: None, + }; + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Thing".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Thing".to_string()], + types: Vec::new(), + framework_types: Vec::new(), }; - let Ok(json) = json else { return }; - let krate = crate::rustdoc::parse(&json).unwrap(); - let api = crate::rustdoc::extract_api(&krate).unwrap(); - let types = types_by_name(&api); - - let mut unresolved: std::collections::BTreeSet = Default::default(); - for trait_def in &api.traits { - for method in &trait_def.methods { - for part in method_payload_signature(method, &types) - .split("UNRESOLVED<") - .skip(1) - { - unresolved.insert(part.chars().take_while(|c| *c != '>').collect()); - } - } - } + let err = wire_schema_hash(&api, 1, 1) + .expect_err("an unresolvable payload type must fail codegen"); assert!( - unresolved.is_empty(), - "these types are on the wire but contribute only their name to the \ - schema hash, so their shape can change undetected: {unresolved:?}" + format!("{err}").contains("Missing"), + "the error must name the offending type: {err}" ); } + #[test] + fn a_resolvable_payload_hashes_without_complaint() { + // The negative control: the guard must not fire on an ordinary payload, + // or every codegen run would fail. + let api = + api_with_payload_fields(vec![("ring_index", TypeRef::Primitive("u32".to_string()))]); + + assert!(wire_schema_hash(&api, 1, 1).is_ok()); + } + #[test] fn service_display_name_formats_known_acronyms() { let json_rpc = TraitDef { @@ -3262,6 +3315,20 @@ mod tests { } } + /// An empty struct `TypeDef`, so a synthetic fixture's payload types resolve. + /// A fixture that references a name it never defines is not a realistic API, + /// and the schema-hash guard rejects it for the same reason it rejects real + /// drift: an unresolvable type contributes only its name to the fingerprint. + fn empty_struct(name: &str) -> TypeDef { + TypeDef { + name: name.to_string(), + module_path: Vec::new(), + generic_params: Vec::new(), + kind: TypeDefKind::Struct(Vec::new()), + docs: None, + } + } + fn versioned_tuple_wrapper_variants(name: &str, variants: &[(u32, &str)]) -> TypeDef { TypeDef { name: name.to_string(), @@ -3640,6 +3707,9 @@ mod tests { versioned_tuple_wrapper_variants("FutureRequest", &[(2, "FutureRequestV2")]), versioned_tuple_wrapper_variants("FutureResponse", &[(2, "FutureResponseV2")]), versioned_tuple_wrapper_variants("FutureError", &[(2, "FutureErrorV2")]), + empty_struct("LegacyErrorV1"), + empty_struct("LegacyRequestV1"), + empty_struct("LegacyResponseV1"), ], framework_types: Vec::new(), }; @@ -3686,6 +3756,10 @@ mod tests { types: vec![ versioned_tuple_wrapper("ExampleRequest", "LegacyRequest", "LatestRequest"), versioned_tuple_wrapper("ExampleResponse", "LegacyResponse", "LatestResponse"), + empty_struct("LatestRequest"), + empty_struct("LatestResponse"), + empty_struct("LegacyRequest"), + empty_struct("LegacyResponse"), ], framework_types: Vec::new(), }; @@ -3804,6 +3878,9 @@ mod tests { types: vec![ versioned_tuple_wrapper_variants("ExampleRequest", &[(1, "LegacyRequest")]), versioned_tuple_wrapper("ExampleResponse", "LegacyResponse", "LatestResponse"), + empty_struct("LatestResponse"), + empty_struct("LegacyRequest"), + empty_struct("LegacyResponse"), ], framework_types: Vec::new(), }; diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 9806528fd..f994b7375 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -50,7 +50,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "11e091e3d9b0b08f"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "011b775e2c24d30f"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index 9806528fd..f994b7375 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -50,7 +50,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "11e091e3d9b0b08f"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "011b775e2c24d30f"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds {