From f95621c40a4a1b4858a26e0ff4b383d93df39f1c Mon Sep 17 00:00:00 2001 From: Nidish Date: Thu, 27 Aug 2026 17:00:35 +0530 Subject: [PATCH 1/2] feat(truapi-server): payload-blind wire-debug tap, sinks, and the codegen decode surface --- Cargo.lock | 1 + js/packages/truapi/README.md | 29 +- js/packages/truapi/package.json | 4 + .../truapi/scripts/ensure-generated.sh | 1 + js/packages/truapi/src/client.ts | 2 + rust/crates/truapi-codegen/src/main.rs | 11 +- rust/crates/truapi-codegen/src/rust.rs | 45 +- .../truapi-codegen/src/rust/wire_table.rs | 22 +- rust/crates/truapi-codegen/src/rustdoc.rs | 76 +- rust/crates/truapi-codegen/src/ts.rs | 756 +++++++++++++++++- .../truapi-codegen/tests/golden/wire_table.rs | 6 + rust/crates/truapi-macros/src/lib.rs | 49 +- rust/crates/truapi-server/Cargo.toml | 7 +- .../truapi-server/src/generated/wire_table.rs | 6 + rust/crates/truapi-server/src/host_core.rs | 514 +++++++++++- rust/crates/truapi-server/src/lib.rs | 11 +- rust/crates/truapi-server/src/native_debug.rs | 689 ++++++++++++++++ rust/crates/truapi-server/src/wasm.rs | 55 +- 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 +- 25 files changed, 2257 insertions(+), 69 deletions(-) create mode 100644 rust/crates/truapi-server/src/native_debug.rs diff --git a/Cargo.lock b/Cargo.lock index df1684406..ff9b6c1d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5199,6 +5199,7 @@ name = "truapi-server" version = "0.1.0" dependencies = [ "async-trait", + "base64", "blake2b_simd", "chacha20poly1305", "console_error_panic_hook", diff --git a/js/packages/truapi/README.md b/js/packages/truapi/README.md index b034e70f4..e1302f80d 100644 --- a/js/packages/truapi/README.md +++ b/js/packages/truapi/README.md @@ -70,7 +70,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 @@ -120,6 +120,33 @@ This is what makes a real host usable from an ordinary browser tab during develo transport without the sandbox's caching and detection, `createWebSocketProvider(url)` from the package root returns the bare `WireProvider`. +## 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. + +- 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, 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 Frames are SCALE encoded: diff --git a/js/packages/truapi/package.json b/js/packages/truapi/package.json index e019c74ac..8490401ba 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/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 7d757f98c..9187fca8d 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(()) } @@ -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, } @@ -275,9 +277,10 @@ mod tests { }], public_trait_order: vec!["Account".to_string()], types: vec![], + framework_types: Vec::new(), }; - 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, @@ -313,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"); @@ -325,7 +329,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 @@ -365,8 +369,10 @@ mod tests { ], public_trait_order: vec!["Foo".to_string(), "FooBar".to_string()], types: vec![], + framework_types: Vec::new(), }; - 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"), @@ -394,14 +400,15 @@ 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"); 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); } @@ -423,8 +430,9 @@ mod tests { }], public_trait_order: vec!["Permissions".to_string()], types: vec![], + framework_types: Vec::new(), }; - 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"), @@ -478,8 +486,10 @@ mod tests { }], public_trait_order: vec!["Permissions".to_string()], types: vec![], + framework_types: Vec::new(), }; - 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"), @@ -501,8 +511,10 @@ mod tests { }], public_trait_order: vec!["Account".to_string()], types: vec![], + framework_types: Vec::new(), }; - 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"), @@ -525,8 +537,10 @@ mod tests { }], public_trait_order: vec!["Permissions".to_string()], types: vec![], + framework_types: Vec::new(), }; - 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"), @@ -548,8 +562,10 @@ mod tests { }], public_trait_order: vec!["Account".to_string()], types: vec![], + framework_types: Vec::new(), }; - 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"), @@ -579,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}"); @@ -612,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}"); @@ -649,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"); @@ -678,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"); @@ -714,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/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/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index f84eaff20..cad34c19a 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. @@ -121,6 +127,11 @@ 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)]`; folded into the wire schema-hash + /// fingerprint so a change in a frame's sensitivity classification is caught + /// as contract drift. + pub sensitive: bool, } /// Wire-shape classification of a trait method. @@ -335,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; } @@ -387,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, }) } @@ -819,6 +859,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), @@ -1042,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" => { @@ -1484,7 +1542,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 +1560,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..2f62c7d6f 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(()) } @@ -652,6 +658,260 @@ fn generate_wire_table(api: &ApiDefinition, target_version: u32) -> Result Result> { + let wrappers = collect_versioned_wrappers(api); + 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, payload.clone())) + { + bail!("wire id {id} reused: `{existing}` and `{tag}` collide"); + } + } + } + } + Ok(seen + .into_iter() + .map(|(id, (tag, sensitive, payload))| (id, tag, sensitive, payload)) + .collect()) +} + +/// Index the API's user-defined types by their emitted name, so a signature walk +/// can resolve a [`TypeRef::Named`] to its actual shape. +fn types_by_name(api: &ApiDefinition) -> 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() +} + +/// 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 { + // 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 { + 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 +/// 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"); + 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; + 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, @@ -926,6 +1186,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, @@ -944,6 +1205,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; @@ -1077,6 +1339,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, @@ -2241,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()), @@ -2326,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"), @@ -2528,6 +2956,266 @@ 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], + framework_types: Vec::new(), + } + } + + #[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_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 + // 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], + framework_types: Vec::new(), + } + }; + + assert_ne!( + wire_schema_hash(&build(["Allow", "Deny"]), 1, 1).unwrap(), + wire_schema_hash(&build(["Deny", "Allow"]), 1, 1).unwrap(), + ); + } + + #[test] + 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 err = wire_schema_hash(&api, 1, 1) + .expect_err("an unresolvable payload type must fail codegen"); + assert!( + 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 { @@ -2582,6 +3270,7 @@ mod tests { }], public_trait_order: Vec::new(), types: Vec::new(), + framework_types: Vec::new(), } } @@ -2626,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(), @@ -2775,6 +3478,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); } @@ -2789,6 +3493,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); } @@ -2819,6 +3524,37 @@ mod tests { ); } + #[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(), + framework_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( @@ -2923,6 +3659,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"); @@ -2970,7 +3707,11 @@ 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(), }; let source = generate_client(&api, 1, 1).expect("generate client"); @@ -3015,7 +3756,12 @@ 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(), }; let client_source = generate_client(&api, 2, 1).expect("generate client"); @@ -3083,6 +3829,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"); @@ -3131,7 +3878,11 @@ 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(), }; let client_source = generate_client(&api, 2, 1).expect("generate client"); @@ -3178,6 +3929,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 9eee6495c..a9a6ef930 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 = "1dead485f8986339"; /// 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 8e27efc4d..649a97575 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,32 @@ 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 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`")); } - 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 +135,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 +154,15 @@ fn set_id(args: &mut WireArgs, key: &Ident, value: u8) -> syn::Result<()> { /// /// #[wire(start_id = 42)] /// async fn host_account_connection_status_subscribe(...) -> ...; +/// +/// // 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(...) -> ...; /// ``` /// /// Expands to the original method plus hidden doc tags that `truapi-codegen` @@ -177,7 +195,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 +205,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-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/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index 9eee6495c..a9a6ef930 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 = "1dead485f8986339"; /// 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 7291ecad7..fd3c7757b 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -49,6 +49,99 @@ 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 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 an + /// out-of-repo or test sink can be installed under it. (The only in-repo + /// installer is the wasm host, which cannot unwind at all; `truapi-host-cli` + /// installs no sink.) 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)] +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))] @@ -1086,6 +1179,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 { @@ -1114,6 +1209,18 @@ impl ProductRuntime { return Ok(()); } + // 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(), + }, + ); + } + let message = ProtocolMessage::decode(&mut frame.as_slice()).map_err(|err| { ProductRuntimeError::InvalidFrame { reason: err.to_string(), @@ -1124,9 +1231,16 @@ impl ProductRuntime { }; let dispatch_id = self.next_dispatch_id.fetch_add(1, Ordering::Relaxed); let (abort_handle, abort_registration) = AbortHandle::new_pair(); + // Same poison recovery as `self.debug`, and for a concrete reason rather than + // symmetry: `dispose` below holds THIS guard across its whole drain loop and + // calls `AbortHandle::abort()` inside it, which wakes the task's waker - i.e. + // arbitrary out-of-repo executor code, under the lock. One panicking waker + // would poison this mutex and every later `receive_frame` would then panic + // here, which is exactly the production-host-killing shape the debug tap + // above was fixed for. self.in_flight .lock() - .expect("host core in-flight dispatch mutex poisoned") + .unwrap_or_else(|poisoned| poisoned.into_inner()) .insert(dispatch_id, abort_handle); let transport: Arc = self.transport.clone(); @@ -1134,7 +1248,7 @@ impl ProductRuntime { self.in_flight .lock() - .expect("host core in-flight dispatch mutex poisoned") + .unwrap_or_else(|poisoned| poisoned.into_inner()) .remove(&dispatch_id); if self.disposed.load(Ordering::Acquire) { self.core.cancel_subscriptions(); @@ -1199,6 +1313,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 @@ -1211,7 +1332,7 @@ impl ProductRuntime { for (_, handle) in self .in_flight .lock() - .expect("host core in-flight dispatch mutex poisoned") + .unwrap_or_else(|poisoned| poisoned.into_inner()) .drain() { handle.abort(); @@ -1225,6 +1346,62 @@ 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; + } + // Recover from poisoning rather than panicking. Note which of the two fixes + // here is load-bearing: moving the previous sink's `drop` out of + // `set_debug_sink`'s critical section (below) is what removes the only + // reachable poisoner, since nothing else run under this guard can unwind - + // the body is an `Option<(ChannelId, Arc<..>)>` clone. This recovery is the + // belt-and-braces half, kept because the guard is on the per-frame path in + // both directions and outside `emit_debug`'s `catch_unwind`, so a panic here + // would unwind straight into live dispatch. + // + // Two independent reasons the poisoner is unreachable in what ships, and + // neither is the profile. `wasm.rs` is the ONLY non-test `set_debug_sink` + // caller in the repo (`truapi-host-cli` installs no sink at all), so: the + // wasm32 target cannot unwind, AND that call site builds a fresh + // `SinkTransport` per `product_runtime()` and installs at most once on it, + // so `previous` is always `None` and there is no destructor to run under + // the lock regardless of profile. + // + // Kept anyway because this guard is on the per-frame path in both + // directions and sits outside `emit_debug`'s `catch_unwind`, so any future + // in-guard work that can unwind would land straight in live dispatch. + self.debug + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + fn set_debug_sink(&self, channel_id: ChannelId, sink: Arc) { + // Take the previous sink out under the lock, then drop it AFTER releasing. + // `*guard = Some(..)` would drop the old `Arc` in place, running an + // out-of-repo destructor inside the critical section: a panic there + // poisoned the mutex, and every subsequent frame then panicked on the + // lock. Dropping outside moves that unwind off the per-frame path and into + // whoever installs a sink - a much smaller blast radius, but NOT + // containment: unlike `emit`, this drop is not wrapped in `catch_unwind`. + let previous = self + .debug + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .replace((channel_id, sink)); + self.has_debug.store(true, Ordering::Relaxed); + drop(previous); + } } impl Transport for SinkTransport { @@ -1232,7 +1409,23 @@ 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()); + emit_debug( + debug.as_ref(), + DebugEvent::Frame { + channel_id, + dir: FrameDirection::Out, + bytes: encoded, + }, + ); + } + None => self.sink.emit_frame(encoded), + } } fn on_message( @@ -1342,6 +1535,319 @@ 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" + ); + } + + /// A sink whose `Drop` panics. `emit` is a no-op: the point is the destructor, + /// which `set_debug_sink` runs when it replaces this sink. + struct PanicOnDropSink; + + impl DebugSink for PanicOnDropSink { + fn emit(&self, _event: DebugEvent) {} + } + + impl Drop for PanicOnDropSink { + fn drop(&mut self) { + panic!("out-of-repo sink destructor"); + } + } + + /// Records how many frames the transport had already delivered at the moment + /// each outbound tap fired, which is what pins the deliver-THEN-tap ordering. + struct DeliveryOrderSink { + transport: Arc, + delivered_at_tap: Mutex>, + } + + impl DebugSink for DeliveryOrderSink { + fn emit(&self, event: DebugEvent) { + let DebugEvent::Frame { dir, .. } = event; + if dir != FrameDirection::Out { + return; + } + let delivered = self + .transport + .frames + .lock() + .expect("recording sink mutex poisoned") + .len(); + self.delivered_at_tap + .lock() + .expect("delivery order mutex poisoned") + .push(delivered); + } + } + + #[test] + fn a_panicking_sink_destructor_does_not_poison_the_tap_for_later_frames() { + // `set_debug_sink` takes the previous sink out under the lock and drops it + // after releasing. If it dropped in place instead, this destructor's unwind + // would poison the debug mutex and - before the recovery below it - every + // subsequent frame would panic on that lock, killing a live host over a + // third-party sink's `Drop`. Both properties are asserted: the unwind + // surfaces to whoever INSTALLS a sink, and the tap keeps working after. + // + // This pins the two fixes as a PAIR, not individually: reverting only the + // drop-outside-the-guard change leaves the poison recovery to absorb it, and + // reverting only the recovery leaves no poisoner to trip it. Restoring both + // (the original code) fails here on the poisoned lock, which is the + // production shape being guarded against. + let platform = Arc::new(StubPlatform::default()); + let transport = Arc::new(RecordingSink::default()); + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + platform, + host_config, + product, + test_spawner(), + transport, + ); + let channel = ChannelId("myapp.dot".to_string()); + runtime.set_debug_sink(channel.clone(), Arc::new(PanicOnDropSink)); + + // Replacing it drops the panicking sink. The unwind lands HERE, on the + // installer, not on a later frame. + let replaced = Arc::new(RecordingDebugSink::default()); + let installed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + runtime.set_debug_sink(channel.clone(), replaced.clone()); + })); + assert!( + installed.is_err(), + "the destructor's panic should surface to the installer" + ); + + // The mutex must not be poisoned: the new sink is reachable and taps. + 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(), + }, + }; + futures::executor::block_on(runtime.receive_frame(frame.encode())).unwrap(); + let tapped = replaced + .events + .lock() + .expect("debug events mutex poisoned") + .len(); + assert!( + tapped > 0, + "the replacement sink should still receive frames after the panic" + ); + } + + #[test] + fn outbound_frames_are_delivered_before_they_are_tapped() { + // `send` hands the frame to the transport and taps afterwards, so a slow or + // panicking sink can never delay or drop a real protocol frame. Asserting + // the two lists match cannot see this - they are order-identical either way. + // Counting deliveries AT TAP TIME can: tap N must observe N deliveries, and + // tapping first would make it N-1. + let platform = Arc::new(StubPlatform::default()); + let transport = Arc::new(RecordingSink::default()); + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + platform, + host_config, + product, + test_spawner(), + transport.clone(), + ); + let debug = Arc::new(DeliveryOrderSink { + transport: transport.clone(), + delivered_at_tap: Mutex::new(Vec::new()), + }); + 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(), + }, + }; + futures::executor::block_on(runtime.receive_frame(frame.encode())).unwrap(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while debug + .delivered_at_tap + .lock() + .expect("delivery order 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)); + + let observed = debug + .delivered_at_tap + .lock() + .expect("delivery order mutex poisoned") + .clone(); + assert!(!observed.is_empty(), "expected at least one outbound tap"); + // Tap i (0-based) must see i+1 frames already delivered. + let expected: Vec = (1..=observed.len()).collect(); + assert_eq!( + observed, expected, + "each outbound tap must run after its own frame was delivered" + ); + } + + /// [`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 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" + ); + assert!( + manifest.contains("[profile.codegen]") && manifest.contains("inherits = \"release\""), + "codegen no longer inherits release: recheck what the ws-bridge artifacts build with" + ); + } + + #[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 app_connection_rejects_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 8b9ef7f53..4e9788e14 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -18,6 +18,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; @@ -49,13 +51,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}; #[cfg(not(target_arch = "wasm32"))] pub use runtime::StatementRenewalTarget; pub use runtime::login_failure::reports_exhausted_period; 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..134caea4b --- /dev/null +++ b/rust/crates/truapi-server/src/native_debug.rs @@ -0,0 +1,689 @@ +//! 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 lives 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. +//! 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 +//! 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, AtomicUsize, 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::tungstenite::Message; +use tokio_tungstenite::{WebSocketStream, client_async}; +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; + +/// 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); + +/// 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, + 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. +#[derive(Serialize)] +struct WireMessage<'a> { + v: u32, + codec: u32, + schema: &'static str, + #[serde(rename = "channelId")] + 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 { + /// 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> { + // 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 addrs = resolve_loopback_target(url)?; + + // 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)); + let pending_dropped = Arc::new(AtomicU64::new(0)); + let queued_bytes = Arc::new(AtomicUsize::new(0)); + tokio::spawn(writer_loop( + url.to_string(), + addrs, + inbox, + Arc::clone(&dropped), + Arc::clone(&pending_dropped), + Arc::clone(&queued_bytes), + )); + Ok(Arc::new(Self { + outbound, + dropped, + pending_dropped, + queued_bytes, + })) + } + + /// 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) + } + + /// 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 { + fn emit(&self, event: DebugEvent) { + let DebugEvent::Frame { + channel_id, + 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, + schema: TRUAPI_WIRE_SCHEMA_HASH, + channel_id: &channel_id.0, + // 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.count_drop(shed); + 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.count_drop(shed); + debug!("truapi debug sink: byte budget full, frame dropped (total {dropped})"); + return; + } + 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.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, + addrs: Vec, + mut inbox: mpsc::Receiver, + dropped: Arc, + pending_dropped: Arc, + queued_bytes: Arc, +) { + let mut backoff = INITIAL_BACKOFF; + loop { + 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. + 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(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); + 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); + // 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; + } + } + } + // 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"); + // 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); + // 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])); + // 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] + 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_at_validation() { + for url in [ + "ws://127.0.0.1:9231", + "ws://localhost:9231", + "ws://[::1]:9231", + ] { + assert!(WsDebugSink::connect(url).is_ok(), "should accept {url}"); + } + } + + /// 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, HostInfoError, HostInfoRequest, HostInfoResponse, + 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") + } + + async fn host_info( + &self, + _cx: &CallContext, + _request: HostInfoRequest, + ) -> 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 + // 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" + ); + } + + #[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" + ); + } +} diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index 8b849fb0f..e6a212c0d 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -38,8 +38,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; @@ -74,6 +74,47 @@ 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 +/// 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>, } @@ -837,10 +878,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)) } diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index f7ee2b758..f3e18bcde 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -151,7 +151,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, @@ -185,7 +185,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, @@ -298,7 +298,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, @@ -319,7 +319,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 010d39a31..aa13b3f9b 100644 --- a/rust/crates/truapi/src/api/signing.rs +++ b/rust/crates/truapi/src/api/signing.rs @@ -61,7 +61,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, @@ -118,7 +118,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, @@ -150,7 +150,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, @@ -196,7 +196,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, @@ -226,7 +226,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, @@ -263,7 +263,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 ae8bdbef8..66a693e0a 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, @@ -99,7 +99,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, @@ -126,7 +126,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, @@ -158,7 +158,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 e8d9625c57608cfcfdbbaac7ccd0442a6f22a0d4 Mon Sep 17 00:00:00 2001 From: Nidish Date: Thu, 27 Aug 2026 17:03:11 +0530 Subject: [PATCH 2/2] feat(truapi-debugger): wire trace, decode, and render engine --- .github/workflows/ci.yml | 41 ++ js/packages/truapi-debugger/.gitignore | 3 + js/packages/truapi-debugger/README.md | 153 ++++++ js/packages/truapi-debugger/package.json | 37 ++ .../truapi-debugger/src/decode.test.ts | 146 +++++ js/packages/truapi-debugger/src/decode.ts | 108 ++++ js/packages/truapi-debugger/src/index.ts | 55 ++ .../truapi-debugger/src/ingest.test.ts | 336 ++++++++++++ js/packages/truapi-debugger/src/ingest.ts | 289 ++++++++++ .../truapi-debugger/src/inspector-styles.ts | 247 +++++++++ .../truapi-debugger/src/observed-frame.ts | 109 ++++ .../truapi-debugger/src/operation-row.test.ts | 133 +++++ .../truapi-debugger/src/retry-storm.test.ts | 231 ++++++++ .../truapi-debugger/src/retry-storm.ts | 124 +++++ js/packages/truapi-debugger/src/session.ts | 362 +++++++++++++ .../truapi-debugger/src/trace-render.test.ts | 468 ++++++++++++++++ .../truapi-debugger/src/trace-render.ts | 404 ++++++++++++++ .../truapi-debugger/src/trace-styles.ts | 190 +++++++ .../truapi-debugger/src/trace-view.test.ts | 385 +++++++++++++ js/packages/truapi-debugger/src/trace-view.ts | 466 ++++++++++++++++ .../truapi-debugger/src/wire-debugger.test.ts | 364 +++++++++++++ .../truapi-debugger/src/wire-debugger.ts | 504 ++++++++++++++++++ js/packages/truapi-debugger/tsconfig.json | 21 + .../truapi-debugger/tsconfig.test.json | 14 + package-lock.json | 125 +++++ 25 files changed, 5315 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.test.ts create mode 100644 js/packages/truapi-debugger/src/ingest.ts create mode 100644 js/packages/truapi-debugger/src/inspector-styles.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 create mode 100644 js/packages/truapi-debugger/tsconfig.test.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a23ab9e7a..e10ef3fdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -329,6 +329,45 @@ 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: Typecheck tests + run: npm run typecheck:tests --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 @@ -491,6 +530,7 @@ jobs: ios-swift, ts-client, ts-host, + ts-debugger, playground, explorer, e2e, @@ -510,6 +550,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/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..8ad2b780f --- /dev/null +++ b/js/packages/truapi-debugger/README.md @@ -0,0 +1,153 @@ +# @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 owns **all** decoding — the wire +envelope (`requestId` and frame id, via `decodeWireMessage`), the grouping into +per-operation traces, and the per-frame payload decode. The host core treats +frames as opaque bytes and never decodes. + +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, and value-decode engines plus the ingest that turns a wire +> envelope into a decoded frame) and its two *mounts* — the standalone app +> (`server.ts`) and the in-app embed (`in-app.ts`). 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 an open +> decision for the host-protocol owner; in-repo is the low-regret default and +> moving it later is cheap. + +## What's here + +- **`createDebugSession()`** — the trace engine wired to the ingest. Feed it + envelopes with `handleEnvelope(...)`; read grouped traces from `traceEngine`, + per-frame values from `frameDetail(...)` / `decodedFrames(...)`. +- **`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 + per-frame decode of a payload to a plain JS value, reusing `@parity/truapi`'s + generated `WIRE_DECODE_TABLE`. Every frame it can decode, it does, with no + sensitive special-casing. The bare factory takes `enabled: true` to opt in; a + session turns it on for you. +- **`buildTraceView` / `wireTraceToView`, `renderOperationRow`, + `renderTraceDetail`, `renderFrameValueDetail`** — the one view model and the one + set of renderers both mounts share, so the two cannot drift apart. +- **`startDebugServer(...)`** (`server.ts`) — the standalone mount, below. +- **`createInAppDebugger(...)`** (`in-app.ts`) — the in-app mount, below. + +## The two mounts + +Both render the same view model with the same renderers and the same stylesheet. +They differ in where the debugger sits relative to the host: + +```text +standalone: host process ──ws://127.0.0.1:9231──▶ debugger server ──HTTP──▶ browser + (host dials out; frames leave the app; one server, many channels) + +in-app: host in the page ──handleFrame()──▶ InAppDebugger.mount(el) + (same page as the host; no server, no dial; frames never leave the app) +``` + +- **Standalone** (`startDebugServer`): a Bun WS+HTTP server bound to + `127.0.0.1` only. Hosts dial *in* and send one text message per frame, + `{ channelId, dir, frame }` with `frame` base64-encoded, plus the wire-identity + fields a versioned host stamps (`v`, `codec`, `schema`) and an optional + `dropped` count. The browser view is a thin client over server-rendered + fragments. +- **In-app** (`createInAppDebugger`): the second mount, for a host that runs in + the page. It takes the same raw SCALE frame bytes with the same + product-vantage `dir`, holds the session in-process, and renders the fragments + directly with no polling. Browser-only (uses `document`); each browser tab is + its own tenant, so there is nothing to host or scope. + +## Value decode (level 2 — on by default) + +This is a **dev-only tool that decodes everything**. The list views stay +payload-blind — they group frames and sum byte lengths, never their contents — +and the drill-down decodes a frame's payload to a plain JS value, for every +frame, with no "sensitive" special-casing. Its contract: + +- **On by default.** The standalone server decodes unless + `TRUAPI_DEBUGGER_DECODE_VALUES` is set to a falsy value + (`0`/`false`/`no`/`off`), or `startDebugServer({ decodeValues: false })` / + `createInAppDebugger({ 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. +- **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 the codec cannot type still + shows its raw payload as `B · 0x…` hex — a dev-only tool hides nothing it + has the bytes for. Only a frame with no retained bytes (decode off) reads + `payload not shown`. +- **Refused on contract drift.** Decode is allowed only for a channel whose + declared `schema` fingerprint (`TRUAPI_WIRE_SCHEMA_HASH`) and `codec` match + this debugger's; a mismatched or absent identity is refused (`/frame` answers + 409) and banners in the view. Payload-blind grouping is unaffected. +- **Never over the wire, never in the list endpoints.** The host emits opaque + bytes only; nothing about decode changes what it sends. Decode happens in the + debugger, in the drill-down paths only. + +## Standalone endpoints + +| Endpoint | Serves | +| ------------------------------------- | --------------------------------------------------------- | +| `GET /` | The inspector page: polls the fragments below. | +| `GET /op-list?channel=&sort=` | One server-rendered row per op. `sort` is `recent`, `duration`, `frames`, or `method`; absent keeps arrival order. Payload-blind. | +| `GET /op?id=&channel=&gen=` | The selected op's drill-down, each frame's value inline. | +| `GET /view` | The drill-down as a standalone fragment, values inline. | +| `GET /channels` | Connected hosts/channels, liveness, codec-mismatch flag. | +| `GET /stats?channel=` | Aggregate roll-up: counts, bytes, durations, health, busiest methods. Payload-blind. | +| `GET /traces` | The grouped traces as JSON. Payload-blind — never serializes bytes or values. | +| `GET /frame?id=&i=&channel=` | One frame's decode as JSON (the programmatic drill-down). | + +Loopback is enforced on more than the bind: a request whose `Host` header is not +a loopback name gets a 403 (DNS-rebinding guard), and a WebSocket upgrade from a +foreign browser `Origin` is refused (CSWSH). + +## 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 127.0.0.1:9231, decodes by default + +# a different port, or decode off for a demo +TRUAPI_DEBUGGER_PORT=9300 npm run serve +TRUAPI_DEBUGGER_DECODE_VALUES=0 npm run serve +``` + +Point a host's debugger URL at `ws://127.0.0.1:9231` (the host dials out) and +open `http://127.0.0.1:9231/`; click an op for its drill-down detail. + +Use the literal `127.0.0.1`, not `localhost`. Both dial gates accept a `ws://` +URL on a loopback host **only** — `wss://`, certificates, and any non-loopback +target are rejected — and `localhost` passes that check but resolves `::1` first +on macOS, while the server binds `127.0.0.1` alone. A native host then dials an +address nothing is listening on and logs nothing. + +For the in-app mount, feed frames straight to the session: + +```ts +import { createInAppDebugger } from "@parity/truapi-debugger"; + +const inspector = createInAppDebugger(); +const dispose = inspector.mount(document.getElementById("wire-panel")!); +// from the host's tap, per frame: +inspector.handleFrame(channelId, "out", frameBytes); +``` + +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..bf7cf3aea --- /dev/null +++ b/js/packages/truapi-debugger/package.json @@ -0,0 +1,37 @@ +{ + "name": "@parity/truapi-debugger", + "version": "0.1.0", + "description": "In-repo debugger consumer for TrUAPI wire frames: decodes and groups the frames the truapi-server host tap streams out", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "author": "Parity Technologies ", + "type": "module", + "sideEffects": false, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b", + "typecheck": "tsc -b", + "typecheck:tests": "tsc -p tsconfig.test.json", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "happy-dom": "^20.11.2", + "typescript": "^6.0" + }, + "dependencies": { + "@parity/truapi": "^0.10.0" + } +} 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..e8eb9ffc7 --- /dev/null +++ b/js/packages/truapi-debugger/src/decode.test.ts @@ -0,0 +1,146 @@ +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, 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, + // These tests exercise the DECODER. Decode is gated on the frame's producer + // having vouched for the wire contract, so an attested frame is the fixture; + // `unattested()` below covers the refusal. + // + // NOTE this INVERTS the production default: on the wire the field is absent + // and absent means untrusted. A new test reaching for `frame()` silently opts + // into trust, so anything asserting a refusal must start from `unattested()`. + identityConfirmed: true, + ...(bytes ? { bytes } : {}), + }; +} + +/** The same frame with no identity: its producer never vouched for the contract. */ +function unattested(frameId: number, bytes?: Uint8Array): ObservedFrame { + const f = frame(frameId, bytes); + delete f.identityConfirmed; + return f; +} + +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 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("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 [ + 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("frame decoder (injected table)", () => { + const table = { 999: (b: Uint8Array) => ({ ok: Array.from(b) }) }; + + 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", + value: { ok: [1, 2] }, + } satisfies FrameValueDetail); + }); + + test("decodes a secret-named field too — no content guard withholds it", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { 999: () => ({ source: { sr25519SecretKey: "0xdead" } }) }, + }); + 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 }); + 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"); + }, + }, + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).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"); + }); +}); + +test("an unattested frame never decodes, whatever its channel did", () => { + // The gate is per FRAME, not per channel. As a per-channel latch, one attested + // frame retroactively unlocked every unattested frame already retained under + // the same `channelId` - and `channelId` is the productId, shared by a stale + // host and a fresh one, and by every frame an embedding host tees before it + // learns its core's schema hash. + const decoder = createFrameDecoder({ enabled: true }); + const id = W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start; + const bytes = new Uint8Array([0]); + + // Same id, same bytes. The only difference is who vouched for the contract. + const attested = decoder.detail(frame(id, bytes)); + const refused = decoder.detail(unattested(id, bytes)); + + expect(attested.kind).toBe("decoded"); + expect(refused.kind).toBe("bytes"); + if (refused.kind === "bytes") expect(refused.hex).toBe("0x00"); +}); diff --git a/js/packages/truapi-debugger/src/decode.ts b/js/packages/truapi-debugger/src/decode.ts new file mode 100644 index 000000000..244c4789c --- /dev/null +++ b/js/packages/truapi-debugger/src/decode.ts @@ -0,0 +1,108 @@ +// 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. + * + * 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 work live here: + * + * - **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. + * + * 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 type { ObservedFrame } from "./observed-frame.js"; + +/** + * Per-frame decode result for the drill-down detail path. + * + * `"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. When the decoder is on and the bytes are + * retained, that fallback still carries the raw `hex` so a dev-only tool always + * shows *something* for a payload it could not type; `hex` is absent only in + * payload-blind mode (decoder off) or when no bytes were retained. + */ +export type FrameValueDetail = + | { kind: "decoded"; value: unknown } + | { kind: "bytes"; byteLength: number; hex?: string }; + +/** Options for {@link createFrameDecoder}. */ +export interface FrameDecoderOptions { + /** + * Master gate. `false` (the default) means the decoder never inspects a + * payload: every frame reports bytes only. + */ + enabled?: boolean; + /** + * Frame-id → decoder map. Defaults to the generated + * {@link WIRE_DECODE_TABLE}; overridable for tests. + */ + decodeTable?: Record unknown>; +} + +/** 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; + /** Resolve one frame to its {@link FrameValueDetail}. */ + detail(frame: ObservedFrame): FrameValueDetail; +} + +/** + * Build a {@link FrameDecoder}. Off by default: pass `enabled: true` to opt in. + * 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 decodeTable = options.decodeTable ?? WIRE_DECODE_TABLE; + + // Raw bytes as `0x…` hex so a payload the decoder can't type is still visible + // in the drill-down (a dev-only tool hides nothing it has the bytes for). + const toHex = (bytes: Uint8Array): string => + "0x" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + + const detail = (frame: ObservedFrame): FrameValueDetail => { + if (!enabled) return { kind: "bytes", byteLength: frame.byteLength }; + // Decoder on: keep the raw hex on the bytes fallback so nothing reads + // "payload not shown" when the bytes are right there. + const bytesFallback = (): FrameValueDetail => ({ + kind: "bytes", + byteLength: frame.byteLength, + ...(frame.bytes ? { hex: toHex(frame.bytes) } : {}), + }); + // The frame's OWN producer must have vouched for the wire contract. This is + // ADDITIVE to the per-channel `decodeTrusted` gate both mounts still apply, + // not a replacement for it. Keying on the channel ALONE made it a latch: + // one attested frame unlocked every unattested frame already retained under + // that `channelId`, and a frame id means nothing without the table that + // assigned it. Unattested frames still group, list and show their hex. + if (frame.identityConfirmed !== true) return bytesFallback(); + const decode = decodeTable[frame.frameId]; + if (!decode || !frame.bytes) return bytesFallback(); + try { + return { kind: "decoded", value: decode(frame.bytes) }; + } catch { + // A malformed or version-skewed payload must not break the drill-down; + // fall back to the raw hex. + return bytesFallback(); + } + }; + + return { enabled, 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..812db4dfc --- /dev/null +++ b/js/packages/truapi-debugger/src/index.ts @@ -0,0 +1,55 @@ +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 } 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"; +export { + INSPECTOR_LAYOUT_CSS, + INSPECTOR_SHELL_CSS, +} from "./inspector-styles.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"; diff --git a/js/packages/truapi-debugger/src/ingest.test.ts b/js/packages/truapi-debugger/src/ingest.test.ts new file mode 100644 index 000000000..38b229def --- /dev/null +++ b/js/packages/truapi-debugger/src/ingest.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, test } from "bun:test"; + +import { encodeWireMessage } from "@parity/truapi"; +import * as W from "@parity/truapi/wire-table"; + +import { createDebugIngest, DEFAULT_MAX_ID_CHARS, normalizeId } from "./ingest.js"; +import type { DebugFrameEnvelope } from "./ingest.js"; +import type { ObservedFrame } from "./observed-frame.js"; +import { detectRetryStorms } from "./retry-storm.js"; +import { createMethodNameMap, createWireDebugger } from "./wire-debugger.js"; + +/** The real generated table, keyed the way `createDebugSession` keys it. */ +const METHOD_NAMES = createMethodNameMap( + W as unknown as Record, + ["account", "signing", "chain", "chat", "resourceAllocation"], +); + +/** One host-tap envelope carrying `frameId` under correlation id `requestId`. */ +function envelope( + requestId: string, + frameId: number, + value = new Uint8Array([0]), + dir: "in" | "out" = "out", + channelId = "myapp.dot", +): DebugFrameEnvelope { + const encoded = encodeWireMessage({ requestId, payload: { id: frameId, value } }); + if (encoded.isErr()) throw encoded.error; + return { channelId, dir, frame: encoded.value }; +} + +/** + * One envelope as a host tap replays it out of its backlog: `buffered`, with the + * producer's own `observedAt` rather than the flush instant. + */ +function flushed( + observedAt: number | undefined, + requestId: string, + frameId: number, + dir: "in" | "out" = "out", +): DebugFrameEnvelope { + return { + ...envelope(requestId, frameId, new Uint8Array([0]), dir), + ...(observedAt === undefined ? {} : { observedAt }), + buffered: true, + }; +} + +/** Collect every frame an ingest emits. */ +function collect(options: Parameters[1] = {}) { + const seen: ObservedFrame[] = []; + return { seen, ingest: createDebugIngest((f) => seen.push(f), options) }; +} + +describe("ingest resolves role from the wire table", () => { + test("role is a pure function of frameId, across every leg of a method", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + + // A request/response pair and a subscription's start/receive legs. Each id + // carries its own role on the wire table; none of them needs correlation + // state, and they arrive here out of any lifecycle order on purpose. + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.response)); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + ingest(envelope("p:2", W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.receive)); + ingest(envelope("p:2", W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start)); + + expect(seen.map((f) => f.role)).toEqual([ + "response", + "request", + "receive", + "start", + ]); + }); + + test("an off-table id and a map-less ingest both fall back to unknown", () => { + const withMap = collect({ methodNames: METHOD_NAMES }); + // 250 is above every id the current table assigns. + withMap.ingest(envelope("p:1", 250)); + expect(withMap.seen[0]?.role).toBe("unknown"); + + const withoutMap = collect(); + withoutMap.ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + expect(withoutMap.seen[0]?.role).toBe("unknown"); + }); + + test("an undecodable frame is a malformed sentinel, not a drop", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest({ channelId: "myapp.dot", dir: "out", frame: new Uint8Array([0xff]) }); + + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ + role: "malformed", + requestId: "malformed", + frameId: -1, + byteLength: 1, + }); + }); +}); + +describe("every consumer sees the resolved role, not just the view adapter", () => { + test("the formatted sink line names the role, not 'unknown'", () => { + const lines: string[] = []; + const wireDebugger = createWireDebugger({ + methodNames: METHOD_NAMES, + sink: (line) => lines.push(line), + }); + const ingest = createDebugIngest(wireDebugger.observe, { + methodNames: METHOD_NAMES, + }); + + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + + // This is the line the default `console.debug` sink prints. It read + // "-> unknown account.getAccount" while role was resolved only downstream. + expect(lines[0]).toBe( + `[wire p:1] → request account.getAccount (id=${W.ACCOUNT_GET_ACCOUNT.request}, 1B)`, + ); + }); + + test("the forward hook receives the resolved role", () => { + const forwarded: ObservedFrame[] = []; + const wireDebugger = createWireDebugger({ + methodNames: METHOD_NAMES, + sink: () => {}, + forward: (frame) => forwarded.push(frame), + }); + const ingest = createDebugIngest(wireDebugger.observe, { + methodNames: METHOD_NAMES, + }); + + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + + expect(forwarded).toHaveLength(1); + expect(forwarded[0]?.role).toBe("request"); + }); +}); + +describe("ingest bounds ids and gates raw bytes", () => { + test("channelId and requestId over the bound are digested, not sliced", () => { + const long = "x".repeat(DEFAULT_MAX_ID_CHARS + 100); + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + + ingest( + envelope(long, W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([0]), "out", long), + ); + + // A slice of the id would be a prefix of it and would keep the whole 356-char + // parent string alive (JSC/V8 both back `slice` with a view of the parent, so + // a 250k-char id retains 250k chars while accounting for 256). The digest + // references nothing. + for (const id of [seen[0]?.channelId, seen[0]?.requestId]) { + expect(id).toBe(normalizeId(long)); + expect(long.startsWith(id ?? "")).toBe(false); + expect((id ?? "").length).toBeLessThan(40); + // The length the host actually sent stays visible to the operator. + expect(id).toContain(`:${String(long.length)}`); + } + }); + + test("two ids sharing the bound-length prefix stay two ops", () => { + // The consequence of truncating: these differ only past the cap, so they + // clamped to the same key, merged into one trace, and manufactured a + // roundTripMs between two unrelated ops (while clearing the `orphaned` badge + // each of them had earned). + const shared = "x".repeat(DEFAULT_MAX_ID_CHARS); + const wireDebugger = createWireDebugger({ + methodNames: METHOD_NAMES, + sink: () => {}, + }); + const ingest = createDebugIngest(wireDebugger.observe, { + methodNames: METHOD_NAMES, + }); + + ingest(envelope(`${shared}a`, W.ACCOUNT_GET_ACCOUNT.request)); + ingest(envelope(`${shared}b`, W.ACCOUNT_GET_ACCOUNT.request)); + + const traces = wireDebugger.traces(); + expect(traces).toHaveLength(2); + expect(new Set(traces.map((t) => t.requestId)).size).toBe(2); + }); + + test("ids within the bound are passed through untouched", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + expect(seen[0]?.requestId).toBe("p:1"); + expect(seen[0]?.channelId).toBe("myapp.dot"); + expect(normalizeId("x".repeat(DEFAULT_MAX_ID_CHARS))).toHaveLength( + DEFAULT_MAX_ID_CHARS, + ); + }); + + test("maxIdChars overrides the default bound", () => { + const { seen, ingest } = collect({ maxIdChars: 4 }); + ingest(envelope("p:1234567890", W.ACCOUNT_GET_ACCOUNT.request)); + expect(seen[0]?.requestId).toBe(normalizeId("p:1234567890", 4)); + expect(seen[0]?.requestId).not.toBe("p:12"); + }); + + test("raw bytes are attached only under retainBytes", () => { + const off = collect({ methodNames: METHOD_NAMES }); + off.ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([7]))); + expect(off.seen[0]?.bytes).toBeUndefined(); + // Byte length is recorded either way. + expect(off.seen[0]?.byteLength).toBe(1); + + const on = collect({ methodNames: METHOD_NAMES, retainBytes: true }); + on.ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([7]))); + expect(Array.from(on.seen[0]?.bytes ?? [])).toEqual([7]); + }); + + test("the product-vantage direction is carried through untouched", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([0]), "out")); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.response, new Uint8Array([0]), "in")); + expect(seen.map((f) => f.direction)).toEqual(["out", "in"]); + }); +}); + +/** + * A host tap buffers a backlog while the debugger is absent and flushes it in one + * loop on connect. If the ingest clock is the only clock, that loop stamps every + * frame of the whole session with the same instant: durations collapse to 0ms and + * ops minutes apart fall inside the retry-storm window. These cover both halves + * against the real trace engine and the real storm detector. + */ +describe("a flushed backlog keeps the producer's clock, not the flush instant", () => { + /** Feed envelopes through a real ingest into a real trace engine. */ + function traceEngine() { + const wireDebugger = createWireDebugger({ + methodNames: METHOD_NAMES, + sink: () => {}, + }); + return { + traces: () => wireDebugger.traces(), + ingest: createDebugIngest(wireDebugger.observe, { + methodNames: METHOD_NAMES, + }), + }; + } + + test("a 500ms round trip stays 500ms after the flush", () => { + const engine = traceEngine(); + // One op whose two frames genuinely crossed 500ms apart, both replayed out of + // the backlog in the same loop long afterwards. + engine.ingest(flushed(1_000_000, "p:1", W.ACCOUNT_GET_ACCOUNT.request, "out")); + engine.ingest(flushed(1_000_500, "p:1", W.ACCOUNT_GET_ACCOUNT.response, "in")); + + const [trace] = engine.traces(); + expect(trace?.lastAt - trace?.startedAt).toBe(500); + expect(trace?.frames.map((f) => f.timestamp)).toEqual([1_000_000, 1_000_500]); + // The frames say where their clock came from, and that they were replayed. + expect(trace?.frames.every((f) => f.timestampFromProducer === true)).toBe(true); + expect(trace?.frames.every((f) => f.buffered === true)).toBe(true); + }); + + test("six ops ten seconds apart are not a retry storm", () => { + const engine = traceEngine(); + // Six `account.getAccount` calls, one every 10s: a calm session by any + // reading. Flushed together, an ingest-stamped clock puts all six inside the + // detector's 1000ms window and badges every row "retry storm". + for (let i = 0; i < 6; i++) { + engine.ingest( + flushed(1_000_000 + i * 10_000, `p:${String(i)}`, W.ACCOUNT_GET_ACCOUNT.request), + ); + } + + const traces = engine.traces(); + expect(traces).toHaveLength(6); + expect(detectRetryStorms(traces).size).toBe(0); + }); + + test("a genuine burst is still detected through a flush", () => { + const engine = traceEngine(); + // The same six ops 100ms apart really are a storm: preserving the producer's + // clock must not blunt the signal, only stop fabricating it. + for (let i = 0; i < 6; i++) { + engine.ingest( + flushed(1_000_000 + i * 100, `p:${String(i)}`, W.ACCOUNT_GET_ACCOUNT.request), + ); + } + + expect(detectRetryStorms(engine.traces()).size).toBe(6); + }); + + test("a tap that stamps no time falls back to the ingest clock and marks the frame", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + const before = Date.now(); + ingest(flushed(undefined, "p:1", W.ACCOUNT_GET_ACCOUNT.request)); + + // Nothing better exists for such a frame, so `timestamp` is the flush instant + // - but it is flagged `buffered` with no `timestampFromProducer`, which is the + // pair a consumer keys on to suppress its duration and its storm + // participation. + expect(seen[0]?.timestamp).toBeGreaterThanOrEqual(before); + expect(seen[0]?.timestampFromProducer).toBeUndefined(); + expect(seen[0]?.buffered).toBe(true); + }); + + test("a live frame is neither buffered nor producer-stamped", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + expect(seen[0]?.buffered).toBeUndefined(); + expect(seen[0]?.timestampFromProducer).toBeUndefined(); + }); + + test("an unusable observedAt is refused, not trusted into the trace list", () => { + // Anything reaching the tap can put anything here, and it feeds ordering and + // every duration. + for (const observedAt of [0, -1, Number.NaN, Infinity, -Infinity]) { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + const before = Date.now(); + ingest({ + ...envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request), + observedAt, + }); + expect(seen[0]?.timestampFromProducer).toBeUndefined(); + expect(seen[0]?.timestamp).toBeGreaterThanOrEqual(before); + } + }); + + test("a malformed frame carries the same provenance as a decodable one", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest({ + channelId: "myapp.dot", + dir: "out", + frame: new Uint8Array([0xff]), + observedAt: 1_000_000, + buffered: true, + }); + expect(seen[0]).toMatchObject({ + role: "malformed", + timestamp: 1_000_000, + timestampFromProducer: true, + buffered: true, + }); + }); +}); diff --git a/js/packages/truapi-debugger/src/ingest.ts b/js/packages/truapi-debugger/src/ingest.ts new file mode 100644 index 000000000..74bb7b9c5 --- /dev/null +++ b/js/packages/truapi-debugger/src/ingest.ts @@ -0,0 +1,289 @@ +/** + * 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"; +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 and + * the wrong value. + */ +export const WIRE_ENVELOPE_VERSION = 1; + +/** + * Default cap on `channelId` / `requestId` length, above which the id is + * replaced by a digest ({@link normalizeId}). Shared so the debugger server's + * channel registry normalizes to the same bound as ingest and the two keys stay + * equal (the UI filters by the normalized key). + */ +export const DEFAULT_MAX_ID_CHARS = 256; + +/** + * FNV-1a over `text`'s UTF-16 code units, in 32 bits. Not cryptographic: this + * only has to keep two *distinct* ids distinct, which a shared prefix does not. + */ +function fnv1a32(text: string, seed: number): number { + let hash = seed >>> 0; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash >>> 0; +} + +/** Two independently-seeded FNV-1a passes, as 16 hex chars. */ +function digest(text: string): string { + const lo = fnv1a32(text, 0x811c9dc5).toString(16).padStart(8, "0"); + const hi = fnv1a32(text, 0x9dc5811c).toString(16).padStart(8, "0"); + return `${lo}${hi}`; +} + +/** + * Bound an id's retained length: returned unchanged when it is within + * `maxChars`, otherwise replaced by `…:`. + * + * A digest, not a truncation, for two reasons. + * + * - Retention. `String.prototype.slice` yields a view that keeps its *parent* + * alive in both JSC and V8, so truncating a 250k-char id retains the whole + * 250k chars while accounting for 256 - and `retainBytes: false` is no + * mitigation, because the ids are retained on every {@link ObservedFrame} + * regardless. The digest is computed arithmetically, so nothing references the + * input. + * - Identity. Two distinct ids sharing a `maxChars` prefix truncate to the same + * key and merge into one trace, fabricating a `roundTripMs` between two + * unrelated ops and clearing a genuinely `orphaned` badge. Distinct ids digest + * to distinct keys. + * + * Rejecting the frame instead would take the op dark, which is the opposite of + * the ingest's own rule for input it cannot use (an undecodable frame becomes a + * `"malformed"` sentinel, never a drop), and it would discard legitimate frames + * from any host whose ids are merely long. The digest keeps the op observable + * and correlatable while bounding what is retained. + * + * The length suffix is diagnostic: it says how long the id the host sent + * actually was, which is the fact an operator needs to see. + */ +export function normalizeId( + id: string, + maxChars: number = DEFAULT_MAX_ID_CHARS, +): string { + if (id.length <= maxChars) return id; + return `…${digest(id)}:${String(id.length)}`; +} + +/** + * 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; + /** + * Whether this envelope's producer affirmatively vouched for the debugger's wire + * contract. Set by the mount that parsed the identity fields; carried onto every + * frame so decode is gated per frame rather than per channel. + */ + identityConfirmed?: boolean; + /** + * Epoch ms at which the *producer* saw the frame cross the tap, stamped by the + * host link at emit time. + * + * The debugger's own clock cannot stand in for this. A host tap buffers a + * backlog while the debugger is absent and flushes it in one loop on connect, + * so every frame of a session that ran before the debugger started would be + * stamped with the same flush instant: durations collapse to 0ms and ops + * minutes apart land inside the retry-storm window. The producer is the only + * party that knows when a frame actually crossed. + * + * Optional because a host may not stamp it (a pre-identity or foreign tap); + * such frames fall back to the ingest clock and are marked as such - see + * {@link ObservedFrame.timestampFromProducer}. + */ + observedAt?: number; + /** + * The producer replayed this frame from its backlog rather than streaming it + * live, so its arrival order and arrival time are the link's, not the + * session's. Piggybacked on the envelope the same way `dropped` is. + */ + buffered?: boolean; +} + +// Both fields below are produced *only* here, and `ObservedFrame` is the contract +// every consumer reads, so they are declared onto it rather than pushing every +// consumer through an ingest-specific subtype. Fold them into +// `observed-frame.ts` proper when that file is next touched. +declare module "./observed-frame.js" { + interface ObservedFrame { + /** + * The producer replayed this frame from its backlog (the debugger was absent + * or slow) instead of streaming it live. Present only when true. + * + * Provenance, not a verdict on `timestamp`: a buffered frame that also + * carries {@link ObservedFrame.timestampFromProducer} has a real observation + * time and its timings are sound. A buffered frame *without* it has only the + * flush instant, and every duration derived from it - `roundTripMs`, the + * retry-storm window - is meaningless. + */ + buffered?: true; + /** + * `timestamp` is the producer's own observation time rather than the moment + * ingest decoded the frame. Present only when true. + */ + timestampFromProducer?: true; + } +} + +/** + * An `observedAt` fit to be used as a timestamp, or `undefined`. + * + * Anything able to reach the tap can put anything in this field, and it feeds + * trace ordering and every duration, so a non-finite or non-positive value falls + * back to the ingest clock rather than poisoning the trace list. + */ +function producerTimestamp(observedAt: number | undefined): number | undefined { + if (typeof observedAt !== "number") 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; +} + +/** 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; + /** + * 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; + /** + * Length above which a `channelId` / `requestId` is replaced by a digest + * ({@link normalizeId}). 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; +} + +/** + * Ingest that decodes each {@link DebugFrameEnvelope} and forwards the resulting + * {@link ObservedFrame} to `sink` (typically a {@link WireDebugger}'s `observe`). + * + * `role` is a pure function of the frame's wire discriminant: the generated wire + * table already states, per `frameId`, which leg of a method it is, so `role` is + * resolved here from `methodNames` rather than reconstructed from correlation + * state. Resolving it at ingest is what makes it true for *every* consumer - + * the default `console.debug` sink, the `forward` hook, and the trace engine - + * instead of only for the view adapter, which resolves one layer further down + * (`wireTraceToView`) and would leave the other two reading `"unknown"`. + * + * `role` falls back to `"unknown"` in exactly two cases: no `methodNames` map was + * given, or the id is off-table (a frame from a newer host). 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. + * + * `timestamp` is the producer's `observedAt` whenever the tap stamped a usable + * one, and the ingest clock otherwise. Which of the two it is, and whether the + * frame was replayed from the tap's backlog, are recorded on the frame + * ({@link ObservedFrame.timestampFromProducer}, {@link ObservedFrame.buffered}), + * because a flushed backlog arrives in a single loop: read as observation times, + * those instants collapse every duration to 0ms and pull ops minutes apart into + * one retry-storm window. + */ +export function createDebugIngest( + sink: TransportObserver, + options: DebugIngestOptions = {}, +): (envelope: DebugFrameEnvelope) => void { + const retainBytes = options.retainBytes ?? false; + const methodNames = options.methodNames; + const maxIdChars = options.maxIdChars ?? DEFAULT_MAX_ID_CHARS; + return (envelope) => { + const channelId = normalizeId(envelope.channelId, maxIdChars); + // Prefer the producer's observation time; the ingest clock is a fallback, and + // one that is wrong by the whole duration of the session for a flushed + // backlog. `provenance` is what lets a consumer tell the two apart instead of + // reading every timestamp as an observation time. + const producerAt = producerTimestamp(envelope.observedAt); + const timestamp = producerAt ?? Date.now(); + const provenance = { + ...(envelope.buffered === true ? { buffered: true as const } : {}), + ...(producerAt !== undefined ? { timestampFromProducer: true as const } : {}), + // Per-frame, deliberately: see ObservedFrame.identityConfirmed. + ...(envelope.identityConfirmed === true + ? { identityConfirmed: true as const } + : {}), + }; + const decoded = decodeWireMessage(envelope.frame); + if (decoded.isErr()) { + sink({ + channelId, + direction: envelope.dir, + requestId: "malformed", + frameId: -1, + role: "malformed", + byteLength: envelope.frame.length, + timestamp, + ...provenance, + }); + return; + } + const { requestId, payload } = decoded.value; + const frame: ObservedFrame = { + channelId, + direction: envelope.dir, + requestId: normalizeId(requestId, maxIdChars), + frameId: payload.id, + // 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, + ...provenance, + ...(retainBytes ? { bytes: payload.value } : {}), + }; + sink(frame); + }; +} diff --git a/js/packages/truapi-debugger/src/inspector-styles.ts b/js/packages/truapi-debugger/src/inspector-styles.ts new file mode 100644 index 000000000..e300ded01 --- /dev/null +++ b/js/packages/truapi-debugger/src/inspector-styles.ts @@ -0,0 +1,247 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * The inspector chrome: every rule the Network-tab shell needs that is not a + * per-frame drill-down rule (those live in `trace-styles.ts`). + * + * Shared so the two mounts cannot drift apart visually. The standalone app puts + * the shell in a full page; the in-app embed puts the same shell in a panel + * inside the host. Neither owns these rules, so a change lands in both. + * + * The rules are written FLAT (`.ins-top`, `.td-op`, …) because that is correct + * for the standalone: it owns its document, and flat rules keep the shared source + * readable and diffable against dotli's stylesheet. An embed shares a document + * with the host application, where a flat `.td-*` rule would restyle the host's + * own debug panel, so the embed does not inject these constants directly - it runs + * them through {@link scopeCss} first. That keeps one source of truth with two + * correct injections instead of a second, pre-scoped copy. + * + * Deliberately free of page-level rules (`html`, `body`, viewport units): a mount + * scopes its own container, and an embed must never restyle its host's page. + * + * @module + */ + +/** + * At-rules whose body is a list of style rules, so scoping recurses into it. + * Anything else with a block (`@keyframes`, `@font-face`, `@property`) has a body + * that is NOT selectors and is passed through untouched. + */ +const NESTED_AT_RULES: ReadonlySet = new Set([ + "media", + "supports", + "layer", + "container", +]); + +/** Index of the `}` matching the `{` at `open`, or the end of the string. */ +function matchBrace(css: string, open: number): number { + let depth = 0; + for (let i = open; i < css.length; i++) { + const c = css[i]; + // A quoted value may contain a brace (`content: "}"`); skip the string. + if (c === '"' || c === "'") { + const end = css.indexOf(c, i + 1); + if (end === -1) return css.length; + i = end; + continue; + } + if (c === "{") depth += 1; + else if (c === "}") { + depth -= 1; + if (depth === 0) return i; + } + } + return css.length; +} + +/** Prefix every selector in a comma-separated list with `scope`. */ +function scopeSelectorList(selectors: string, scope: string): string { + return selectors + .split(",") + .map((s) => s.trim()) + .filter((s) => s !== "") + .map((s) => `${scope} ${s}`) + .join(", "); +} + +/** + * Rewrite every rule in `css` so it only matches inside `scope`. + * + * This is what lets one flat shared stylesheet serve both mounts: the standalone + * injects the constants as-is (it owns the page), and an embed injects + * `scopeCss(css, ".td-inapp")` so not one rule can reach the host application's + * own markup. Every selector is prefixed, so relative precedence inside the + * block is unchanged (each selector gains the same specificity) - the cascade the + * standalone sees is the cascade the embed sees. + * + * `scope` is a selector (`".td-inapp"`), not a class name. Rules that target the + * mount root itself are the mount's own business and are written already-scoped, + * not passed through here. + */ +export function scopeCss(css: string, scope: string): string { + // Comments can contain braces and selectors; drop them before parsing. + return scopeRules(css.replace(/\/\*[\s\S]*?\*\//g, ""), scope); +} + +/** Scope one block's worth of rules (top level, or an at-rule body). */ +function scopeRules(css: string, scope: string): string { + const out: string[] = []; + let i = 0; + while (i < css.length) { + const brace = css.indexOf("{", i); + if (brace === -1) break; + let prelude = css.slice(i, brace).trim(); + const end = matchBrace(css, brace); + const body = css.slice(brace + 1, end); + // Statement at-rules (`@import`, `@charset`) end in `;` and carry no block; + // they must stay verbatim and at the top, so split them off the prelude. + const semi = prelude.lastIndexOf(";"); + if (semi !== -1) { + out.push(prelude.slice(0, semi + 1).trim()); + prelude = prelude.slice(semi + 1).trim(); + } + if (prelude.startsWith("@")) { + const name = /^@([\w-]+)/.exec(prelude)?.[1] ?? ""; + out.push( + NESTED_AT_RULES.has(name) + ? `${prelude} {\n${scopeRules(body, scope)}\n}` + : `${prelude} {${body}}`, + ); + } else if (prelude === "") { + out.push(`{${body}}`); + } else { + out.push(`${scopeSelectorList(prelude, scope)} {${body}}`); + } + i = end + 1; + } + return out.join("\n"); +} + +/** + * The shell: top bar, channel chips, the list/detail split, and operation rows. + * Pair with {@link TRACE_DETAIL_CSS} and {@link INSPECTOR_LAYOUT_CSS}. + */ +export const INSPECTOR_SHELL_CSS = ` + .ins-top { display: flex; align-items: center; gap: 12px; padding: 6px 12px; + border-bottom: 1px solid rgba(255,255,255,.08); } + .ins-title { font-weight: 600; letter-spacing: .02em; white-space: nowrap; } + .ins-title .accent { color: #4ade80; } + .ins-channels { display: flex; gap: 6px; flex: 1; flex-wrap: wrap; } + .ins-chan { display: inline-flex; align-items: center; gap: 5px; padding: 1px 9px; + border: 1px solid rgba(255,255,255,.12); border-radius: 10px; background: transparent; + color: #94a3b8; cursor: pointer; font: inherit; } + .ins-chan.active { color: #0a0a0a; background: #4ade80; border-color: #4ade80; } + .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-body { display: grid; grid-template-columns: var(--list-w, 340px) 6px 1fr; + min-height: 0; } + .ins-list { overflow: auto; outline: none; } + .ins-split { cursor: col-resize; background: rgba(255,255,255,.05); } + .ins-split:hover { background: rgba(74,222,128,.4); } + .ins-detail { overflow: auto; padding: 8px 12px; outline: none; } + .td-op { display: flex; align-items: center; gap: 8px; padding: 4px 10px; + cursor: pointer; border-bottom: 1px solid rgba(255,255,255,.03); } + .td-op:hover { background: rgba(255,255,255,.04); } + .td-op.selected { background: rgba(74,222,128,.13); } + .ins-list:focus-visible .td-op.selected { box-shadow: inset 2px 0 0 #4ade80; } + .td-op-kind { width: 12px; text-align: center; } + .td-op-req .td-op-kind { color: #fbbf24; } + .td-op-sub .td-op-kind { color: #c084fc; } + /* Truncate the *start*, not the end: sibling methods share a service prefix + (account.getAccount vs account.getAccountAlias), so clipping the tail + renders two different methods identically. Keeping the tail makes them + distinguishable in a narrow list. */ + .td-op-method { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + direction: rtl; text-align: left; } + .td-op-method.anon { color: #525252; font-style: italic; } + .td-op-meta { color: #6b7280; font-size: 10.5px; white-space: nowrap; } + .td-op-live .td-op-meta { color: #4ade80; } + /* An op that went out and is still unanswered: counts up amber, and reads as a + problem rather than a completed 0ms call. + + PRECEDENCE (pinned by a test): a live subscription whose start frame was + never answered carries BOTH td-op-live and td-op-waiting, and waiting must + win - the row is reporting a stall, not health. Two guards, because either + alone is one edit away from silently flipping the colour back to green: this + rule sits AFTER the .td-op-live rule, and the extra .td-op raises its + specificity above it. */ + .td-op.td-op-waiting .td-op-meta { color: #fbbf24; } + .td-op-badges { display: inline-flex; gap: 4px; } + .td-op-empty, .td-detail-empty { color: #6b7280; padding: 14px; } + .td-frame.cursor { background: rgba(255,255,255,.06); box-shadow: inset 2px 0 0 #94a3b8; } +`; + +/** + * App-level layout applied on top of the shared drill-down rules: the two-column + * frame grid, the filter/sort controls, and the aggregate summary strip. + * Applied after {@link TRACE_DETAIL_CSS} because it overrides some of it. + */ +export const INSPECTOR_LAYOUT_CSS = ` + /* App-level layout for the drill-down (trace-styles.ts stays untouched). + Each frame is a two-column grid: meta on the left, a fixed-width payload + column on the right, so every frame's decoded / blurred box opens in the + same aligned partitioned space instead of trailing variable-width meta. */ + .ins-detail { padding: 6px 10px 10px; } + .td-frame { display: grid; align-items: start; column-gap: 10px; + grid-template-columns: minmax(0, 1fr); padding: 4px 8px; } + .td-frame:has(.td-frame-payload) { + grid-template-columns: minmax(0, 1fr) var(--payload-w, clamp(240px, 44%, 520px)); } + .td-frame-meta { display: flex; align-items: center; gap: 8px; min-width: 0; } + .td-frame-meta .td-frame-method { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .td-frames .td-frame:nth-child(even) { background: rgba(255,255,255,.02); } + .td-frame:hover { background: rgba(255,255,255,.05); } + /* The payload column: same width for every frame; content scrolls inside. */ + .td-frame-payload { min-width: 0; } + .td-frame-decoded > * { margin: 0; } + .td-frame-decoded .td-detail-pre { max-height: 240px; overflow: auto; margin: 0; + white-space: pre; } + /* 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; } + .td-op.filtered-out { display: none; } + /* Clickable top-method pills. */ + .ins-method { cursor: pointer; } + .ins-method:hover { border-color: rgba(74,222,128,.5); color: #d1fae5; } + /* 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); + background: rgba(255,255,255,.02); overflow-x: auto; } + .ins-stat { display: flex; flex-direction: column; gap: 1px; padding: 2px 10px 2px 0; + border-right: 1px solid rgba(255,255,255,.06); } + .ins-stat:last-child { border-right: 0; } + .ins-stat .n { font-size: 14px; font-weight: 600; color: #f1f5f9; + font-variant-numeric: tabular-nums; line-height: 1.15; } + .ins-stat .k { font-size: 9.5px; text-transform: uppercase; letter-spacing: .06em; color: #64748b; } + .ins-stat.warn .n { color: #f87171; } + /* The zero class dims a count that is currently nothing. It must NOT require + the warn class alongside it: an informational tile (infoStat/infoTile) emits + "ins-stat zero" with no warn, and while this rule was warn-only that tile + rendered its 0 at full headline brightness, identical to a non-zero - no + signal at all, which defeats the point of counting it. NOTE no backticks in + this block: it lives inside a TS template literal and a backtick would + terminate the string. */ + .ins-stat.zero .n { color: #475569; } + .ins-stat.warn.zero .n { color: #475569; } + .ins-stat.good .n { color: #4ade80; } + .ins-stat .sub { color: #64748b; font-weight: 400; font-size: 10px; } + /* Pills stay on one row, pushed right; when the viewport is too narrow the + whole summary scrolls (overflow-x above) rather than the pills wrapping to a + second line. */ + .ins-methods { display: flex; align-items: center; gap: 6px; margin-left: auto; + flex: 0 0 auto; flex-wrap: nowrap; } + .ins-method { white-space: nowrap; } + .ins-method { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; + border: 1px solid rgba(255,255,255,.08); border-radius: 10px; color: #94a3b8; + font-size: 10.5px; white-space: nowrap; } + .ins-method b { color: #cbd5e1; font-variant-numeric: tabular-nums; } + .ins-summary.empty { color: #64748b; } + .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; } +`; 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..041ce7dbf --- /dev/null +++ b/js/packages/truapi-debugger/src/observed-frame.ts @@ -0,0 +1,109 @@ +/** + * 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. + */ +/** + * Roles that OPEN an op. Lives here, in the leaf module, because both the + * retention engine and the view layer need it: the engine protects the opener + * from eviction and the storm detector keys on its frame id, and both of those + * were previously written as `frames[0]` on the assumption the opener is the + * first frame observed. It is not - both mounts start mid-session, so the first + * frame seen for an id is often a closer for a request that predates the tap. + */ +export const OPENING_ROLES: ReadonlySet = new Set([ + "request", + "start", +]); + +/** + * Index of the frame that opened this op, or `-1` when no opener was observed. + * Takes anything carrying a {@link FrameRole}, so both the raw + * {@link ObservedFrame} sequence and the view layer's projections can use it + * (the op began before the tap attached). Callers that need a frame to anchor on + * regardless should fall back to `0`, never assume `0` IS the opener. + */ +export function openerIndexOf( + frames: readonly { readonly role: FrameRole }[], +): number { + return frames.findIndex((f) => OPENING_ROLES.has(f.role)); +} + +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; + /** + * Whether the producer of THIS frame affirmatively vouched for the wire contract + * the debugger decodes with (matching envelope version, codec version and wire + * schema hash). + * + * Identity has to travel with the frame, not with its channel. A per-channel + * verdict is a latch: one attested frame flipped the channel to trusted and every + * unattested frame already retained under that `channelId` became decodable + * retroactively. `channelId` is the productId, so a stale host and a fresh host + * serving the same product share it, and an embedding host that learns its core's + * schema hash asynchronously tees unattested frames before it knows it. + * + * Absent means "not vouched for": group it, list it, never decode it. + */ + identityConfirmed?: boolean; +} + +/** + * 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..70356f147 --- /dev/null +++ b/js/packages/truapi-debugger/src/operation-row.test.ts @@ -0,0 +1,133 @@ +// 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 { + channelId: "host-a.dot", + 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, + generation: 0, + truncated: false, + dropped: { framesByCount: 0, framesByBytes: 0, payloadsShed: 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(); +} + +/** + * A trace whose FIRST observed frame is a stale closer and whose opener lands at + * index 1 - the cold-start shape, since both mounts attach mid-session. + */ +function closerFirstTrace( + requestId: string, + openerFrameId: number, + startedAt: number, + channelId = "c", +): WireTrace { + const mk = ( + frameId: number, + role: ObservedFrame["role"], + ): ObservedFrame => ({ + channelId, + direction: "out", + requestId, + frameId, + role, + byteLength: 0, + timestamp: startedAt, + }); + return { + channelId, + requestId, + frames: [mk(openerFrameId + 1, "response"), mk(openerFrameId, "request")], + startedAt, + lastAt: startedAt, + generation: 0, + truncated: false, + dropped: { framesByCount: 0, framesByBytes: 0, payloadsShed: 0 }, + }; +} + +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, + generation: 0, + truncated: false, + dropped: { framesByCount: 0, framesByBytes: 0, payloadsShed: 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"]); + }); + test("keys on the opener, so a stale-closer-first op still storms", () => { + // Three retries of ONE method, each of whose stale closer arrived before its + // request. This first group is NOT the load-bearing case: under the old + // `frames[0]` keying it also stormed, because three closers of one method + // share one response id and group together. It is here to pin that the fix + // does not break the uniform case. + const retries = [ + closerFirstTrace("a", 22, 0), + closerFirstTrace("b", 22, 10), + closerFirstTrace("c", 22, 20), + ]; + expect(stormedIds(detectRetryStorms(retries, { windowMs: 1000 }))).toEqual([ + "a", + "b", + "c", + ]); + + // THIS is the case the fix exists for: a method whose ops are MIXED, some + // observed from their request and some from a stale closer. The old keying + // split them across the request-id and response-id groups, each below + // `minBurst`, so a genuine storm reported nothing - `[]` instead of d/e/f. + // Keying on the opener collapses them into one group, since both resolve to + // frameId 22. + const mixed = [ + trace("d", 22, 0), + closerFirstTrace("e", 22, 10), + trace("f", 22, 20), + ]; + expect(stormedIds(detectRetryStorms(mixed, { windowMs: 1000 }))).toEqual([ + "d", + "e", + "f", + ]); + }); + +}); 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..915d4847f --- /dev/null +++ b/js/packages/truapi-debugger/src/retry-storm.ts @@ -0,0 +1,124 @@ +// 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 { openerIndexOf } from "./observed-frame.js"; +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`, whose 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. + * + * Keyed on the opener's real index rather than `frames[0]`. Both mounts attach + * mid-session, so the first frame observed is often a closer for a request that + * predates the tap, and `frames[0]` then yields the RESPONSE id. + * + * The defect that causes is GROUP DILUTION, not a wholesale miss. Ops that are all + * closer-first still group together (their closers share one response id), so they + * still storm. What breaks is a method whose ops are MIXED - some observed from + * their request, some from a stale closer: those split across two signatures, and + * each half can fall under `minBurst` so a real storm goes unreported. Keying on + * the opener collapses them back into one group. Falls back to `frames[0]` when no + * opener was observed at all, which at least keys consistently within a group. + */ +function signature(trace: WireTrace): string | undefined { + const opener = openerIndexOf(trace.frames); + const frameId = trace.frames[opener === -1 ? 0 : opener]?.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) { + // 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. + // Deliberately `frames[0]`, NOT the opener: `trace.startedAt` is set from + // `frames[0].timestamp` and never recomputed, so "is startedAt a real + // observation time or a replay flush instant?" is a question about the frame + // that set it. Pointing this at the opener broke it both ways - six ops a + // genuine ten seconds apart scored six false storms, and three real retries + // scored zero. Only `signature()` above wants the opener. + const first = trace.frames[0]; + if (first?.buffered === true && first.timestampFromProducer !== true) { + continue; + } + 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..42ba0d8c9 --- /dev/null +++ b/js/packages/truapi-debugger/src/session.ts @@ -0,0 +1,362 @@ +/** + * 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 { + isLiveSubscription, + isSubscription, + operationMethod, + type TraceView, +} from "./trace-view.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. On by default + * (this is a dev-only tool that decodes everything). When on, the session + * retains raw frame bytes so {@link DebugSession.frameDetail} can decode a + * frame; `/traces` stays payload-blind regardless (it never reads bytes or + * 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; + /** + * Ops carrying a closing frame with no opener in view. Reported separately from + * `orphaned` and NOT as a warning: the common cause is the debugger attaching + * mid-op, which is not a host fault. Counted rather than dropped because a real + * double-answer lands here too and would otherwise appear in no aggregate at + * all - only in one op's row. + */ + unpaired: 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. Note what that + * does NOT mean: an op's span is `lastAt - startedAt`, i.e. first frame to last + * frame OBSERVED, with no reference to now. A request that is still hanging has + * one frame, so its span is 0 and it pulls the average DOWN - a stream full of + * hung calls reads as a fast session here, even though the operation row renders + * a live `waiting 9m 59s`. Reporting an open op's true elapsed time would need a + * clock passed in; the row-level fix was never carried up to this aggregate. + */ +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 unpaired = 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("unpaired")) unpaired += 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, + unpaired, + 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. */ +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; + /** + * 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; otherwise it reports byte length only. + */ + frameDetail( + requestId: string, + index: number, + channelId?: string, + generation?: number, + ): FrameValueDetail | undefined; + /** + * Decode every frame of one op in a single trace resolution, keyed by frame + * index (`seq`). This is the batch path the inline drill-down uses, so a mount + * resolves the op once rather than re-resolving it per frame. Empty when decode + * is off or the op is not found. + */ + decodedFrames( + requestId: string, + channelId?: string, + generation?: number, + ): Map; +} + +/** + * 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 { + // Dev-only tool: decode everything by default. The developer is looking at + // their own session's traffic, so value decode is ON unless a caller explicitly + // turns it off (tests do). + const decodeValues = options.decodeValues ?? true; + 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. + // + // 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 + // the real role rather than "unknown". + const handleEnvelope = createDebugIngest(wireDebugger.observe, { + retainBytes: decodeValues, + methodNames, + }); + const decoder = createFrameDecoder({ enabled: decodeValues }); + + const frameDetail = ( + requestId: string, + index: number, + channelId?: string, + generation?: number, + ): FrameValueDetail | undefined => { + const frame = wireDebugger.trace(requestId, channelId, generation)?.frames[ + index + ]; + return frame ? decoder.detail(frame) : undefined; + }; + + const decodedFrames = ( + requestId: string, + channelId?: string, + generation?: number, + ): Map => { + const decoded = new Map(); + if (!decodeValues) return decoded; + // Resolve the op once, then decode each frame off the resolved trace, rather + // than re-resolving (a linear scan over every retained trace) per frame. + const trace = wireDebugger.trace(requestId, channelId, generation); + if (!trace) return decoded; + trace.frames.forEach((frame, index) => { + const detail = decoder.detail(frame); + if (detail !== undefined) decoded.set(index, detail); + }); + return decoded; + }; + + return { + handleEnvelope, + traceEngine: wireDebugger, + methodNames, + decodeValues, + frameDetail, + decodedFrames, + }; +} + +/** + * Decode every frame of an op up front, keyed by frame `seq`, ready to hand to + * {@link renderTraceDetail}'s `decoded` option. A dev-only tool shows values + * inline rather than behind a per-frame control, so a mount decodes the whole + * op in one pass. Returns an empty map when the session has decode off. + */ +export function decodeTraceFrames( + session: DebugSession, + view: TraceView, +): Map { + return session.decodedFrames(view.requestId, view.channelId, view.generation); +} 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..5ec663a84 --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-render.test.ts @@ -0,0 +1,468 @@ +// 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 { 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, + 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("shows byte length for a decodable frame with no resolved value", () => { + // Decode on but no value supplied for the frame: it falls back to its size, + // never a click-to-decode control (a dev-only tool decodes up front). + const html = renderTraceDetail(view, { offerDecode: true }); + expect(html).not.toContain("td-frame-decode-btn"); + expect(html).toContain("payload not shown"); + }); + + 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 bytes-only detail shows byte length, never a value", () => { + const decoded = new Map([ + [0, { kind: "bytes", byteLength: 96 }], + ]); + const html = renderTraceDetail(view, { offerDecode: true, decoded }); + expect(html).toContain("96B"); + expect(html).toContain("payload not shown"); + 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 with no retained hex shows byte length only", () => { + const html = renderFrameValueDetail({ kind: "bytes", byteLength: 12 }); + expect(html).toContain("12B"); + expect(html).toContain("payload not shown"); + }); + + test("bytes with retained hex shows the raw hex, never 'payload not shown'", () => { + const html = renderFrameValueDetail({ + kind: "bytes", + byteLength: 3, + hex: "0x010203", + }); + expect(html).toContain("0x010203"); + expect(html).not.toContain("payload not shown"); + }); +}); + +describe("renderOperationRow — an unanswered op reports how long it has waited", () => { + /** A request that went out and got nothing back: the shape of a hung call. */ + const unanswered: TraceView = { + requestId: "p:4", + channelId: "localhost:3000", + startedAt: 1_000, + lastAt: 1_000, + // One frame, so last === started and the honest span really is 0. + durationMs: 0, + frames: [ + { + seq: 0, + direction: "out", + role: "request", + method: "account.getAccountAlias", + frameId: 24, + byteLength: 97, + timestamp: 1_000, + latencyFromStartMs: 0, + decodable: false, + badges: ["orphaned"], + }, + ], + badges: ["orphaned"], + }; + + test("counts up from the request instead of reporting 0ms", () => { + // 45s after the request went out, with no reply. + const html = renderOperationRow(unanswered, { now: 46_000 }); + expect(html).toContain("waiting 45.00s"); + expect(html).not.toContain("· 0ms"); + // Flagged so the row can be styled as a problem, not a fast success. + expect(html).toContain("td-op-waiting"); + }); + + test("the wait grows as the call stays unanswered", () => { + const early = renderOperationRow(unanswered, { now: 3_000 }); + const later = renderOperationRow(unanswered, { now: 30_000 }); + expect(early).toContain("waiting 2.00s"); + expect(later).toContain("waiting 29.00s"); + }); + + test("without a clock it falls back to the recorded span", () => { + // Callers that cannot supply a clock (or replay a fixed trace) keep the old + // behaviour rather than inventing a time. + const html = renderOperationRow(unanswered); + expect(html).toContain("0ms"); + expect(html).not.toContain("waiting"); + expect(html).not.toContain("td-op-waiting"); + }); + + test("an answered op still shows its real round trip, not a wait", () => { + const answered: TraceView = { + ...unanswered, + requestId: "p:2", + lastAt: 1_150, + durationMs: 150, + frames: [ + { ...unanswered.frames[0]!, badges: [] }, + { + seq: 1, + direction: "in", + role: "response", + method: "account.getAccount", + frameId: 23, + byteLength: 35, + // Distinct from the request it answers: identical timestamps would make + // this fixture describe an impossible 0ms reply if it is ever fed to + // renderTraceDetail, which does read these. + timestamp: 1_120, + latencyFromStartMs: 120, + decodable: false, + badges: [], + }, + ], + badges: [], + }; + const html = renderOperationRow(answered, { now: 999_999 }); + 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", () => { + // `orphaned` is now opener-only by construction: every closer with no opener + // earns `unpaired`. These cases used to earn `orphaned` too, and reading that as + // "unanswered" pre-empted the honest duration with a nonsense wait. The badge + // split removes the ambiguity; these tests pin that the render still never + // reports a wait for any of them. + + 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: `unpaired`, + // not an unanswered request. + expect(view.badges).toContain("unpaired"); + expect(view.badges).not.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 no + // receive has an opener. The sub is delivering a frame a second - `unpaired` + // states that plainly instead of implying the host never answered. + const view = viewOf([ + [41, 1_000], + [41, 2_000], + [41, 3_000], + ]); + expect(view.badges).toContain("unpaired"); + expect(view.badges).not.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", is not recognised as an opener, and its response has none — but + // the call did complete, so `orphaned` would be a lie about the host. + const view = viewOf([ + [999, 1_000], + [23, 1_120], + ]); + expect(view.badges).toContain("unpaired"); + expect(view.badges).not.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 new file mode 100644 index 000000000..033b27de5 --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-render.ts @@ -0,0 +1,404 @@ +// 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/unpaired/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}: a frame renders either its decoded + * value or its byte length. + * + * 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 { + 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 { + /** + * 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; + /** + * Decoded values for this op, keyed by frame `seq`. A dev-only mount decodes + * every frame up front (calling the Core session's `frameDetail`) and passes + * the results here. A frame absent from the map falls back to its byte length. + */ + decoded?: ReadonlyMap; +} + +/** 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 "'"; + } + }); +} + +/** + * 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`; + 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 = { + 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 decoded = options.decoded; + + const header = renderHeader(view); + const rows = view.frames + .map((frame) => renderFrameRow(frame, offerDecode, decoded?.get(frame.seq))) + .join(""); + + return ( + `
` + + header + + `
${rows}
` + + `
` + ); +} + +function renderHeader(view: TraceView): string { + const badges = view.badges + .map((b) => renderOpBadge(b, view.dropped)) + .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", + unpaired: "unpaired", + malformed: "malformed", + "retry-storm": "retry storm", + truncated: "truncated", +}; + +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)}`; +} + +/** 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 - it went out and nothing came back"; + case "unpaired": + return "A closing frame with no opener observed - an op that began before the debugger attached, a close the engine outlived, or a second close. Not a host fault on its own"; + 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"; + case "truncated": + return dropped === undefined + ? "Older frames were dropped to stay under the frame/byte cap" + : truncationTitle(dropped); + } +} + +const FRAME_BADGE_LABEL: Record = { + malformed: "malformed", + orphaned: "orphaned", + unpaired: "unpaired", +}; + +function renderFrameRow( + frame: TraceFrameView, + offerDecode: 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)}`; + 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 + + size + + latency + + (badges === "" ? "" : `${badges}`) + + `
`; + + const payload = + offerDecode && frame.decodable + ? `
${renderDecodeBlock(frame, 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 payload slot for one frame. A dev-only tool decodes every frame, + * so this shows the decoded value; a frame whose value could not be resolved + * (bytes not retained, or a decode miss) shows its byte length instead. + */ +function renderDecodeBlock( + frame: TraceFrameView, + detail: FrameValueDetail | undefined, +): string { + if (detail !== undefined) { + return `
${renderFrameValueDetail(detail)}
`; + } + const size = + frame.byteLength === undefined ? "" : `${String(frame.byteLength)}B · `; + return `
${size}payload not shown
`; +} + +/** + * Render a Core-thread {@link FrameValueDetail}. Shared by both mounts so the + * outcome is identical everywhere: a frame shows its decoded value, or its byte + * length when no value is available. + */ +export function renderFrameValueDetail(detail: FrameValueDetail): string { + switch (detail.kind) { + case "bytes": + // Show the raw hex when we have it (dev-only: nothing is hidden); only a + // frame with no retained bytes reads "payload not shown". + return detail.hex !== undefined + ? `
${String(detail.byteLength)}B · ${esc(detail.hex)}
` + : `
${String(detail.byteLength)}B · payload not shown
`; + case "decoded": + return `
${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);
+  }
+}
+
+/**
+ * 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 role check is now belt-and-braces rather than load-bearing: `orphaned` is
+ * opener-only by construction, since a closer with no opener earns `unpaired`
+ * instead. It used to be essential - the badge fired on both, and the
+ * shapes it caught are often perfectly live: 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" reported a subscription delivering a frame a second as
+ * "waiting 300s", and turned a completed 120ms round trip into "waiting 120s".
+ * Kept so this predicate stays correct on its own terms if the badge derivation
+ * ever widens again.
+ */
+function isUnanswered(view: TraceView): boolean {
+  return view.frames.some(
+    (f) =>
+      (f.role === "request" || f.role === "start") &&
+      f.badges.includes("orphaned"),
+  );
+}
+
+/**
+ * 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,
+  options: { now?: number } = {},
+): string {
+  const method = operationMethod(view);
+  const sub = isSubscription(view);
+  // 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((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
+  // to see, a call that went out and is still hanging. Report the age of the
+  // request instead, so a stuck op counts up rather than looking instant.
+  const waiting = isUnanswered(view) && options.now !== undefined;
+  const meta = waiting
+    ? `${String(count)} frame${count === 1 ? "" : "s"} · waiting ${formatMs(
+        Math.max(0, (options.now ?? 0) - view.startedAt),
+      )}`
+    : `${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)}"`;
+  // 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)}"`;
+
+  return (
+    `
` + + `` + + methodHtml + + (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..45f85689d --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-styles.ts @@ -0,0 +1,190 @@ +// 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 `