From 4069e760a4f477056137c2d25ac19f07c53f0658 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 01:30:09 +0530 Subject: [PATCH 01/50] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to the latest upstream commit. Auto-committed-on: macbook --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index eb5cf72..e5f1cd2 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit eb5cf727071a6eb12f9efa3db8ec59e54039f336 +Subproject commit e5f1cd2d2a7b905ee82410a61219292ca8a85c1f From 2051f7b471ccc6c1dc7650eb1d429632f5a3222b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 01:30:25 +0530 Subject: [PATCH 02/50] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to the latest upstream commit. Auto-committed-on: macbook --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index e5f1cd2..eb5cf72 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit e5f1cd2d2a7b905ee82410a61219292ca8a85c1f +Subproject commit eb5cf727071a6eb12f9efa3db8ec59e54039f336 From ad1e708fa22c54de539b0a79ffd4c35ba6dabe8d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 01:33:28 +0530 Subject: [PATCH 03/50] chore(workspace): add tinydocs-client to workspace members Register the new tinydocs-client crate as a workspace member and include it in the default members so it builds with the rest of the workspace by default. Auto-committed-on: macbook --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9846f17..7ff2641 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,8 +22,8 @@ exclude = [ ] [workspace] -members = ["crates/tinydocs-bus", "crates/tinydocs-module"] -default-members = [".", "crates/tinydocs-bus", "crates/tinydocs-module"] +members = ["crates/tinydocs-bus", "crates/tinydocs-client", "crates/tinydocs-module"] +default-members = [".", "crates/tinydocs-bus", "crates/tinydocs-client", "crates/tinydocs-module"] exclude = ["vendor/tinybus"] resolver = "3" From 791ee8fd6b11f1287f5dd3b9999458958e68dcdd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 01:35:01 +0530 Subject: [PATCH 04/50] feat(tinydocs-client): add client crate with recording broker test Introduce a new tinydocs-client crate exposing the client library entry point. A recording broker test double is included so client behaviour can be exercised without a live broker. Auto-committed-on: macbook --- crates/tinydocs-client/Cargo.toml | 31 + crates/tinydocs-client/src/lib.rs | 5 + .../tinydocs-client/tests/recording_broker.rs | 604 ++++++++++++++++++ 3 files changed, 640 insertions(+) create mode 100644 crates/tinydocs-client/Cargo.toml create mode 100644 crates/tinydocs-client/src/lib.rs create mode 100644 crates/tinydocs-client/tests/recording_broker.rs diff --git a/crates/tinydocs-client/Cargo.toml b/crates/tinydocs-client/Cargo.toml new file mode 100644 index 0000000..6e58c88 --- /dev/null +++ b/crates/tinydocs-client/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "tinydocs-client" +version.workspace = true +edition = "2024" +rust-version = "1.88" +license = "GPL-3.0-only" +description = "Typed TinyBus client for the TinyDocs module." +repository = "https://github.com/tinyhumansai/tinydocs" +documentation = "https://docs.rs/tinydocs-client" +keywords = ["tinybus", "document", "agent"] +categories = ["api-bindings"] + +[dependencies] +# Module loading and typed failure reporting stay host-owned; this is the +# published host-facing SDK without its native loader. +tinybus = { version = "0.1.8", default-features = false } +# Shared wire names and payload DTOs. +tinydocs-bus = { version = "0.2.4", path = "../tinydocs-bus" } +# The held-output protocol uses base64 chunks and verifies their SHA-256 digest. +base64 = "0.22" +sha2 = "0.10" +serde_json = "1" +# Cancellation-safe output release schedules cleanup on the caller's runtime. +tokio = { version = "1", features = ["rt", "time"] } + +[dev-dependencies] +async-trait = "0.1" +tokio = { version = "1", features = ["macros", "rt", "sync", "time"] } + +[lints] +workspace = true diff --git a/crates/tinydocs-client/src/lib.rs b/crates/tinydocs-client/src/lib.rs new file mode 100644 index 0000000..cc12819 --- /dev/null +++ b/crates/tinydocs-client/src/lib.rs @@ -0,0 +1,5 @@ +//! Typed host client for the `tinydocs` TinyBus module. +//! +//! Module loading, readiness, host policy, and transport ownership stay with +//! the embedding host. The client accepts that host's `ModuleClient` and bus +//! connection, then owns TinyDocs contract calls and held-output transfer. diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs new file mode 100644 index 0000000..c396ea7 --- /dev/null +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -0,0 +1,604 @@ +//! Product-facing TinyDocs client tests over an in-process recording broker. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use tinybus::broker::Broker; +use tinybus::module::{ + AccessFuture, FailureReport, FailureReporter, FailureSink, ModuleAccess, ModuleCallError, + ModuleClient, ProxyFailure, Reason, ReleaseRecord, +}; +use tinybus::service::Interface; +use tinybus::transport::memory::MemoryBus; +use tinybus::{Connection, Error, InterfaceName, MemberName, ObjectPath, Proxy}; +use tinydocs_bus::names::{BUS_NAME, OBJECT_PATH, METHODS, methods}; +use tinydocs_bus::{DocumentFormat, DocumentSpec, ExtractDocumentSpec, OutputRef, RenderPdfSpec}; +use tinydocs_client::{DocumentCallError, TinyDocsClient}; +use tokio::sync::Notify; + +const MODULE_ID: &str = "tinydocs"; +const CHUNK_BYTES: u64 = 1024 * 1024; +const MAX_DOCUMENT_BYTES: u64 = 64 * 1024 * 1024; + +static RECORD: ReleaseRecord = ReleaseRecord { + id: MODULE_ID, + bus_name: BUS_NAME, + object_path: OBJECT_PATH, + version: "0.2.4", + release_url: "https://github.com/tinyhumansai/tinydocs/releases/tag/v0.2.4", + assets: &[], +}; + +#[derive(Default)] +struct NoopSink; + +impl FailureSink for NoopSink { + fn capture(&self, _report: &FailureReport<'_>) {} +} + +struct FixtureAccess { + connection: Connection, + reporter: FailureReporter, +} + +impl ModuleAccess for FixtureAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == MODULE_ID).then_some(&RECORD) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + Box::pin(async move { + self.connection + .proxy(bus_name, object_path, bus_name) + .map_err(|_| ProxyFailure::Contract) + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Call { + member: String, + args: Value, +} + +#[derive(Default)] +struct State { + calls: Vec, + input_streams: BTreeMap>, + outputs: HashMap>, + generated_bytes: Vec, + next_output: usize, + faults: HashMap, + declared_size: Option, + declared_digest: Option, + read_mode: ReadMode, + block_reads: bool, + read_started: Arc, + release_seen: Arc, +} + +#[derive(Clone, Copy, Default)] +enum ReadMode { + #[default] + Normal, + Empty, + InvalidBase64, + Overlong, +} + +#[derive(Clone)] +struct RecordingService { + connection: Connection, + state: Arc>, +} + +impl RecordingService { + fn hold(&self, bytes: Vec) -> OutputRef { + let mut state = self.state.lock().expect("fixture state lock"); + state.next_output += 1; + let output_id = format!("output-{}", state.next_output); + let sha256 = state + .declared_digest + .clone() + .unwrap_or_else(|| digest(&bytes)); + let total_bytes = state.declared_size.unwrap_or(bytes.len() as u64); + state.outputs.insert(output_id.clone(), bytes); + OutputRef { + output_id, + total_bytes, + sha256, + } + } + + async fn read_stream_arg(&self, args: &Value, index: usize, member: &str) -> tinybus::Result<()> { + let stream: tinybus::stream::StreamRef = serde_json::from_value(args[index].clone())?; + let bytes = self.connection.read_stream(&stream).await?; + self.state + .lock() + .expect("fixture state lock") + .input_streams + .insert(member.to_string(), bytes); + Ok(()) + } +} + +#[async_trait] +impl Interface for RecordingService { + fn name(&self) -> InterfaceName { + InterfaceName::new(BUS_NAME).expect("valid TinyDocs interface") + } + + fn members(&self) -> Vec { + METHODS + .iter() + .map(|member| MemberName::new(*member).expect("valid TinyDocs member")) + .collect() + } + + async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { + let member = member.as_str().to_string(); + let fault = { + let mut state = self.state.lock().expect("fixture state lock"); + state.calls.push(Call { + member: member.clone(), + args: args.clone(), + }); + state.faults.get(&member).cloned() + }; + if let Some((name, message)) = fault { + return Err(Error::MethodFailed { name, message }); + } + + match member.as_str() { + methods::GENERATE_DOCX => { + let bytes = self + .state + .lock() + .expect("fixture state lock") + .generated_bytes + .clone(); + Ok(serde_json::to_value(self.hold(bytes))?) + } + methods::GENERATE_PPTX => { + if !args[1].is_null() { + self.read_stream_arg(&args, 1, &member).await?; + } + Ok(serde_json::to_value(self.hold(b"pptx bytes".to_vec()))?) + } + methods::EXTRACT_TEXT => { + self.read_stream_arg(&args, 0, &member).await?; + Ok(serde_json::to_value(self.hold(b"extracted text".to_vec()))?) + } + methods::EXTRACT_DOCUMENT => { + self.read_stream_arg(&args, 1, &member).await?; + Ok(json!({ + "format": "pdf", + "section_count": 1, + "sections": [{ + "source": "page:1", + "index": 1, + "text": "section text", + "scanned_candidate": false + }], + "truncated": false + })) + } + methods::CONVERT_MARKDOWN => { + self.read_stream_arg(&args, 1, &member).await?; + Ok(serde_json::to_value(self.hold(b"# converted\n".to_vec()))?) + } + methods::INSPECT_IMAGE => { + self.read_stream_arg(&args, 0, &member).await?; + Ok(json!({ "format": "PNG", "width_px": 2, "height_px": 3 })) + } + methods::RENDER_PDF => { + self.read_stream_arg(&args, 1, &member).await?; + let first = self.hold(b"page one".to_vec()); + let second = self.hold(b"page two".to_vec()); + Ok(json!({ + "page_count": 3, + "pages": [ + { "page": 1, "width": 2, "height": 3, "output": first }, + { "page": 3, "width": 4, "height": 5, "output": second } + ] + })) + } + methods::READ_OUTPUT => self.read_output(&args).await, + methods::RELEASE_OUTPUT => { + let output_id = args[0].as_str().expect("output id string"); + self.state + .lock() + .expect("fixture state lock") + .outputs + .remove(output_id); + self.state + .lock() + .expect("fixture state lock") + .release_seen + .notify_waiters(); + Ok(Value::Null) + } + unknown => Err(Error::MethodFailed { + name: "ai.tinyhumans.tinybus.Error.UnknownMethod".into(), + message: format!("unexpected test member {unknown}"), + }), + } + } +} + +impl RecordingService { + async fn read_output(&self, args: &Value) -> tinybus::Result { + let output_id = args[0].as_str().expect("output id string").to_string(); + let offset = args[1].as_u64().expect("output offset"); + let requested = args[2].as_u64().expect("requested length"); + let (mode, block, started, bytes) = { + let state = self.state.lock().expect("fixture state lock"); + ( + state.read_mode, + state.block_reads, + state.read_started.clone(), + state.outputs.get(&output_id).cloned().unwrap_or_default(), + ) + }; + if block { + started.notify_one(); + let released = self + .state + .lock() + .expect("fixture state lock") + .release_seen + .clone(); + released.notified().await; + } + let value = match mode { + ReadMode::Empty => String::new(), + ReadMode::InvalidBase64 => "!".to_string(), + ReadMode::Normal | ReadMode::Overlong => { + let start = usize::try_from(offset).unwrap_or(usize::MAX); + let wanted = usize::try_from(requested).unwrap_or(usize::MAX); + let extra = usize::from(matches!(mode, ReadMode::Overlong)); + let end = start.saturating_add(wanted).saturating_add(extra).min(bytes.len()); + BASE64.encode(bytes.get(start..end).unwrap_or_default()) + } + }; + Ok(Value::String(value)) + } +} + +struct Fixture { + client: TinyDocsClient, + state: Arc>, + _broker: tokio::task::JoinHandle>, +} + +async fn fixture() -> Fixture { + let bus = MemoryBus::new(); + let broker = Broker::new().spawn(bus.clone()); + let server = Connection::connect(bus.connect().await.expect("server transport")) + .await + .expect("server connection"); + let state = Arc::new(Mutex::new(State::default())); + server + .serve_at( + ObjectPath::new(OBJECT_PATH).expect("valid TinyDocs path"), + RecordingService { + connection: server.clone(), + state: state.clone(), + }, + ) + .await + .expect("serve recording module"); + server.request_name(BUS_NAME).await.expect("claim module name"); + + let client_connection = Connection::connect(bus.connect().await.expect("client transport")) + .await + .expect("client connection"); + let proxy = client_connection + .proxy(BUS_NAME, OBJECT_PATH, BUS_NAME) + .expect("fixture proxy"); + let sdk_client = ModuleClient::new(FixtureAccess { + connection: client_connection.clone(), + reporter: FailureReporter::new(Arc::new(NoopSink)), + }) + .with_fixture(proxy); + + Fixture { + client: TinyDocsClient::new(sdk_client, client_connection), + state, + _broker: broker, + } +} + +fn digest(bytes: &[u8]) -> String { + let mut output = String::new(); + for byte in Sha256::digest(bytes) { + use std::fmt::Write as _; + write!(&mut output, "{byte:02x}").expect("writing into String"); + } + output +} + +fn document_spec() -> DocumentSpec { + DocumentSpec { + title: "Broker proof".into(), + author: None, + sections: vec![], + } +} + +#[tokio::test] +async fn typed_operations_use_contract_members_and_stream_document_bytes() { + let fixture = fixture().await; + let input = b"incoming PDF bytes"; + + let generated = fixture + .client + .generate_docx(&document_spec()) + .await + .expect("held DOCX should be assembled"); + assert_eq!(generated, b"docx bytes"); + + let deck = tinydocs_bus::WirePresentationSpec { + title: "Deck".into(), + author: None, + theme: None, + slides: vec![], + }; + let slide_images = b"slide image bytes"; + assert_eq!( + fixture + .client + .generate_pptx(&deck, slide_images) + .await + .expect("PPTX output is assembled"), + b"pptx bytes" + ); + assert_eq!( + fixture.client.extract_text(input).await.expect("text is read"), + "extracted text" + ); + assert_eq!( + fixture + .client + .convert_markdown(DocumentFormat::Pdf, input) + .await + .expect("Markdown is read"), + "# converted\n" + ); + + let extracted = fixture + .client + .extract_document( + input, + &ExtractDocumentSpec::new(DocumentFormat::Pdf), + ) + .await + .expect("typed extraction succeeds"); + assert_eq!(extracted.sections[0].text, "section text"); + + let image = fixture + .client + .inspect_image(b"PNG stream") + .await + .expect("image facts are decoded"); + assert_eq!((image.width_px, image.height_px), (2, 3)); + + let state = fixture.state.lock().expect("fixture state lock"); + assert_eq!(state.input_streams[methods::EXTRACT_DOCUMENT], input); + assert_eq!(state.input_streams[methods::INSPECT_IMAGE], b"PNG stream"); + assert_eq!(state.input_streams[methods::GENERATE_PPTX], slide_images); + assert_eq!(state.input_streams[methods::EXTRACT_TEXT], input); + assert_eq!(state.input_streams[methods::CONVERT_MARKDOWN], input); + assert!(state.calls.iter().any(|call| { + call.member == methods::GENERATE_DOCX && call.args[0]["title"] == "Broker proof" + })); +} + +#[tokio::test] +async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure() { + let fixture = fixture().await; + fixture + .state + .lock() + .expect("fixture state lock") + .faults + .insert( + methods::GENERATE_DOCX.to_string(), + ( + "ai.tinyhumans.tinydocs.Error.InvalidInput".into(), + "title is empty".into(), + ), + ); + assert!(matches!( + fixture.client.generate_docx(&document_spec()).await, + Err(DocumentCallError::InvalidInput(_)) + )); + + fixture + .state + .lock() + .expect("fixture state lock") + .faults + .insert( + methods::GENERATE_DOCX.to_string(), + ( + "ai.tinyhumans.tinydocs.Error.GenerationFailed".into(), + "private generator detail".into(), + ), + ); + let error = fixture + .client + .generate_docx(&document_spec()) + .await + .expect_err("generation failure is returned"); + assert!(matches!(error, DocumentCallError::Failed(_))); + assert!(!error.to_string().contains("private generator detail")); + + fixture + .state + .lock() + .expect("fixture state lock") + .faults + .insert( + methods::GENERATE_DOCX.to_string(), + ( + "ai.tinyhumans.tinybus.Error.ModuleUnavailable".into(), + "module is down".into(), + ), + ); + assert!(matches!( + fixture.client.generate_docx(&document_spec()).await, + Err(DocumentCallError::Unavailable(_)) + )); + + let _sdk_outcome_vocabulary_is_closed = [ + ModuleCallError::Unavailable, + ModuleCallError::IncompatibleContract, + ModuleCallError::TransportFailed, + ModuleCallError::ModuleFault, + ]; +} + +#[tokio::test] +async fn held_outputs_are_chunked_in_order_verified_and_released() { + let fixture = fixture().await; + let bytes = vec![0x5a; (CHUNK_BYTES * 2 + 13) as usize]; + { + let mut state = fixture.state.lock().expect("fixture state lock"); + state.generated_bytes = bytes.clone(); + state.declared_digest = Some(digest(&bytes)); + } + let got = fixture + .client + .generate_docx(&document_spec()) + .await + .expect("output is assembled"); + assert_eq!(got, bytes); + let offsets = fixture + .state + .lock() + .expect("fixture state lock") + .calls + .iter() + .filter(|call| call.member == methods::READ_OUTPUT) + .map(|call| call.args[1].as_u64().expect("read offset")) + .collect::>(); + assert_eq!(offsets, [0, CHUNK_BYTES, CHUNK_BYTES * 2]); + assert!(fixture + .state + .lock() + .expect("fixture state lock") + .outputs + .is_empty()); + + let fixture = fixture().await; + fixture + .state + .lock() + .expect("fixture state lock") + .declared_size = Some(MAX_DOCUMENT_BYTES + 1); + assert!(matches!( + fixture.client.generate_docx(&document_spec()).await, + Err(DocumentCallError::Failed(_)) + )); + let calls = &fixture.state.lock().expect("fixture state lock").calls; + assert!(!calls.iter().any(|call| call.member == methods::READ_OUTPUT)); + assert!(calls.iter().any(|call| call.member == methods::RELEASE_OUTPUT)); + + for mode in [ReadMode::Empty, ReadMode::InvalidBase64, ReadMode::Overlong] { + let fixture = fixture().await; + { + let mut state = fixture.state.lock().expect("fixture state lock"); + state.read_mode = mode; + if matches!(mode, ReadMode::Overlong) { + state.generated_bytes = vec![0x33; CHUNK_BYTES as usize + 16]; + state.declared_size = Some(CHUNK_BYTES); + state.declared_digest = Some(digest(&state.generated_bytes)); + } + } + assert!(matches!( + fixture.client.generate_docx(&document_spec()).await, + Err(DocumentCallError::Failed(_)) + )); + assert!(fixture + .state + .lock() + .expect("fixture state lock") + .calls + .iter() + .any(|call| call.member == methods::RELEASE_OUTPUT)); + } +} + +#[tokio::test] +async fn cancellation_releases_held_output_and_render_releases_every_page() { + let fixture = fixture().await; + fixture.state.lock().expect("fixture state lock").block_reads = true; + let started = fixture + .state + .lock() + .expect("fixture state lock") + .read_started + .clone(); + let release_seen = fixture + .state + .lock() + .expect("fixture state lock") + .release_seen + .clone(); + let client = fixture.client.clone(); + let call = tokio::spawn(async move { client.generate_docx(&document_spec()).await }); + started.notified().await; + call.abort(); + let _ = call.await; + release_seen.notified().await; + assert!(fixture + .state + .lock() + .expect("fixture state lock") + .outputs + .is_empty()); + + let fixture = fixture().await; + let rendered = fixture + .client + .render_pdf( + b"PDF input", + &RenderPdfSpec { + pages: vec![1, 3], + max_dimension: 256, + max_total_pixels: 65_536, + max_output_bytes: 1_048_576, + }, + ) + .await + .expect("selected pages are collected"); + assert_eq!(rendered.iter().map(|(page, _)| *page).collect::>(), [1, 3]); + assert!(fixture + .state + .lock() + .expect("fixture state lock") + .outputs + .is_empty()); +} From c3111299b2db6a86efa46f6d63b0a579cc502718 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 01:36:14 +0530 Subject: [PATCH 05/50] test(tinydocs-client): cover malformed replies and digest rejection Extend the recording broker fixture with configurable malformed replies and a separate resume signal so blocked reads can be released independently of output release. Add cases asserting that a malformed typed reply surfaces as a failure and that a digest mismatch releases the held output. Auto-committed-on: macbook --- crates/tinydocs-client/README.md | 20 ++ .../tinydocs-client/tests/recording_broker.rs | 184 +++++++++++++----- 2 files changed, 151 insertions(+), 53 deletions(-) create mode 100644 crates/tinydocs-client/README.md diff --git a/crates/tinydocs-client/README.md b/crates/tinydocs-client/README.md new file mode 100644 index 0000000..a4502dd --- /dev/null +++ b/crates/tinydocs-client/README.md @@ -0,0 +1,20 @@ +# TinyDocs client + +`tinydocs-client` is the host-side typed client for the `tinydocs` TinyBus +module. It translates contract methods and held outputs into typed Rust calls. +It does not load modules, decide readiness, configure reporting, or choose +deadlines; those choices belong to the embedding host. + +Ordinary typed calls use `ModuleClient::call_detailed`. The existing adapter +also needs streamed input, which the published TinyBus SDK does not currently +expose through `ModuleClient`. The client contract tests specify the required +SDK addition: a detailed streaming call that retains module readiness, +registry-address validation, the configured timeout, and failure reporting. +The host continues to own module loading and transport lifecycle. + +Variable-size results use the contract's held-output protocol. The client +reads bounded base64 chunks, checks the declared length and SHA-256 digest, and +releases each output after success or failure. Cancellation schedules the +release through the caller's Tokio runtime. The contract and behavior are +exercised by `tests/recording_broker.rs`, which serves a local recording +interface over TinyBus's in-memory broker and never contacts a live module. diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index c396ea7..f1719c8 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -10,13 +10,13 @@ use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use tinybus::broker::Broker; use tinybus::module::{ - AccessFuture, FailureReport, FailureReporter, FailureSink, ModuleAccess, ModuleCallError, - ModuleClient, ProxyFailure, Reason, ReleaseRecord, + AccessFuture, FailureReport, FailureReporter, FailureSink, ModuleAccess, ModuleClient, + ProxyFailure, Reason, ReleaseRecord, }; use tinybus::service::Interface; use tinybus::transport::memory::MemoryBus; use tinybus::{Connection, Error, InterfaceName, MemberName, ObjectPath, Proxy}; -use tinydocs_bus::names::{BUS_NAME, OBJECT_PATH, METHODS, methods}; +use tinydocs_bus::names::{BUS_NAME, METHODS, OBJECT_PATH, methods}; use tinydocs_bus::{DocumentFormat, DocumentSpec, ExtractDocumentSpec, OutputRef, RenderPdfSpec}; use tinydocs_client::{DocumentCallError, TinyDocsClient}; use tokio::sync::Notify; @@ -90,12 +90,14 @@ struct State { generated_bytes: Vec, next_output: usize, faults: HashMap, + malformed_replies: HashMap, declared_size: Option, declared_digest: Option, read_mode: ReadMode, block_reads: bool, read_started: Arc, release_seen: Arc, + resume_read: Arc, } #[derive(Clone, Copy, Default)] @@ -131,7 +133,12 @@ impl RecordingService { } } - async fn read_stream_arg(&self, args: &Value, index: usize, member: &str) -> tinybus::Result<()> { + async fn read_stream_arg( + &self, + args: &Value, + index: usize, + member: &str, + ) -> tinybus::Result<()> { let stream: tinybus::stream::StreamRef = serde_json::from_value(args[index].clone())?; let bytes = self.connection.read_stream(&stream).await?; self.state @@ -169,6 +176,19 @@ impl Interface for RecordingService { if let Some((name, message)) = fault { return Err(Error::MethodFailed { name, message }); } + let malformed = self + .state + .lock() + .expect("fixture state lock") + .malformed_replies + .get(&member) + .cloned(); + if let Some(reply) = malformed { + if member == methods::EXTRACT_DOCUMENT { + self.read_stream_arg(&args, 1, &member).await?; + } + return Ok(reply); + } match member.as_str() { methods::GENERATE_DOCX => { @@ -236,7 +256,7 @@ impl Interface for RecordingService { .lock() .expect("fixture state lock") .release_seen - .notify_waiters(); + .notify_one(); Ok(Value::Null) } unknown => Err(Error::MethodFailed { @@ -252,24 +272,19 @@ impl RecordingService { let output_id = args[0].as_str().expect("output id string").to_string(); let offset = args[1].as_u64().expect("output offset"); let requested = args[2].as_u64().expect("requested length"); - let (mode, block, started, bytes) = { + let (mode, block, started, resume, bytes) = { let state = self.state.lock().expect("fixture state lock"); ( state.read_mode, state.block_reads, state.read_started.clone(), + state.resume_read.clone(), state.outputs.get(&output_id).cloned().unwrap_or_default(), ) }; if block { started.notify_one(); - let released = self - .state - .lock() - .expect("fixture state lock") - .release_seen - .clone(); - released.notified().await; + resume.notified().await; } let value = match mode { ReadMode::Empty => String::new(), @@ -278,7 +293,10 @@ impl RecordingService { let start = usize::try_from(offset).unwrap_or(usize::MAX); let wanted = usize::try_from(requested).unwrap_or(usize::MAX); let extra = usize::from(matches!(mode, ReadMode::Overlong)); - let end = start.saturating_add(wanted).saturating_add(extra).min(bytes.len()); + let end = start + .saturating_add(wanted) + .saturating_add(extra) + .min(bytes.len()); BASE64.encode(bytes.get(start..end).unwrap_or_default()) } }; @@ -298,7 +316,9 @@ async fn fixture() -> Fixture { let server = Connection::connect(bus.connect().await.expect("server transport")) .await .expect("server connection"); - let state = Arc::new(Mutex::new(State::default())); + let mut initial_state = State::default(); + initial_state.generated_bytes = b"docx bytes".to_vec(); + let state = Arc::new(Mutex::new(initial_state)); server .serve_at( ObjectPath::new(OBJECT_PATH).expect("valid TinyDocs path"), @@ -309,7 +329,10 @@ async fn fixture() -> Fixture { ) .await .expect("serve recording module"); - server.request_name(BUS_NAME).await.expect("claim module name"); + server + .request_name(BUS_NAME) + .await + .expect("claim module name"); let client_connection = Connection::connect(bus.connect().await.expect("client transport")) .await @@ -324,7 +347,7 @@ async fn fixture() -> Fixture { .with_fixture(proxy); Fixture { - client: TinyDocsClient::new(sdk_client, client_connection), + client: TinyDocsClient::new(sdk_client), state, _broker: broker, } @@ -375,7 +398,11 @@ async fn typed_operations_use_contract_members_and_stream_document_bytes() { b"pptx bytes" ); assert_eq!( - fixture.client.extract_text(input).await.expect("text is read"), + fixture + .client + .extract_text(input) + .await + .expect("text is read"), "extracted text" ); assert_eq!( @@ -389,10 +416,7 @@ async fn typed_operations_use_contract_members_and_stream_document_bytes() { let extracted = fixture .client - .extract_document( - input, - &ExtractDocumentSpec::new(DocumentFormat::Pdf), - ) + .extract_document(input, &ExtractDocumentSpec::new(DocumentFormat::Pdf)) .await .expect("typed extraction succeeds"); assert_eq!(extracted.sections[0].text, "section text"); @@ -472,12 +496,22 @@ async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure Err(DocumentCallError::Unavailable(_)) )); - let _sdk_outcome_vocabulary_is_closed = [ - ModuleCallError::Unavailable, - ModuleCallError::IncompatibleContract, - ModuleCallError::TransportFailed, - ModuleCallError::ModuleFault, - ]; + let fixture = fixture().await; + fixture + .state + .lock() + .expect("fixture state lock") + .malformed_replies + .insert( + methods::EXTRACT_DOCUMENT.to_string(), + json!({"truncated": "wrong type"}), + ); + let error = fixture + .client + .extract_document(b"PDF bytes", &ExtractDocumentSpec::new(DocumentFormat::Pdf)) + .await + .expect_err("a malformed typed reply is rejected"); + assert!(matches!(error, DocumentCallError::Failed(_))); } #[tokio::test] @@ -505,12 +539,33 @@ async fn held_outputs_are_chunked_in_order_verified_and_released() { .map(|call| call.args[1].as_u64().expect("read offset")) .collect::>(); assert_eq!(offsets, [0, CHUNK_BYTES, CHUNK_BYTES * 2]); - assert!(fixture - .state - .lock() - .expect("fixture state lock") - .outputs - .is_empty()); + assert!( + fixture + .state + .lock() + .expect("fixture state lock") + .outputs + .is_empty() + ); + + let fixture = fixture().await; + { + let mut state = fixture.state.lock().expect("fixture state lock"); + state.generated_bytes = b"digest must reject this output".to_vec(); + state.declared_digest = Some("0".repeat(64)); + } + assert!(matches!( + fixture.client.generate_docx(&document_spec()).await, + Err(DocumentCallError::Failed(_)) + )); + let state = fixture.state.lock().expect("fixture state lock"); + assert!( + state + .calls + .iter() + .any(|call| call.member == methods::RELEASE_OUTPUT) + ); + assert!(state.outputs.is_empty()); let fixture = fixture().await; fixture @@ -524,7 +579,11 @@ async fn held_outputs_are_chunked_in_order_verified_and_released() { )); let calls = &fixture.state.lock().expect("fixture state lock").calls; assert!(!calls.iter().any(|call| call.member == methods::READ_OUTPUT)); - assert!(calls.iter().any(|call| call.member == methods::RELEASE_OUTPUT)); + assert!( + calls + .iter() + .any(|call| call.member == methods::RELEASE_OUTPUT) + ); for mode in [ReadMode::Empty, ReadMode::InvalidBase64, ReadMode::Overlong] { let fixture = fixture().await; @@ -541,20 +600,26 @@ async fn held_outputs_are_chunked_in_order_verified_and_released() { fixture.client.generate_docx(&document_spec()).await, Err(DocumentCallError::Failed(_)) )); - assert!(fixture - .state - .lock() - .expect("fixture state lock") - .calls - .iter() - .any(|call| call.member == methods::RELEASE_OUTPUT)); + assert!( + fixture + .state + .lock() + .expect("fixture state lock") + .calls + .iter() + .any(|call| call.member == methods::RELEASE_OUTPUT) + ); } } #[tokio::test] async fn cancellation_releases_held_output_and_render_releases_every_page() { let fixture = fixture().await; - fixture.state.lock().expect("fixture state lock").block_reads = true; + fixture + .state + .lock() + .expect("fixture state lock") + .block_reads = true; let started = fixture .state .lock() @@ -573,12 +638,20 @@ async fn cancellation_releases_held_output_and_render_releases_every_page() { call.abort(); let _ = call.await; release_seen.notified().await; - assert!(fixture + fixture .state .lock() .expect("fixture state lock") - .outputs - .is_empty()); + .resume_read + .notify_one(); + assert!( + fixture + .state + .lock() + .expect("fixture state lock") + .outputs + .is_empty() + ); let fixture = fixture().await; let rendered = fixture @@ -594,11 +667,16 @@ async fn cancellation_releases_held_output_and_render_releases_every_page() { ) .await .expect("selected pages are collected"); - assert_eq!(rendered.iter().map(|(page, _)| *page).collect::>(), [1, 3]); - assert!(fixture - .state - .lock() - .expect("fixture state lock") - .outputs - .is_empty()); + assert_eq!( + rendered.iter().map(|(page, _)| *page).collect::>(), + [1, 3] + ); + assert!( + fixture + .state + .lock() + .expect("fixture state lock") + .outputs + .is_empty() + ); } From 29e1db78f06242bfab2f322f83d413669b4b5e8a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 01:36:57 +0530 Subject: [PATCH 06/50] test(tinydocs-client): cover text-only pptx generation without an upload stream Add assertions that generating a PPTX from a deck with no images succeeds and sends a null upload argument, confirming the text-only path avoids opening a stream. Auto-committed-on: macbook --- crates/tinydocs-client/tests/recording_broker.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index f1719c8..5024337 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -397,6 +397,14 @@ async fn typed_operations_use_contract_members_and_stream_document_bytes() { .expect("PPTX output is assembled"), b"pptx bytes" ); + assert_eq!( + fixture + .client + .generate_pptx(&deck, &[]) + .await + .expect("text-only PPTX avoids an upload stream"), + b"pptx bytes" + ); assert_eq!( fixture .client @@ -437,6 +445,9 @@ async fn typed_operations_use_contract_members_and_stream_document_bytes() { assert!(state.calls.iter().any(|call| { call.member == methods::GENERATE_DOCX && call.args[0]["title"] == "Broker proof" })); + assert!(state.calls.iter().any(|call| { + call.member == methods::GENERATE_PPTX && call.args[1].is_null() + })); } #[tokio::test] From 2db21888b72e34dfdf7def752dfbc4157a3b566d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 01:37:13 +0530 Subject: [PATCH 07/50] test(client): cover invalid input on read output Adds a recording-broker case asserting that an InvalidInput fault on read output surfaces as DocumentCallError::InvalidInput, still releases the output, and leaves no outputs behind. Auto-committed-on: macbook --- .../tinydocs-client/tests/recording_broker.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 5024337..c33804c 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -507,6 +507,27 @@ async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure Err(DocumentCallError::Unavailable(_)) )); + let fixture = fixture().await; + fixture + .state + .lock() + .expect("fixture state lock") + .faults + .insert( + methods::READ_OUTPUT.to_string(), + ( + "ai.tinyhumans.tinydocs.Error.InvalidInput".into(), + "output range is invalid".into(), + ), + ); + assert!(matches!( + fixture.client.generate_docx(&document_spec()).await, + Err(DocumentCallError::InvalidInput(_)) + )); + let state = fixture.state.lock().expect("fixture state lock"); + assert!(state.calls.iter().any(|call| call.member == methods::RELEASE_OUTPUT)); + assert!(state.outputs.is_empty()); + let fixture = fixture().await; fixture .state From e5d0a66a306fa7eef77390bf8391ae85fb1eb3ec Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 01:37:25 +0530 Subject: [PATCH 08/50] test(client): cover render failure releasing held output Extend the recording broker test to check that a render call failing on invalid base64 input still releases the output it held, and clone the resume notification handle so the fixture lock is not held across the notify. Auto-committed-on: macbook --- .../tinydocs-client/tests/recording_broker.rs | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index c33804c..a7dd8d9 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -670,12 +670,13 @@ async fn cancellation_releases_held_output_and_render_releases_every_page() { call.abort(); let _ = call.await; release_seen.notified().await; - fixture + let resume_read = fixture .state .lock() .expect("fixture state lock") .resume_read - .notify_one(); + .clone(); + resume_read.notify_one(); assert!( fixture .state @@ -711,4 +712,32 @@ async fn cancellation_releases_held_output_and_render_releases_every_page() { .outputs .is_empty() ); + + let fixture = fixture().await; + fixture.state.lock().expect("fixture state lock").read_mode = ReadMode::InvalidBase64; + assert!(matches!( + fixture + .client + .render_pdf( + b"PDF input", + &RenderPdfSpec { + pages: vec![1, 3], + max_dimension: 256, + max_total_pixels: 65_536, + max_output_bytes: 1_048_576, + }, + ) + .await, + Err(DocumentCallError::Failed(_)) + )); + let state = fixture.state.lock().expect("fixture state lock"); + assert!(state.outputs.is_empty()); + assert_eq!( + state + .calls + .iter() + .filter(|call| call.member == methods::RELEASE_OUTPUT) + .count(), + 2 + ); } From 43ff26b1c039ddd37f36a2357c1d61c5df14def5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 01:37:58 +0530 Subject: [PATCH 09/50] docs(tinydocs-client): document detailed streaming and object client calls The README now describes the SDK additions the client contract tests require: detailed streaming calls that keep module readiness, registry-address validation, timeout, and failure reporting, plus detailed calls on `ModuleObjectClient` so held-output wire errors retain their contract distinction. The recording broker tests were reformatted to match, with no change in what they assert. Auto-committed-on: macbook --- crates/tinydocs-client/README.md | 8 +++++--- crates/tinydocs-client/tests/recording_broker.rs | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/tinydocs-client/README.md b/crates/tinydocs-client/README.md index a4502dd..9ee8c45 100644 --- a/crates/tinydocs-client/README.md +++ b/crates/tinydocs-client/README.md @@ -8,9 +8,11 @@ deadlines; those choices belong to the embedding host. Ordinary typed calls use `ModuleClient::call_detailed`. The existing adapter also needs streamed input, which the published TinyBus SDK does not currently expose through `ModuleClient`. The client contract tests specify the required -SDK addition: a detailed streaming call that retains module readiness, -registry-address validation, the configured timeout, and failure reporting. -The host continues to own module loading and transport lifecycle. +SDK additions: detailed streaming calls that retain module readiness, +registry-address validation, the configured timeout, and failure reporting, +plus detailed calls on `ModuleObjectClient` so held-output wire errors retain +their contract distinction. The host continues to own module loading and +transport lifecycle. Variable-size results use the contract's held-output protocol. The client reads bounded base64 chunks, checks the declared length and SHA-256 digest, and diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index a7dd8d9..e370eff 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -445,9 +445,12 @@ async fn typed_operations_use_contract_members_and_stream_document_bytes() { assert!(state.calls.iter().any(|call| { call.member == methods::GENERATE_DOCX && call.args[0]["title"] == "Broker proof" })); - assert!(state.calls.iter().any(|call| { - call.member == methods::GENERATE_PPTX && call.args[1].is_null() - })); + assert!( + state + .calls + .iter() + .any(|call| { call.member == methods::GENERATE_PPTX && call.args[1].is_null() }) + ); } #[tokio::test] @@ -525,7 +528,12 @@ async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure Err(DocumentCallError::InvalidInput(_)) )); let state = fixture.state.lock().expect("fixture state lock"); - assert!(state.calls.iter().any(|call| call.member == methods::RELEASE_OUTPUT)); + assert!( + state + .calls + .iter() + .any(|call| call.member == methods::RELEASE_OUTPUT) + ); assert!(state.outputs.is_empty()); let fixture = fixture().await; From 8e6d2758d76ed8d54147a22837f2dfe1dae5f008 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 01:38:28 +0530 Subject: [PATCH 10/50] docs(tinydocs-client): clarify client ownership in module docs The crate-level documentation no longer claims the client takes the host's bus connection, matching the actual constructor signature. Test assertions were also tightened to hold the fixture state lock across both checks and to avoid borrow conflicts. Auto-committed-on: macbook --- crates/tinydocs-client/src/lib.rs | 4 ++-- .../tinydocs-client/tests/recording_broker.rs | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/crates/tinydocs-client/src/lib.rs b/crates/tinydocs-client/src/lib.rs index cc12819..1e40715 100644 --- a/crates/tinydocs-client/src/lib.rs +++ b/crates/tinydocs-client/src/lib.rs @@ -1,5 +1,5 @@ //! Typed host client for the `tinydocs` TinyBus module. //! //! Module loading, readiness, host policy, and transport ownership stay with -//! the embedding host. The client accepts that host's `ModuleClient` and bus -//! connection, then owns TinyDocs contract calls and held-output transfer. +//! the embedding host. The client accepts that host's `ModuleClient`, then +//! owns TinyDocs contract calls and held-output transfer. diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index e370eff..c4aea49 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -617,10 +617,16 @@ async fn held_outputs_are_chunked_in_order_verified_and_released() { fixture.client.generate_docx(&document_spec()).await, Err(DocumentCallError::Failed(_)) )); - let calls = &fixture.state.lock().expect("fixture state lock").calls; - assert!(!calls.iter().any(|call| call.member == methods::READ_OUTPUT)); + let state = fixture.state.lock().expect("fixture state lock"); assert!( - calls + !state + .calls + .iter() + .any(|call| call.member == methods::READ_OUTPUT) + ); + assert!( + state + .calls .iter() .any(|call| call.member == methods::RELEASE_OUTPUT) ); @@ -633,7 +639,8 @@ async fn held_outputs_are_chunked_in_order_verified_and_released() { if matches!(mode, ReadMode::Overlong) { state.generated_bytes = vec![0x33; CHUNK_BYTES as usize + 16]; state.declared_size = Some(CHUNK_BYTES); - state.declared_digest = Some(digest(&state.generated_bytes)); + let declared_digest = digest(&state.generated_bytes); + state.declared_digest = Some(declared_digest); } } assert!(matches!( @@ -710,7 +717,7 @@ async fn cancellation_releases_held_output_and_render_releases_every_page() { .expect("selected pages are collected"); assert_eq!( rendered.iter().map(|(page, _)| *page).collect::>(), - [1, 3] + vec![1, 3] ); assert!( fixture From 8b332bcdc23546dc930c5e8690828e3e05760e7d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 02:53:43 +0530 Subject: [PATCH 11/50] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to the latest upstream commit. Auto-committed-on: macbook --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index eb5cf72..b6cc32b 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit eb5cf727071a6eb12f9efa3db8ec59e54039f336 +Subproject commit b6cc32b309065d9f27cabe9c50eba137c423e2f6 From d7154cdb1a14f0de46c4c64245c8bf449c43ea8f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 02:54:33 +0530 Subject: [PATCH 12/50] chore(tinydocs-client): bump tinybus to 0.2.2 Update the tinybus dependency to 0.2.2 and adjust the recording broker test to assert the failure variant inline. The test no longer checks that the error message omits private generator detail. Auto-committed-on: macbook --- crates/tinydocs-client/Cargo.toml | 2 +- crates/tinydocs-client/tests/recording_broker.rs | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/tinydocs-client/Cargo.toml b/crates/tinydocs-client/Cargo.toml index 6e58c88..b39faa9 100644 --- a/crates/tinydocs-client/Cargo.toml +++ b/crates/tinydocs-client/Cargo.toml @@ -13,7 +13,7 @@ categories = ["api-bindings"] [dependencies] # Module loading and typed failure reporting stay host-owned; this is the # published host-facing SDK without its native loader. -tinybus = { version = "0.1.8", default-features = false } +tinybus = { version = "0.2.2", default-features = false } # Shared wire names and payload DTOs. tinydocs-bus = { version = "0.2.4", path = "../tinydocs-bus" } # The held-output protocol uses base64 chunks and verifies their SHA-256 digest. diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index c4aea49..1db4ae5 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -485,13 +485,14 @@ async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure "private generator detail".into(), ), ); - let error = fixture - .client - .generate_docx(&document_spec()) - .await - .expect_err("generation failure is returned"); - assert!(matches!(error, DocumentCallError::Failed(_))); - assert!(!error.to_string().contains("private generator detail")); + assert!(matches!( + fixture + .client + .generate_docx(&document_spec()) + .await + .expect_err("generation failure is returned"), + DocumentCallError::Failed(_) + )); fixture .state From 3fc02f28f9a234fb39beac0d29edb92154eedc2e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 02:55:05 +0530 Subject: [PATCH 13/50] test(tinydocs-client): cover streamed calls that fail before reading input The recording broker now drains the stream argument before returning a fault, so tests exercise the same path as a real broker that fails a streamed call. Added cases asserting that extract_document and inspect_image still map wire errors to InvalidInput and Failed, and the README now describes the streaming and held-output call paths the SDK exposes. Auto-committed-on: macbook --- crates/tinydocs-client/README.md | 14 +++-- .../tinydocs-client/tests/recording_broker.rs | 53 +++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/crates/tinydocs-client/README.md b/crates/tinydocs-client/README.md index 9ee8c45..e53f272 100644 --- a/crates/tinydocs-client/README.md +++ b/crates/tinydocs-client/README.md @@ -5,14 +5,12 @@ module. It translates contract methods and held outputs into typed Rust calls. It does not load modules, decide readiness, configure reporting, or choose deadlines; those choices belong to the embedding host. -Ordinary typed calls use `ModuleClient::call_detailed`. The existing adapter -also needs streamed input, which the published TinyBus SDK does not currently -expose through `ModuleClient`. The client contract tests specify the required -SDK additions: detailed streaming calls that retain module readiness, -registry-address validation, the configured timeout, and failure reporting, -plus detailed calls on `ModuleObjectClient` so held-output wire errors retain -their contract distinction. The host continues to own module loading and -transport lifecycle. +Ordinary typed calls use `ModuleClient::call_detailed`. Upload operations use +`ModuleClient::call_with_stream_detailed`, which keeps readiness, registry +validation, timeouts, and failure reporting inside the SDK. Held-output reads +use `ModuleObjectClient::call_detailed` so their wire errors retain the same +contract distinctions. The host continues to own module loading and transport +lifecycle. Variable-size results use the contract's held-output protocol. The client reads bounded base64 chunks, checks the declared length and SHA-256 digest, and diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 1db4ae5..7897ac1 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -174,6 +174,17 @@ impl Interface for RecordingService { state.faults.get(&member).cloned() }; if let Some((name, message)) = fault { + let stream_index = match member.as_str() { + methods::GENERATE_PPTX if !args[1].is_null() => Some(1), + methods::EXTRACT_TEXT | methods::INSPECT_IMAGE => Some(0), + methods::EXTRACT_DOCUMENT | methods::CONVERT_MARKDOWN | methods::RENDER_PDF => { + Some(1) + } + _ => None, + }; + if let Some(index) = stream_index { + self.read_stream_arg(&args, index, &member).await?; + } return Err(Error::MethodFailed { name, message }); } let malformed = self @@ -511,6 +522,48 @@ async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure Err(DocumentCallError::Unavailable(_)) )); + let fixture = fixture().await; + fixture + .state + .lock() + .expect("fixture state lock") + .faults + .insert( + methods::EXTRACT_DOCUMENT.to_string(), + ( + "ai.tinyhumans.tinydocs.Error.InvalidInput".into(), + "PDF header is invalid".into(), + ), + ); + assert!(matches!( + fixture + .client + .extract_document( + b"invalid PDF", + &ExtractDocumentSpec::new(DocumentFormat::Pdf) + ) + .await, + Err(DocumentCallError::InvalidInput(_)) + )); + + let fixture = fixture().await; + fixture + .state + .lock() + .expect("fixture state lock") + .faults + .insert( + methods::INSPECT_IMAGE.to_string(), + ( + "ai.tinyhumans.tinydocs.Error.ExtractionFailed".into(), + "image parser failed".into(), + ), + ); + assert!(matches!( + fixture.client.inspect_image(b"PNG bytes").await, + Err(DocumentCallError::Failed(_)) + )); + let fixture = fixture().await; fixture .state From 2f9a74ae0aeecf79cdcc87665082c57626957a0f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:01:20 +0530 Subject: [PATCH 14/50] chore(deps): bump tinybus to 0.2.2 Update the tinybus and tinybus-module dependencies to 0.2.2, which pulls in icu_casemap, rawzip, and unicode-normalization while dropping the zip 2.x and arbitrary crates. This keeps the module aligned with the current TinyBus host ABI. Auto-committed-on: macbook --- Cargo.lock | 232 ++++++++++++++++++++++++------ crates/tinydocs-module/Cargo.toml | 6 +- 2 files changed, 193 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index da16e0b..4b8ddad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,15 +49,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arrayvec" version = "0.7.8" @@ -419,17 +410,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "digest" version = "0.10.7" @@ -890,6 +870,95 @@ dependencies = [ "typenum", ] +[[package]] +name = "icu_casemap" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2edfd120d8445de853cf53a1abec916f638f933a70a6f4bf8f7e2d7b7f884e9" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059fcda0fc8d33effc6633a8cf0d8a3c17caf7d7c91918fba9918744f501e4e8" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "serde", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "image" version = "0.25.10" @@ -1002,6 +1071,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "litrs" version = "1.0.0" @@ -1327,6 +1402,17 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1436,6 +1522,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" +[[package]] +name = "rawzip" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9999039f74cd73c03158cddfe4362991703c74efe5ce5c8073026f80a5e48c" + [[package]] name = "read-fonts" version = "0.35.0" @@ -1842,7 +1934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1914,11 +2006,13 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.1.5" +version = "0.2.2" dependencies = [ "async-trait", "flate2", + "icu_casemap", "libc", + "rawzip", "serde", "serde_json", "sha2 0.10.9", @@ -1929,13 +2023,13 @@ dependencies = [ "tokio", "toml", "tracing", + "unicode-normalization", "ureq", - "zip 2.4.2", ] [[package]] name = "tinybus-macros" -version = "0.1.5" +version = "0.2.2" dependencies = [ "proc-macro2", "quote", @@ -1944,7 +2038,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.1.5" +version = "0.2.2" dependencies = [ "async-trait", "serde", @@ -1979,6 +2073,19 @@ dependencies = [ "thiserror", ] +[[package]] +name = "tinydocs-client" +version = "0.2.4" +dependencies = [ + "async-trait", + "base64 0.22.1", + "serde_json", + "sha2 0.10.9", + "tinybus", + "tinydocs-bus", + "tokio", +] + [[package]] name = "tinydocs-module" version = "0.2.4" @@ -1997,6 +2104,17 @@ dependencies = [ "zip 8.6.0", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.13.3" @@ -2190,6 +2308,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.26.1" @@ -2441,6 +2565,12 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "xattr" version = "1.6.1" @@ -2536,6 +2666,41 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "zip" version = "0.6.6" @@ -2556,23 +2721,6 @@ dependencies = [ "zstd", ] -[[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "arbitrary", - "crc32fast", - "crossbeam-utils", - "displaydoc", - "flate2", - "indexmap", - "memchr", - "thiserror", - "zopfli", -] - [[package]] name = "zip" version = "8.6.0" diff --git a/crates/tinydocs-module/Cargo.toml b/crates/tinydocs-module/Cargo.toml index 6daee69..d84e1cb 100644 --- a/crates/tinydocs-module/Cargo.toml +++ b/crates/tinydocs-module/Cargo.toml @@ -21,9 +21,9 @@ tinydocs = { path = "../..", default-features = false, features = ["docx", "pptx # The transport-free vocabulary shared with every TinyBus host. tinydocs-bus = { path = "../tinydocs-bus" } # TinyBus provides the typed service interface and dynamic module host ABI. -tinybus = { version = ">=0.1.3, <0.2", default-features = false, features = ["macros", "modules"] } +tinybus = { version = "0.2.2", default-features = false, features = ["macros", "modules"] } # The module-side SDK owns its runtime and exports the stable C entrypoints. -tinybus-module = ">=0.1.3, <0.2" +tinybus-module = "0.2.2" # Module methods move CPU-bound document synthesis onto Tokio's blocking pool. tokio = { version = "1", features = ["macros", "rt-multi-thread"] } # Bus frames are JSON, where a byte array costs ~3.5 bytes per byte. Chunks are @@ -54,4 +54,4 @@ base64 = "0.23" serde_json = "1" zip = { version = "8", default-features = false, features = ["deflate"] } # Enable TinyBus loader helpers only in test builds, not downstream production builds. -tinybus = { version = ">=0.1.3, <0.2", default-features = false, features = ["test-support"] } +tinybus = { version = "0.2.2", default-features = false, features = ["test-support"] } From 1eec5704491c5532608370970c81e5aecc6e73e6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:01:34 +0530 Subject: [PATCH 15/50] test(tinydocs-client): rename fixture helper to test_fixture Renamed the local fixture constructor to test_fixture and updated all call sites, and dropped the now-unused Reason import. This avoids the helper shadowing the fixture variable and keeps the test module free of unused imports. Auto-committed-on: macbook --- .../tinydocs-client/tests/recording_broker.rs | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 7897ac1..8b98f44 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -11,7 +11,7 @@ use sha2::{Digest, Sha256}; use tinybus::broker::Broker; use tinybus::module::{ AccessFuture, FailureReport, FailureReporter, FailureSink, ModuleAccess, ModuleClient, - ProxyFailure, Reason, ReleaseRecord, + ProxyFailure, ReleaseRecord, }; use tinybus::service::Interface; use tinybus::transport::memory::MemoryBus; @@ -321,7 +321,7 @@ struct Fixture { _broker: tokio::task::JoinHandle>, } -async fn fixture() -> Fixture { +async fn test_fixture() -> Fixture { let bus = MemoryBus::new(); let broker = Broker::new().spawn(bus.clone()); let server = Connection::connect(bus.connect().await.expect("server transport")) @@ -383,7 +383,7 @@ fn document_spec() -> DocumentSpec { #[tokio::test] async fn typed_operations_use_contract_members_and_stream_document_bytes() { - let fixture = fixture().await; + let fixture = test_fixture().await; let input = b"incoming PDF bytes"; let generated = fixture @@ -466,7 +466,7 @@ async fn typed_operations_use_contract_members_and_stream_document_bytes() { #[tokio::test] async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure() { - let fixture = fixture().await; + let fixture = test_fixture().await; fixture .state .lock() @@ -522,7 +522,7 @@ async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure Err(DocumentCallError::Unavailable(_)) )); - let fixture = fixture().await; + let fixture = test_fixture().await; fixture .state .lock() @@ -546,7 +546,7 @@ async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure Err(DocumentCallError::InvalidInput(_)) )); - let fixture = fixture().await; + let fixture = test_fixture().await; fixture .state .lock() @@ -564,7 +564,7 @@ async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure Err(DocumentCallError::Failed(_)) )); - let fixture = fixture().await; + let fixture = test_fixture().await; fixture .state .lock() @@ -590,7 +590,7 @@ async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure ); assert!(state.outputs.is_empty()); - let fixture = fixture().await; + let fixture = test_fixture().await; fixture .state .lock() @@ -610,7 +610,7 @@ async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure #[tokio::test] async fn held_outputs_are_chunked_in_order_verified_and_released() { - let fixture = fixture().await; + let fixture = test_fixture().await; let bytes = vec![0x5a; (CHUNK_BYTES * 2 + 13) as usize]; { let mut state = fixture.state.lock().expect("fixture state lock"); @@ -642,7 +642,7 @@ async fn held_outputs_are_chunked_in_order_verified_and_released() { .is_empty() ); - let fixture = fixture().await; + let fixture = test_fixture().await; { let mut state = fixture.state.lock().expect("fixture state lock"); state.generated_bytes = b"digest must reject this output".to_vec(); @@ -661,7 +661,7 @@ async fn held_outputs_are_chunked_in_order_verified_and_released() { ); assert!(state.outputs.is_empty()); - let fixture = fixture().await; + let fixture = test_fixture().await; fixture .state .lock() @@ -686,7 +686,7 @@ async fn held_outputs_are_chunked_in_order_verified_and_released() { ); for mode in [ReadMode::Empty, ReadMode::InvalidBase64, ReadMode::Overlong] { - let fixture = fixture().await; + let fixture = test_fixture().await; { let mut state = fixture.state.lock().expect("fixture state lock"); state.read_mode = mode; @@ -715,7 +715,7 @@ async fn held_outputs_are_chunked_in_order_verified_and_released() { #[tokio::test] async fn cancellation_releases_held_output_and_render_releases_every_page() { - let fixture = fixture().await; + let fixture = test_fixture().await; fixture .state .lock() @@ -755,7 +755,7 @@ async fn cancellation_releases_held_output_and_render_releases_every_page() { .is_empty() ); - let fixture = fixture().await; + let fixture = test_fixture().await; let rendered = fixture .client .render_pdf( From 2674417b885a34cfb62e046e543ee0ce7e302dc0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:01:41 +0530 Subject: [PATCH 16/50] test: rename fixture helper call in recording broker test Auto-committed-on: macbook --- crates/tinydocs-client/tests/recording_broker.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 8b98f44..ee0df9e 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -782,7 +782,7 @@ async fn cancellation_releases_held_output_and_render_releases_every_page() { .is_empty() ); - let fixture = fixture().await; + let fixture = test_fixture().await; fixture.state.lock().expect("fixture state lock").read_mode = ReadMode::InvalidBase64; assert!(matches!( fixture From 921bca01c8ac918e21dda8e6220b006c6991dc7d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:03:49 +0530 Subject: [PATCH 17/50] feat(tinydocs-client): add TinyDocsClient and DocumentCallError Introduce a client type that owns TinyDocs contract calls and held-output transfer, along with an error type for document call failures, and wire in tracing for diagnostics. Auto-committed-on: macbook --- Cargo.lock | 1 + crates/tinydocs-client/Cargo.toml | 1 + crates/tinydocs-client/src/client.rs | 394 +++++++++++++++++++++++++++ crates/tinydocs-client/src/error.rs | 42 +++ crates/tinydocs-client/src/lib.rs | 6 + 5 files changed, 444 insertions(+) create mode 100644 crates/tinydocs-client/src/client.rs create mode 100644 crates/tinydocs-client/src/error.rs diff --git a/Cargo.lock b/Cargo.lock index 4b8ddad..858de8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2084,6 +2084,7 @@ dependencies = [ "tinybus", "tinydocs-bus", "tokio", + "tracing", ] [[package]] diff --git a/crates/tinydocs-client/Cargo.toml b/crates/tinydocs-client/Cargo.toml index b39faa9..51cb991 100644 --- a/crates/tinydocs-client/Cargo.toml +++ b/crates/tinydocs-client/Cargo.toml @@ -22,6 +22,7 @@ sha2 = "0.10" serde_json = "1" # Cancellation-safe output release schedules cleanup on the caller's runtime. tokio = { version = "1", features = ["rt", "time"] } +tracing = "0.1" [dev-dependencies] async-trait = "0.1" diff --git a/crates/tinydocs-client/src/client.rs b/crates/tinydocs-client/src/client.rs new file mode 100644 index 0000000..39ac39a --- /dev/null +++ b/crates/tinydocs-client/src/client.rs @@ -0,0 +1,394 @@ +//! Typed calls and held-output transfer for the TinyDocs module. + +use std::time::Duration; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use sha2::{Digest, Sha256}; +use tinybus::module::{ + ModuleCallError, ModuleCallFailure, ModuleClient, ModuleObjectClient, +}; +use tinydocs_bus::names::{BUS_NAME, OBJECT_PATH, methods}; +use tinydocs_bus::spec::presentation::MAX_IMAGE_BYTES; +use tinydocs_bus::{ + DocumentFormat, DocumentSpec, ExtractDocumentSpec, ExtractedDocument, ImageFacts, OutputRef, + RenderPdfSpec, RenderedPdf, WirePresentationSpec, +}; + +use crate::DocumentCallError; + +const MODULE_ID: &str = "tinydocs"; +const READ_CHUNK_BYTES: u64 = 1024 * 1024; +const MAX_DOCUMENT_BYTES: u64 = 64 * 1024 * 1024; +const RELEASE_TIMEOUT: Duration = Duration::from_secs(5); +const INVALID_INPUT_WIRE_NAME: &str = "ai.tinyhumans.tinydocs.Error.InvalidInput"; + +/// Typed client for the TinyDocs module. +/// +/// The embedding host supplies its process-owned [`ModuleClient`], which keeps +/// module loading, readiness, timeouts, and failure reporting outside this +/// crate. +#[derive(Clone)] +pub struct TinyDocsClient { + module: ModuleClient, +} + +impl TinyDocsClient { + /// Create a TinyDocs client over the host's module client. + #[must_use] + pub fn new(module: ModuleClient) -> Self { + Self { module } + } + + /// Generate and collect a `.docx` document. + /// + /// # Errors + /// + /// Returns [`DocumentCallError`] when TinyDocs is unavailable, rejects the + /// specification, or fails to generate or transfer the output. + pub async fn generate_docx( + &self, + spec: &DocumentSpec, + ) -> Result, DocumentCallError> { + let output = self.output_client().await?; + let handle: OutputRef = self + .module + .call_detailed(MODULE_ID, methods::GENERATE_DOCX, (spec,)) + .await + .map_err(classify_failure)?; + collect(&output, handle).await + } + + /// Generate and collect a `.pptx` document. + /// + /// `images` contains slide-image bytes concatenated in slide order. Each + /// slide's byte length in `deck` defines its range in that buffer. An empty + /// image buffer uses the text-only call without opening a stream. + /// + /// # Errors + /// + /// Returns [`DocumentCallError`] when TinyDocs is unavailable, rejects the + /// deck, or fails to generate or transfer the output. + pub async fn generate_pptx( + &self, + deck: &WirePresentationSpec, + images: &[u8], + ) -> Result, DocumentCallError> { + let output = self.output_client().await?; + let handle: OutputRef = if images.is_empty() { + self.module + .call_detailed( + MODULE_ID, + methods::GENERATE_PPTX, + (deck, Option::::None), + ) + .await + .map_err(classify_failure)? + } else { + self.module + .call_with_stream_detailed( + MODULE_ID, + methods::GENERATE_PPTX, + |stream| serde_json::json!([deck, stream]), + images, + ) + .await + .map_err(classify_failure)? + }; + collect(&output, handle).await + } + + /// Extract the text layer from a streamed PDF. + /// + /// Scanned documents with no text layer produce an empty string. + /// + /// # Errors + /// + /// Returns [`DocumentCallError`] when extraction, output transfer, or UTF-8 + /// decoding fails. + pub async fn extract_text(&self, document: &[u8]) -> Result { + let output = self.output_client().await?; + let handle: OutputRef = self + .module + .call_with_stream_detailed( + MODULE_ID, + methods::EXTRACT_TEXT, + |stream| serde_json::json!([stream]), + document, + ) + .await + .map_err(classify_failure)?; + let bytes = collect(&output, handle).await?; + String::from_utf8(bytes).map_err(|_| { + DocumentCallError::Failed("extracted text was not valid UTF-8".to_string()) + }) + } + + /// Extract bounded text and provenance from a streamed document. + /// + /// # Errors + /// + /// Returns [`DocumentCallError`] when extraction or the typed reply fails. + pub async fn extract_document( + &self, + document: &[u8], + spec: &ExtractDocumentSpec, + ) -> Result { + self.module + .call_with_stream_detailed( + MODULE_ID, + methods::EXTRACT_DOCUMENT, + |stream| serde_json::json!([spec, stream]), + document, + ) + .await + .map_err(classify_failure) + } + + /// Convert a streamed PDF or Office document into normalized Markdown. + /// + /// # Errors + /// + /// Returns [`DocumentCallError`] when conversion, output transfer, or UTF-8 + /// decoding fails. + pub async fn convert_markdown( + &self, + format: DocumentFormat, + document: &[u8], + ) -> Result { + let output = self.output_client().await?; + let handle: OutputRef = self + .module + .call_with_stream_detailed( + MODULE_ID, + methods::CONVERT_MARKDOWN, + |stream| serde_json::json!([format, stream]), + document, + ) + .await + .map_err(classify_failure)?; + let bytes = collect(&output, handle).await?; + String::from_utf8(bytes).map_err(|_| { + DocumentCallError::Failed("converted Markdown was not valid UTF-8".to_string()) + }) + } + + /// Inspect a bounded PNG or JPEG image and return its verified facts. + /// + /// # Errors + /// + /// Returns [`DocumentCallError::InvalidInput`] for empty or oversized image + /// data, and another [`DocumentCallError`] when TinyDocs cannot inspect it. + pub async fn inspect_image(&self, image: &[u8]) -> Result { + if image.is_empty() || image.len() > MAX_IMAGE_BYTES { + return Err(DocumentCallError::InvalidInput(format!( + "image must contain 1 to {MAX_IMAGE_BYTES} bytes" + ))); + } + self.module + .call_with_stream_detailed( + MODULE_ID, + methods::INSPECT_IMAGE, + |stream| serde_json::json!([stream]), + image, + ) + .await + .map_err(classify_failure) + } + + /// Render selected pages of a PDF and collect every returned PNG. + /// + /// The result preserves the page order declared by TinyDocs. + /// + /// # Errors + /// + /// Returns [`DocumentCallError`] when rendering or any page transfer fails. + pub async fn render_pdf( + &self, + document: &[u8], + spec: &RenderPdfSpec, + ) -> Result)>, DocumentCallError> { + let output = self.output_client().await?; + let rendered: RenderedPdf = self + .module + .call_with_stream_detailed( + MODULE_ID, + methods::RENDER_PDF, + |stream| serde_json::json!([spec, stream]), + document, + ) + .await + .map_err(classify_failure)?; + + let mut pending = OutputReleaseGuard { + output: output.clone(), + ids: rendered + .pages + .iter() + .map(|page| page.output.output_id.clone()) + .collect(), + }; + let mut pages = Vec::new(); + let mut first_error = None; + for page in rendered.pages { + let output_id = page.output.output_id.clone(); + // Transfer cleanup responsibility to `collect` before its first + // await. If this future is cancelled, that guard releases this + // output and `pending` releases all pages not yet visited. + pending.ids.retain(|pending_id| pending_id != &output_id); + match collect(&output, page.output).await { + Ok(bytes) if first_error.is_none() => pages.push((page.page, bytes)), + Ok(_) => {} + Err(error) => { + first_error.get_or_insert(error); + } + } + } + if let Some(error) = first_error { + Err(error) + } else { + Ok(pages) + } + } + + async fn output_client(&self) -> Result { + self.module + .object_client(MODULE_ID, OBJECT_PATH) + .await + .map_err(classify_sdk_error) + } +} + +/// Releases retained module outputs when the collection future is cancelled. +struct OutputReleaseGuard { + output: ModuleObjectClient, + ids: Vec, +} + +impl Drop for OutputReleaseGuard { + fn drop(&mut self) { + if self.ids.is_empty() { + return; + } + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + return; + }; + let output = self.output.clone(); + let ids = std::mem::take(&mut self.ids); + runtime.spawn(async move { + for output_id in ids { + match tokio::time::timeout( + RELEASE_TIMEOUT, + output.call_detailed::<()>(methods::RELEASE_OUTPUT, (output_id,)), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(_)) | Err(_) => { + tracing::debug!(module = MODULE_ID, "releasing a TinyDocs output failed") + } + } + } + }); + } +} + +async fn collect( + output: &ModuleObjectClient, + handle: OutputRef, +) -> Result, DocumentCallError> { + let mut release = OutputReleaseGuard { + output: output.clone(), + ids: vec![handle.output_id.clone()], + }; + let result = read_all(output, &handle).await; + let release_result = tokio::time::timeout( + RELEASE_TIMEOUT, + output.call_detailed::<()>(methods::RELEASE_OUTPUT, (handle.output_id.clone(),)), + ) + .await; + if !matches!(release_result, Ok(Ok(()))) { + tracing::debug!(module = MODULE_ID, "releasing a TinyDocs output failed") + } + release.ids.clear(); + result +} + +async fn read_all( + output: &ModuleObjectClient, + handle: &OutputRef, +) -> Result, DocumentCallError> { + if handle.total_bytes > MAX_DOCUMENT_BYTES { + return Err(DocumentCallError::Failed(format!( + "the module declared a {}-byte document, over the {MAX_DOCUMENT_BYTES}-byte limit", + handle.total_bytes + ))); + } + + let capacity = usize::try_from(handle.total_bytes).unwrap_or(0); + let mut bytes = Vec::with_capacity(capacity.min(MAX_DOCUMENT_BYTES as usize)); + while (bytes.len() as u64) < handle.total_bytes { + let encoded: String = output + .call_detailed( + methods::READ_OUTPUT, + (handle.output_id.clone(), bytes.len() as u64, READ_CHUNK_BYTES), + ) + .await + .map_err(classify_failure)?; + let chunk = BASE64.decode(encoded).map_err(|_| { + DocumentCallError::Failed("a TinyDocs output chunk was not base64".to_string()) + })?; + if chunk.is_empty() { + return Err(DocumentCallError::Failed( + "the TinyDocs output ended before its declared length".to_string(), + )); + } + if bytes.len().saturating_add(chunk.len()) as u64 > handle.total_bytes { + return Err(DocumentCallError::Failed( + "the TinyDocs output exceeded its declared length".to_string(), + )); + } + bytes.extend_from_slice(&chunk); + } + + if sha256_hex(&bytes) != handle.sha256 { + return Err(DocumentCallError::Failed( + "the assembled TinyDocs output did not match its declared digest".to_string(), + )); + } + Ok(bytes) +} + +fn classify_failure(failure: ModuleCallFailure) -> DocumentCallError { + if failure.wire_name == INVALID_INPUT_WIRE_NAME { + DocumentCallError::InvalidInput(failure.message) + } else if failure.kind == ModuleCallError::Unavailable + || failure.wire_name.contains("ModuleUnavailable") + { + DocumentCallError::Unavailable(failure.to_string()) + } else { + DocumentCallError::Failed(failure.to_string()) + } +} + +fn classify_sdk_error(error: ModuleCallError) -> DocumentCallError { + if error == ModuleCallError::Unavailable { + DocumentCallError::Unavailable(error.to_string()) + } else { + DocumentCallError::Failed(error.to_string()) + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut output = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(output, "{byte:02x}"); + } + output +} + +#[cfg(test)] +#[path = "client_tests.rs"] +mod tests; diff --git a/crates/tinydocs-client/src/error.rs b/crates/tinydocs-client/src/error.rs new file mode 100644 index 0000000..a39a8e9 --- /dev/null +++ b/crates/tinydocs-client/src/error.rs @@ -0,0 +1,42 @@ +//! TinyDocs call errors kept distinct for the host's tool and reporting layers. + +use std::fmt::{Debug, Display, Formatter}; + +/// Why a TinyDocs operation did not produce its requested result. +/// +/// Remote text is retained in the value for an intentional caller-facing error +/// path. Its [`Debug`] form redacts the text so diagnostics do not accidentally +/// include module-provided data. +#[derive(Clone, PartialEq, Eq)] +pub enum DocumentCallError { + /// The module is unavailable or cannot serve this contract. + Unavailable(String), + /// TinyDocs rejected caller-provided input. + InvalidInput(String), + /// The operation or its output transfer failed. + Failed(String), +} + +impl Debug for DocumentCallError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let (kind, message) = match self { + Self::Unavailable(_) => ("Unavailable", ""), + Self::InvalidInput(_) => ("InvalidInput", ""), + Self::Failed(_) => ("Failed", ""), + }; + formatter.debug_tuple(kind).field(&message).finish() + } +} + +impl Display for DocumentCallError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::Unavailable(message) | Self::InvalidInput(message) | Self::Failed(message) => { + message + } + }; + formatter.write_str(message) + } +} + +impl std::error::Error for DocumentCallError {} diff --git a/crates/tinydocs-client/src/lib.rs b/crates/tinydocs-client/src/lib.rs index 1e40715..a7e0a75 100644 --- a/crates/tinydocs-client/src/lib.rs +++ b/crates/tinydocs-client/src/lib.rs @@ -3,3 +3,9 @@ //! Module loading, readiness, host policy, and transport ownership stay with //! the embedding host. The client accepts that host's `ModuleClient`, then //! owns TinyDocs contract calls and held-output transfer. + +mod client; +mod error; + +pub use client::TinyDocsClient; +pub use error::DocumentCallError; From c2cc4dfb24a644d94d4b23a2f7682780d5af44ba Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:05:14 +0530 Subject: [PATCH 18/50] test(client): cover disabled modules, invalid text output, and release failures Adds integration tests for the client's error paths: a disabled module reports Unavailable without issuing any call, local image bounds reject empty and oversized input before dispatch, non-UTF-8 text outputs fail while still releasing the held output, and a release failure does not discard already-collected bytes. The recording broker gains an enabled flag and per-member output overrides to support these cases, and the client now implements Debug. Auto-committed-on: macbook --- crates/tinydocs-client/src/client.rs | 26 ++-- crates/tinydocs-client/src/client_tests.rs | 73 +++++++++++ crates/tinydocs-client/src/error.rs | 4 + crates/tinydocs-client/src/error_tests.rs | 23 ++++ crates/tinydocs-client/src/lib.rs | 12 ++ .../tinydocs-client/tests/recording_broker.rs | 117 +++++++++++++++++- 6 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 crates/tinydocs-client/src/client_tests.rs create mode 100644 crates/tinydocs-client/src/error_tests.rs diff --git a/crates/tinydocs-client/src/client.rs b/crates/tinydocs-client/src/client.rs index 39ac39a..6b2e326 100644 --- a/crates/tinydocs-client/src/client.rs +++ b/crates/tinydocs-client/src/client.rs @@ -1,14 +1,13 @@ //! Typed calls and held-output transfer for the TinyDocs module. +use std::fmt::{Debug, Formatter}; use std::time::Duration; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use sha2::{Digest, Sha256}; -use tinybus::module::{ - ModuleCallError, ModuleCallFailure, ModuleClient, ModuleObjectClient, -}; -use tinydocs_bus::names::{BUS_NAME, OBJECT_PATH, methods}; +use tinybus::module::{ModuleCallError, ModuleCallFailure, ModuleClient, ModuleObjectClient}; +use tinydocs_bus::names::{OBJECT_PATH, methods}; use tinydocs_bus::spec::presentation::MAX_IMAGE_BYTES; use tinydocs_bus::{ DocumentFormat, DocumentSpec, ExtractDocumentSpec, ExtractedDocument, ImageFacts, OutputRef, @@ -33,6 +32,14 @@ pub struct TinyDocsClient { module: ModuleClient, } +impl Debug for TinyDocsClient { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("TinyDocsClient") + .finish_non_exhaustive() + } +} + impl TinyDocsClient { /// Create a TinyDocs client over the host's module client. #[must_use] @@ -46,10 +53,7 @@ impl TinyDocsClient { /// /// Returns [`DocumentCallError`] when TinyDocs is unavailable, rejects the /// specification, or fails to generate or transfer the output. - pub async fn generate_docx( - &self, - spec: &DocumentSpec, - ) -> Result, DocumentCallError> { + pub async fn generate_docx(&self, spec: &DocumentSpec) -> Result, DocumentCallError> { let output = self.output_client().await?; let handle: OutputRef = self .module @@ -331,7 +335,11 @@ async fn read_all( let encoded: String = output .call_detailed( methods::READ_OUTPUT, - (handle.output_id.clone(), bytes.len() as u64, READ_CHUNK_BYTES), + ( + handle.output_id.clone(), + bytes.len() as u64, + READ_CHUNK_BYTES, + ), ) .await .map_err(classify_failure)?; diff --git a/crates/tinydocs-client/src/client_tests.rs b/crates/tinydocs-client/src/client_tests.rs new file mode 100644 index 0000000..c6a44ff --- /dev/null +++ b/crates/tinydocs-client/src/client_tests.rs @@ -0,0 +1,73 @@ +//! Focused classification and digest tests for the TinyDocs client. + +use super::*; + +fn failure(kind: ModuleCallError, wire_name: &str, message: &str) -> ModuleCallFailure { + ModuleCallFailure { + kind, + wire_name: wire_name.to_string(), + message: message.to_string(), + } +} + +#[test] +fn only_the_tinydocs_invalid_input_wire_name_is_model_correctable() { + let invalid = classify_failure(failure( + ModuleCallError::ModuleFault, + INVALID_INPUT_WIRE_NAME, + "invalid section heading", + )); + assert_eq!( + invalid, + DocumentCallError::InvalidInput("invalid section heading".to_string()) + ); + + for wire_name in [ + "ai.tinyhumans.tinydocs.Error.GenerationFailed", + "ai.tinyhumans.tinydocs.Error.ExtractionFailed", + "ai.tinyhumans.tinydocs.Error.TransferFailed", + "ai.tinyhumans.tinydocs.Error.UnknownOutput", + "ai.tinyhumans.tinydocs.Error.SomethingNew", + ] { + assert!(matches!( + classify_failure(failure( + ModuleCallError::ModuleFault, + wire_name, + "untrusted remote text", + )), + DocumentCallError::Failed(_) + )); + } +} + +#[test] +fn unavailable_sdk_outcomes_stay_distinct_from_module_failures() { + assert!(matches!( + classify_failure(failure( + ModuleCallError::Unavailable, + "ai.tinyhumans.tinybus.Error.ModuleUnavailable", + "module is unavailable", + )), + DocumentCallError::Unavailable(_) + )); + assert!(matches!( + classify_sdk_error(ModuleCallError::Unavailable), + DocumentCallError::Unavailable(_) + )); + assert!(matches!( + classify_sdk_error(ModuleCallError::IncompatibleContract), + DocumentCallError::Failed(_) + )); + assert!(matches!( + classify_sdk_error(ModuleCallError::TransportFailed), + DocumentCallError::Failed(_) + )); +} + +#[test] +fn the_digest_matches_the_empty_document_vector() { + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); +} diff --git a/crates/tinydocs-client/src/error.rs b/crates/tinydocs-client/src/error.rs index a39a8e9..916274f 100644 --- a/crates/tinydocs-client/src/error.rs +++ b/crates/tinydocs-client/src/error.rs @@ -40,3 +40,7 @@ impl Display for DocumentCallError { } impl std::error::Error for DocumentCallError {} + +#[cfg(test)] +#[path = "error_tests.rs"] +mod tests; diff --git a/crates/tinydocs-client/src/error_tests.rs b/crates/tinydocs-client/src/error_tests.rs new file mode 100644 index 0000000..5e66b0d --- /dev/null +++ b/crates/tinydocs-client/src/error_tests.rs @@ -0,0 +1,23 @@ +//! Formatting behavior for TinyDocs errors, including message redaction. + +use super::*; + +#[test] +fn debug_redacts_every_module_message_but_display_keeps_the_intended_input_reason() { + let errors = [ + DocumentCallError::Unavailable("module secret".to_string()), + DocumentCallError::InvalidInput("field must be present".to_string()), + DocumentCallError::Failed("remote detail".to_string()), + ]; + for error in errors { + let debug = format!("{error:?}"); + assert!(debug.contains("")); + assert!(!debug.contains("secret")); + assert!(!debug.contains("present")); + assert!(!debug.contains("remote detail")); + } + assert_eq!( + DocumentCallError::InvalidInput("field must be present".to_string()).to_string(), + "field must be present" + ); +} diff --git a/crates/tinydocs-client/src/lib.rs b/crates/tinydocs-client/src/lib.rs index a7e0a75..7e2014d 100644 --- a/crates/tinydocs-client/src/lib.rs +++ b/crates/tinydocs-client/src/lib.rs @@ -3,6 +3,18 @@ //! Module loading, readiness, host policy, and transport ownership stay with //! the embedding host. The client accepts that host's `ModuleClient`, then //! owns TinyDocs contract calls and held-output transfer. +//! +//! ```no_run +//! use tinydocs_bus::DocumentSpec; +//! use tinydocs_client::{DocumentCallError, TinyDocsClient}; +//! +//! async fn create_docx( +//! client: &TinyDocsClient, +//! spec: &DocumentSpec, +//! ) -> Result, DocumentCallError> { +//! client.generate_docx(spec).await +//! } +//! ``` mod client; mod error; diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index ee0df9e..2fb20d5 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -44,6 +44,7 @@ impl FailureSink for NoopSink { struct FixtureAccess { connection: Connection, reporter: FailureReporter, + enabled: bool, } impl ModuleAccess for FixtureAccess { @@ -52,7 +53,7 @@ impl ModuleAccess for FixtureAccess { } fn enabled(&self) -> bool { - true + self.enabled } fn reporter(&self) -> &FailureReporter { @@ -87,6 +88,7 @@ struct State { calls: Vec, input_streams: BTreeMap>, outputs: HashMap>, + output_overrides: HashMap>, generated_bytes: Vec, next_output: usize, faults: HashMap, @@ -133,6 +135,17 @@ impl RecordingService { } } + fn hold_for(&self, member: &str, fallback: &[u8]) -> OutputRef { + let bytes = self + .state + .lock() + .expect("fixture state lock") + .output_overrides + .remove(member) + .unwrap_or_else(|| fallback.to_vec()); + self.hold(bytes) + } + async fn read_stream_arg( &self, args: &Value, @@ -219,7 +232,9 @@ impl Interface for RecordingService { } methods::EXTRACT_TEXT => { self.read_stream_arg(&args, 0, &member).await?; - Ok(serde_json::to_value(self.hold(b"extracted text".to_vec()))?) + Ok(serde_json::to_value( + self.hold_for(&member, b"extracted text"), + )?) } methods::EXTRACT_DOCUMENT => { self.read_stream_arg(&args, 1, &member).await?; @@ -237,7 +252,9 @@ impl Interface for RecordingService { } methods::CONVERT_MARKDOWN => { self.read_stream_arg(&args, 1, &member).await?; - Ok(serde_json::to_value(self.hold(b"# converted\n".to_vec()))?) + Ok(serde_json::to_value( + self.hold_for(&member, b"# converted\n"), + )?) } methods::INSPECT_IMAGE => { self.read_stream_arg(&args, 0, &member).await?; @@ -322,6 +339,10 @@ struct Fixture { } async fn test_fixture() -> Fixture { + test_fixture_with_enabled(true).await +} + +async fn test_fixture_with_enabled(enabled: bool) -> Fixture { let bus = MemoryBus::new(); let broker = Broker::new().spawn(bus.clone()); let server = Connection::connect(bus.connect().await.expect("server transport")) @@ -354,6 +375,7 @@ async fn test_fixture() -> Fixture { let sdk_client = ModuleClient::new(FixtureAccess { connection: client_connection.clone(), reporter: FailureReporter::new(Arc::new(NoopSink)), + enabled, }) .with_fixture(proxy); @@ -464,6 +486,95 @@ async fn typed_operations_use_contract_members_and_stream_document_bytes() { ); } +#[tokio::test] +async fn disabled_modules_are_unavailable_and_local_image_bounds_send_no_call() { + let fixture = test_fixture_with_enabled(false).await; + assert!(matches!( + fixture.client.generate_docx(&document_spec()).await, + Err(DocumentCallError::Unavailable(_)) + )); + assert!( + fixture + .state + .lock() + .expect("fixture state lock") + .calls + .is_empty() + ); + + let fixture = test_fixture().await; + assert!(matches!( + fixture.client.inspect_image(&[]).await, + Err(DocumentCallError::InvalidInput(_)) + )); + let oversized = vec![0; tinydocs_bus::spec::presentation::MAX_IMAGE_BYTES + 1]; + assert!(matches!( + fixture.client.inspect_image(&oversized).await, + Err(DocumentCallError::InvalidInput(_)) + )); + assert!( + fixture + .state + .lock() + .expect("fixture state lock") + .calls + .is_empty() + ); +} + +#[tokio::test] +async fn text_outputs_must_be_utf8_and_release_errors_do_not_discard_good_bytes() { + for member in [methods::EXTRACT_TEXT, methods::CONVERT_MARKDOWN] { + let fixture = test_fixture().await; + fixture + .state + .lock() + .expect("fixture state lock") + .output_overrides + .insert(member.to_string(), vec![0xff]); + let result = if member == methods::EXTRACT_TEXT { + fixture.client.extract_text(b"PDF bytes").await.map(|_| ()) + } else { + fixture + .client + .convert_markdown(DocumentFormat::Pdf, b"PDF bytes") + .await + .map(|_| ()) + }; + assert!(matches!(result, Err(DocumentCallError::Failed(_)))); + let state = fixture.state.lock().expect("fixture state lock"); + assert!(state.outputs.is_empty()); + assert!( + state + .calls + .iter() + .any(|call| call.member == methods::RELEASE_OUTPUT) + ); + } + + let fixture = test_fixture().await; + fixture + .state + .lock() + .expect("fixture state lock") + .faults + .insert( + methods::RELEASE_OUTPUT.to_string(), + ( + "ai.tinyhumans.tinydocs.Error.UnknownOutput".into(), + "already expired".into(), + ), + ); + assert_eq!( + fixture + .client + .generate_docx(&document_spec()) + .await + .expect("a release failure does not invalidate collected bytes"), + b"docx bytes" + ); +} + #[tokio::test] async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure() { let fixture = test_fixture().await; From 86422592521014ffe4a8af36452359bf76ae1510 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:20:45 +0530 Subject: [PATCH 19/50] test(client): cover release deadline and error redaction Extend the recording broker fixture with a configurable release delay and client timeout so tests can verify that ordinary output release honors the module client's configured deadline. Add tests confirming that credential-like remote text in invalid-input errors is redacted from both display and debug formatting. Auto-committed-on: macbook --- crates/tinydocs-client/Cargo.toml | 2 +- .../tinydocs-client/tests/recording_broker.rs | 128 +++++++++++++++++- 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/crates/tinydocs-client/Cargo.toml b/crates/tinydocs-client/Cargo.toml index 51cb991..a1169f9 100644 --- a/crates/tinydocs-client/Cargo.toml +++ b/crates/tinydocs-client/Cargo.toml @@ -26,7 +26,7 @@ tracing = "0.1" [dev-dependencies] async-trait = "0.1" -tokio = { version = "1", features = ["macros", "rt", "sync", "time"] } +tokio = { version = "1", features = ["macros", "rt", "sync", "time", "test-util"] } [lints] workspace = true diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 2fb20d5..170c705 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use async_trait::async_trait; use base64::Engine as _; @@ -100,6 +101,8 @@ struct State { read_started: Arc, release_seen: Arc, resume_read: Arc, + release_started: Arc, + release_delay: Option, } #[derive(Clone, Copy, Default)] @@ -275,6 +278,14 @@ impl Interface for RecordingService { methods::READ_OUTPUT => self.read_output(&args).await, methods::RELEASE_OUTPUT => { let output_id = args[0].as_str().expect("output id string"); + let (release_delay, release_started) = { + let state = self.state.lock().expect("fixture state lock"); + (state.release_delay, state.release_started.clone()) + }; + if let Some(delay) = release_delay { + release_started.notify_one(); + tokio::time::sleep(delay).await; + } self.state .lock() .expect("fixture state lock") @@ -339,10 +350,18 @@ struct Fixture { } async fn test_fixture() -> Fixture { - test_fixture_with_enabled(true).await + test_fixture_with_options(true, None).await } async fn test_fixture_with_enabled(enabled: bool) -> Fixture { + test_fixture_with_options(enabled, None).await +} + +async fn test_fixture_with_timeout(timeout: Duration) -> Fixture { + test_fixture_with_options(true, Some(timeout)).await +} + +async fn test_fixture_with_options(enabled: bool, timeout: Option) -> Fixture { let bus = MemoryBus::new(); let broker = Broker::new().spawn(bus.clone()); let server = Connection::connect(bus.connect().await.expect("server transport")) @@ -372,12 +391,15 @@ async fn test_fixture_with_enabled(enabled: bool) -> Fixture { let proxy = client_connection .proxy(BUS_NAME, OBJECT_PATH, BUS_NAME) .expect("fixture proxy"); - let sdk_client = ModuleClient::new(FixtureAccess { + let mut sdk_client = ModuleClient::new(FixtureAccess { connection: client_connection.clone(), reporter: FailureReporter::new(Arc::new(NoopSink)), enabled, }) .with_fixture(proxy); + if let Some(timeout) = timeout { + sdk_client = sdk_client.with_timeout(timeout); + } Fixture { client: TinyDocsClient::new(sdk_client), @@ -575,6 +597,108 @@ async fn text_outputs_must_be_utf8_and_release_errors_do_not_discard_good_bytes( ); } +#[tokio::test(start_paused = true)] +async fn output_release_waits_for_the_module_clients_configured_deadline() { + let fixture = test_fixture_with_timeout(Duration::from_secs(10)).await; + let release_started = fixture + .state + .lock() + .expect("fixture state lock") + .release_started + .clone(); + fixture + .state + .lock() + .expect("fixture state lock") + .release_delay = Some(Duration::from_secs(6)); + + let client = fixture.client.clone(); + let call = tokio::spawn(async move { client.generate_docx(&document_spec()).await }); + release_started.notified().await; + tokio::time::advance(Duration::from_millis(5_100)).await; + tokio::task::yield_now().await; + assert!( + !call.is_finished(), + "ordinary output release must honor the configured ten-second module deadline" + ); + + tokio::time::advance(Duration::from_secs(1)).await; + assert_eq!( + call.await + .expect("client task joins") + .expect("output is collected"), + b"docx bytes" + ); + assert!( + fixture + .state + .lock() + .expect("fixture state lock") + .outputs + .is_empty() + ); +} + +#[tokio::test] +async fn credential_like_normal_invalid_input_is_redacted_from_formatted_errors() { + let fixture = test_fixture().await; + let remote_text = "credential-like-token-DoNotDisplay"; + fixture + .state + .lock() + .expect("fixture state lock") + .faults + .insert( + methods::GENERATE_DOCX.to_string(), + ( + "ai.tinyhumans.tinydocs.Error.InvalidInput".into(), + remote_text.into(), + ), + ); + let error = fixture + .client + .generate_docx(&document_spec()) + .await + .expect_err("invalid input is returned"); + assert!(matches!(error, DocumentCallError::InvalidInput(_))); + assert!(!error.to_string().contains(remote_text)); + assert!(!format!("{error:?}").contains(remote_text)); +} + +#[tokio::test] +async fn credential_like_held_output_error_is_redacted_from_formatted_errors() { + let fixture = test_fixture().await; + let remote_text = "credential-like-output-token-DoNotDisplay"; + fixture + .state + .lock() + .expect("fixture state lock") + .faults + .insert( + methods::READ_OUTPUT.to_string(), + ( + "ai.tinyhumans.tinydocs.Error.InvalidInput".into(), + remote_text.into(), + ), + ); + let error = fixture + .client + .generate_docx(&document_spec()) + .await + .expect_err("held output error is returned"); + assert!(matches!(error, DocumentCallError::InvalidInput(_))); + assert!(!error.to_string().contains(remote_text)); + assert!(!format!("{error:?}").contains(remote_text)); + assert!( + fixture + .state + .lock() + .expect("fixture state lock") + .outputs + .is_empty() + ); +} + #[tokio::test] async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure() { let fixture = test_fixture().await; From b4199a04f210fcb2490a36bf99c7c0c9bd17aa59 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:21:14 +0530 Subject: [PATCH 20/50] fix(client): remove release timeout around output release calls Output release calls no longer wrap the RPC in a five second timeout, so a slow release is awaited to completion instead of being abandoned. The timeout was dropped because the release path already tolerates failures and the extra wrapper only added noise. Auto-committed-on: macbook --- crates/tinydocs-client/src/client.rs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/crates/tinydocs-client/src/client.rs b/crates/tinydocs-client/src/client.rs index 6b2e326..6047141 100644 --- a/crates/tinydocs-client/src/client.rs +++ b/crates/tinydocs-client/src/client.rs @@ -19,7 +19,6 @@ use crate::DocumentCallError; const MODULE_ID: &str = "tinydocs"; const READ_CHUNK_BYTES: u64 = 1024 * 1024; const MAX_DOCUMENT_BYTES: u64 = 64 * 1024 * 1024; -const RELEASE_TIMEOUT: Duration = Duration::from_secs(5); const INVALID_INPUT_WIRE_NAME: &str = "ai.tinyhumans.tinydocs.Error.InvalidInput"; /// Typed client for the TinyDocs module. @@ -281,14 +280,12 @@ impl Drop for OutputReleaseGuard { let ids = std::mem::take(&mut self.ids); runtime.spawn(async move { for output_id in ids { - match tokio::time::timeout( - RELEASE_TIMEOUT, - output.call_detailed::<()>(methods::RELEASE_OUTPUT, (output_id,)), - ) - .await + match output + .call_detailed::<()>(methods::RELEASE_OUTPUT, (output_id,)) + .await { Ok(Ok(())) => {} - Ok(Err(_)) | Err(_) => { + Err(_) => { tracing::debug!(module = MODULE_ID, "releasing a TinyDocs output failed") } } @@ -306,12 +303,11 @@ async fn collect( ids: vec![handle.output_id.clone()], }; let result = read_all(output, &handle).await; - let release_result = tokio::time::timeout( - RELEASE_TIMEOUT, - output.call_detailed::<()>(methods::RELEASE_OUTPUT, (handle.output_id.clone(),)), - ) - .await; - if !matches!(release_result, Ok(Ok(()))) { + if output + .call_detailed::<()>(methods::RELEASE_OUTPUT, (handle.output_id.clone(),)) + .await + .is_err() + { tracing::debug!(module = MODULE_ID, "releasing a TinyDocs output failed") } release.ids.clear(); From d8aefbf96a678c633c3053c66d5f2306431b01d1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:21:45 +0530 Subject: [PATCH 21/50] fix(tinydocs-client): redact module messages from Display output Display for DocumentCallError now reports only the error category instead of the module-provided text, matching Debug so untrusted remote text cannot leak into logs or diagnostics. The raw message is still available through a new raw_message accessor for callers that apply their own redaction policy. Auto-committed-on: macbook --- crates/tinydocs-client/src/client.rs | 3 +- crates/tinydocs-client/src/error.rs | 30 ++++++++++++++----- crates/tinydocs-client/src/error_tests.rs | 12 ++++---- .../tinydocs-client/tests/recording_broker.rs | 2 ++ 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/crates/tinydocs-client/src/client.rs b/crates/tinydocs-client/src/client.rs index 6047141..4363670 100644 --- a/crates/tinydocs-client/src/client.rs +++ b/crates/tinydocs-client/src/client.rs @@ -1,7 +1,6 @@ //! Typed calls and held-output transfer for the TinyDocs module. use std::fmt::{Debug, Formatter}; -use std::time::Duration; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; @@ -284,7 +283,7 @@ impl Drop for OutputReleaseGuard { .call_detailed::<()>(methods::RELEASE_OUTPUT, (output_id,)) .await { - Ok(Ok(())) => {} + Ok(()) => {} Err(_) => { tracing::debug!(module = MODULE_ID, "releasing a TinyDocs output failed") } diff --git a/crates/tinydocs-client/src/error.rs b/crates/tinydocs-client/src/error.rs index 916274f..1b89c42 100644 --- a/crates/tinydocs-client/src/error.rs +++ b/crates/tinydocs-client/src/error.rs @@ -4,9 +4,9 @@ use std::fmt::{Debug, Display, Formatter}; /// Why a TinyDocs operation did not produce its requested result. /// -/// Remote text is retained in the value for an intentional caller-facing error -/// path. Its [`Debug`] form redacts the text so diagnostics do not accidentally -/// include module-provided data. +/// Remote text is retained for an explicit caller policy, but [`Debug`] and +/// [`Display`] never include it. Module-provided text is untrusted and may +/// contain caller input or credentials. #[derive(Clone, PartialEq, Eq)] pub enum DocumentCallError { /// The module is unavailable or cannot serve this contract. @@ -28,14 +28,30 @@ impl Debug for DocumentCallError { } } -impl Display for DocumentCallError { - fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { - let message = match self { +impl DocumentCallError { + /// Return the untrusted message received from TinyDocs, when one exists. + /// + /// Apply an explicit product redaction policy before displaying, storing, + /// or logging this text. [`Debug`] and [`Display`] use only the error + /// category and never include this value. + #[must_use] + pub fn raw_message(&self) -> &str { + match self { Self::Unavailable(message) | Self::InvalidInput(message) | Self::Failed(message) => { message } + } + } +} + +impl Display for DocumentCallError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let category = match self { + Self::Unavailable(_) => "unavailable", + Self::InvalidInput(_) => "input rejected", + Self::Failed(_) => "execution failed", }; - formatter.write_str(message) + write!(formatter, "MODULE_CALL_REPORTED: module {category}") } } diff --git a/crates/tinydocs-client/src/error_tests.rs b/crates/tinydocs-client/src/error_tests.rs index 5e66b0d..9082961 100644 --- a/crates/tinydocs-client/src/error_tests.rs +++ b/crates/tinydocs-client/src/error_tests.rs @@ -3,7 +3,7 @@ use super::*; #[test] -fn debug_redacts_every_module_message_but_display_keeps_the_intended_input_reason() { +fn debug_and_display_redact_module_messages_but_raw_message_is_explicitly_available() { let errors = [ DocumentCallError::Unavailable("module secret".to_string()), DocumentCallError::InvalidInput("field must be present".to_string()), @@ -11,13 +11,15 @@ fn debug_redacts_every_module_message_but_display_keeps_the_intended_input_reaso ]; for error in errors { let debug = format!("{error:?}"); + let display = error.to_string(); assert!(debug.contains("")); assert!(!debug.contains("secret")); assert!(!debug.contains("present")); assert!(!debug.contains("remote detail")); + assert!(!display.contains("secret")); + assert!(!display.contains("present")); + assert!(!display.contains("remote detail")); } - assert_eq!( - DocumentCallError::InvalidInput("field must be present".to_string()).to_string(), - "field must be present" - ); + let invalid = DocumentCallError::InvalidInput("field must be present".to_string()); + assert_eq!(invalid.raw_message(), "field must be present"); } diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 170c705..b776601 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -661,6 +661,7 @@ async fn credential_like_normal_invalid_input_is_redacted_from_formatted_errors( .await .expect_err("invalid input is returned"); assert!(matches!(error, DocumentCallError::InvalidInput(_))); + assert_eq!(error.raw_message(), remote_text); assert!(!error.to_string().contains(remote_text)); assert!(!format!("{error:?}").contains(remote_text)); } @@ -687,6 +688,7 @@ async fn credential_like_held_output_error_is_redacted_from_formatted_errors() { .await .expect_err("held output error is returned"); assert!(matches!(error, DocumentCallError::InvalidInput(_))); + assert_eq!(error.raw_message(), remote_text); assert!(!error.to_string().contains(remote_text)); assert!(!format!("{error:?}").contains(remote_text)); assert!( From 05b147bee7b1a72b5ae835ce59110d11ccae2450 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:22:03 +0530 Subject: [PATCH 22/50] docs(tinydocs-client): document DocumentCallError redaction behaviour Explain that DocumentCallError keeps unavailable, invalid-input, and execution failures distinct while its Debug and Display forms expose only the category, and note that callers needing input detail can opt into raw_message() and apply their own redaction policy before surfacing that untrusted text. Auto-committed-on: macbook --- crates/tinydocs-client/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/tinydocs-client/README.md b/crates/tinydocs-client/README.md index e53f272..ed0faad 100644 --- a/crates/tinydocs-client/README.md +++ b/crates/tinydocs-client/README.md @@ -12,6 +12,11 @@ use `ModuleObjectClient::call_detailed` so their wire errors retain the same contract distinctions. The host continues to own module loading and transport lifecycle. +`DocumentCallError` keeps unavailable, invalid-input, and execution failures +distinct. Its `Debug` and `Display` forms expose only the category. A caller +that needs TinyDocs' input detail can opt into `raw_message()` and apply its own +redaction policy before surfacing that untrusted text. + Variable-size results use the contract's held-output protocol. The client reads bounded base64 chunks, checks the declared length and SHA-256 digest, and releases each output after success or failure. Cancellation schedules the From 67ea197554439e9bb9e2e58254d3c5305370fd93 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:22:25 +0530 Subject: [PATCH 23/50] docs(tinydocs-client): clarify best-effort output release on cancellation The README now states that cancellation triggers a best-effort release call on the caller's Tokio runtime, and that if the runtime is gone or the call cannot be delivered, module-side expiry remains the fallback for reclaiming the output. Auto-committed-on: macbook --- crates/tinydocs-client/README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/tinydocs-client/README.md b/crates/tinydocs-client/README.md index ed0faad..82afe77 100644 --- a/crates/tinydocs-client/README.md +++ b/crates/tinydocs-client/README.md @@ -19,7 +19,10 @@ redaction policy before surfacing that untrusted text. Variable-size results use the contract's held-output protocol. The client reads bounded base64 chunks, checks the declared length and SHA-256 digest, and -releases each output after success or failure. Cancellation schedules the -release through the caller's Tokio runtime. The contract and behavior are -exercised by `tests/recording_broker.rs`, which serves a local recording -interface over TinyBus's in-memory broker and never contacts a live module. +releases each output after success or failure. On cancellation, it makes a +best-effort release call on the caller's Tokio runtime. If the runtime is +already gone or that call cannot be delivered, the client cannot guarantee +that the module has physically reclaimed the output; module-side expiry remains +the fallback. The contract and behavior are exercised by +`tests/recording_broker.rs`, which serves a local recording interface over +TinyBus's in-memory broker and never contacts a live module. From 5ce71f259d10d96ee58588c1dba8ab773692d7cb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:22:43 +0530 Subject: [PATCH 24/50] chore(tinydocs-client): fix clippy lints and doc wording Wrapped `TinyDocs` in backticks throughout the doc comments and added the missing semicolons on the debug log statements. Also replaced the lossy `MAX_DOCUMENT_BYTES as usize` cast with a checked conversion so the capacity clamp stays correct on 32-bit targets. Auto-committed-on: macbook --- crates/tinydocs-client/src/client.rs | 21 +++++++++++---------- crates/tinydocs-client/src/client_tests.rs | 2 +- crates/tinydocs-client/src/error.rs | 8 ++++---- crates/tinydocs-client/src/error_tests.rs | 2 +- crates/tinydocs-client/src/lib.rs | 4 ++-- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/crates/tinydocs-client/src/client.rs b/crates/tinydocs-client/src/client.rs index 4363670..3a4cc93 100644 --- a/crates/tinydocs-client/src/client.rs +++ b/crates/tinydocs-client/src/client.rs @@ -1,4 +1,4 @@ -//! Typed calls and held-output transfer for the TinyDocs module. +//! Typed calls and held-output transfer for the `TinyDocs` module. use std::fmt::{Debug, Formatter}; @@ -20,7 +20,7 @@ const READ_CHUNK_BYTES: u64 = 1024 * 1024; const MAX_DOCUMENT_BYTES: u64 = 64 * 1024 * 1024; const INVALID_INPUT_WIRE_NAME: &str = "ai.tinyhumans.tinydocs.Error.InvalidInput"; -/// Typed client for the TinyDocs module. +/// Typed client for the `TinyDocs` module. /// /// The embedding host supplies its process-owned [`ModuleClient`], which keeps /// module loading, readiness, timeouts, and failure reporting outside this @@ -39,7 +39,7 @@ impl Debug for TinyDocsClient { } impl TinyDocsClient { - /// Create a TinyDocs client over the host's module client. + /// Create a `TinyDocs` client over the host's module client. #[must_use] pub fn new(module: ModuleClient) -> Self { Self { module } @@ -49,7 +49,7 @@ impl TinyDocsClient { /// /// # Errors /// - /// Returns [`DocumentCallError`] when TinyDocs is unavailable, rejects the + /// Returns [`DocumentCallError`] when `TinyDocs` is unavailable, rejects the /// specification, or fails to generate or transfer the output. pub async fn generate_docx(&self, spec: &DocumentSpec) -> Result, DocumentCallError> { let output = self.output_client().await?; @@ -69,7 +69,7 @@ impl TinyDocsClient { /// /// # Errors /// - /// Returns [`DocumentCallError`] when TinyDocs is unavailable, rejects the + /// Returns [`DocumentCallError`] when `TinyDocs` is unavailable, rejects the /// deck, or fails to generate or transfer the output. pub async fn generate_pptx( &self, @@ -180,7 +180,7 @@ impl TinyDocsClient { /// # Errors /// /// Returns [`DocumentCallError::InvalidInput`] for empty or oversized image - /// data, and another [`DocumentCallError`] when TinyDocs cannot inspect it. + /// data, and another [`DocumentCallError`] when `TinyDocs` cannot inspect it. pub async fn inspect_image(&self, image: &[u8]) -> Result { if image.is_empty() || image.len() > MAX_IMAGE_BYTES { return Err(DocumentCallError::InvalidInput(format!( @@ -200,7 +200,7 @@ impl TinyDocsClient { /// Render selected pages of a PDF and collect every returned PNG. /// - /// The result preserves the page order declared by TinyDocs. + /// The result preserves the page order declared by `TinyDocs`. /// /// # Errors /// @@ -285,7 +285,7 @@ impl Drop for OutputReleaseGuard { { Ok(()) => {} Err(_) => { - tracing::debug!(module = MODULE_ID, "releasing a TinyDocs output failed") + tracing::debug!(module = MODULE_ID, "releasing a TinyDocs output failed"); } } } @@ -307,7 +307,7 @@ async fn collect( .await .is_err() { - tracing::debug!(module = MODULE_ID, "releasing a TinyDocs output failed") + tracing::debug!(module = MODULE_ID, "releasing a TinyDocs output failed"); } release.ids.clear(); result @@ -325,7 +325,8 @@ async fn read_all( } let capacity = usize::try_from(handle.total_bytes).unwrap_or(0); - let mut bytes = Vec::with_capacity(capacity.min(MAX_DOCUMENT_BYTES as usize)); + let max_capacity = usize::try_from(MAX_DOCUMENT_BYTES).unwrap_or(usize::MAX); + let mut bytes = Vec::with_capacity(capacity.min(max_capacity)); while (bytes.len() as u64) < handle.total_bytes { let encoded: String = output .call_detailed( diff --git a/crates/tinydocs-client/src/client_tests.rs b/crates/tinydocs-client/src/client_tests.rs index c6a44ff..672e38a 100644 --- a/crates/tinydocs-client/src/client_tests.rs +++ b/crates/tinydocs-client/src/client_tests.rs @@ -1,4 +1,4 @@ -//! Focused classification and digest tests for the TinyDocs client. +//! Focused classification and digest tests for the `TinyDocs` client. use super::*; diff --git a/crates/tinydocs-client/src/error.rs b/crates/tinydocs-client/src/error.rs index 1b89c42..85d8502 100644 --- a/crates/tinydocs-client/src/error.rs +++ b/crates/tinydocs-client/src/error.rs @@ -1,8 +1,8 @@ -//! TinyDocs call errors kept distinct for the host's tool and reporting layers. +//! `TinyDocs` call errors kept distinct for the host's tool and reporting layers. use std::fmt::{Debug, Display, Formatter}; -/// Why a TinyDocs operation did not produce its requested result. +/// Why a `TinyDocs` operation did not produce its requested result. /// /// Remote text is retained for an explicit caller policy, but [`Debug`] and /// [`Display`] never include it. Module-provided text is untrusted and may @@ -11,7 +11,7 @@ use std::fmt::{Debug, Display, Formatter}; pub enum DocumentCallError { /// The module is unavailable or cannot serve this contract. Unavailable(String), - /// TinyDocs rejected caller-provided input. + /// `TinyDocs` rejected caller-provided input. InvalidInput(String), /// The operation or its output transfer failed. Failed(String), @@ -29,7 +29,7 @@ impl Debug for DocumentCallError { } impl DocumentCallError { - /// Return the untrusted message received from TinyDocs, when one exists. + /// Return the untrusted message received from `TinyDocs`, when one exists. /// /// Apply an explicit product redaction policy before displaying, storing, /// or logging this text. [`Debug`] and [`Display`] use only the error diff --git a/crates/tinydocs-client/src/error_tests.rs b/crates/tinydocs-client/src/error_tests.rs index 9082961..7f9db52 100644 --- a/crates/tinydocs-client/src/error_tests.rs +++ b/crates/tinydocs-client/src/error_tests.rs @@ -1,4 +1,4 @@ -//! Formatting behavior for TinyDocs errors, including message redaction. +//! Formatting behavior for `TinyDocs` errors, including message redaction. use super::*; diff --git a/crates/tinydocs-client/src/lib.rs b/crates/tinydocs-client/src/lib.rs index 7e2014d..8737ed9 100644 --- a/crates/tinydocs-client/src/lib.rs +++ b/crates/tinydocs-client/src/lib.rs @@ -1,8 +1,8 @@ -//! Typed host client for the `tinydocs` TinyBus module. +//! Typed host client for the `tinydocs` `TinyBus` module. //! //! Module loading, readiness, host policy, and transport ownership stay with //! the embedding host. The client accepts that host's `ModuleClient`, then -//! owns TinyDocs contract calls and held-output transfer. +//! owns `TinyDocs` contract calls and held-output transfer. //! //! ```no_run //! use tinydocs_bus::DocumentSpec; From 240aebde74d09d691e151820aed074664647f8f5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:24:10 +0530 Subject: [PATCH 25/50] test(tinydocs-client): split recording broker fixture and module wire tests Refactored the recording broker fixture so call recording, fault injection, malformed replies, and member dispatch live in separate helpers, and split the oversized module wire failure test into focused cases covering input availability, streamed failures, held output release, and malformed typed replies. Auto-committed-on: macbook --- .../tinydocs-client/tests/recording_broker.rs | 175 +++++++++++------- 1 file changed, 108 insertions(+), 67 deletions(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index b776601..8664d19 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -1,4 +1,9 @@ -//! Product-facing TinyDocs client tests over an in-process recording broker. +#![expect( + clippy::expect_used, + reason = "fixture assertions should stop immediately on broken broker setup" +)] + +//! Product-facing `TinyDocs` client tests over an in-process recording broker. use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, Mutex}; @@ -24,6 +29,7 @@ use tokio::sync::Notify; const MODULE_ID: &str = "tinydocs"; const CHUNK_BYTES: u64 = 1024 * 1024; +const CHUNK_BYTES_USIZE: usize = 1024 * 1024; const MAX_DOCUMENT_BYTES: u64 = 64 * 1024 * 1024; static RECORD: ReleaseRecord = ReleaseRecord { @@ -164,60 +170,59 @@ impl RecordingService { .insert(member.to_string(), bytes); Ok(()) } -} -#[async_trait] -impl Interface for RecordingService { - fn name(&self) -> InterfaceName { - InterfaceName::new(BUS_NAME).expect("valid TinyDocs interface") - } - - fn members(&self) -> Vec { - METHODS - .iter() - .map(|member| MemberName::new(*member).expect("valid TinyDocs member")) - .collect() + fn record_call(&self, member: &str, args: &Value) -> Option<(String, String)> { + let mut state = self.state.lock().expect("fixture state lock"); + state.calls.push(Call { + member: member.to_string(), + args: args.clone(), + }); + state.faults.get(member).cloned() } - async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { - let member = member.as_str().to_string(); - let fault = { - let mut state = self.state.lock().expect("fixture state lock"); - state.calls.push(Call { - member: member.clone(), - args: args.clone(), - }); - state.faults.get(&member).cloned() - }; - if let Some((name, message)) = fault { - let stream_index = match member.as_str() { - methods::GENERATE_PPTX if !args[1].is_null() => Some(1), - methods::EXTRACT_TEXT | methods::INSPECT_IMAGE => Some(0), - methods::EXTRACT_DOCUMENT | methods::CONVERT_MARKDOWN | methods::RENDER_PDF => { - Some(1) - } - _ => None, - }; - if let Some(index) = stream_index { - self.read_stream_arg(&args, index, &member).await?; - } - return Err(Error::MethodFailed { name, message }); - } - let malformed = self - .state + fn malformed_reply(&self, member: &str) -> Option { + self.state .lock() .expect("fixture state lock") .malformed_replies - .get(&member) - .cloned(); - if let Some(reply) = malformed { - if member == methods::EXTRACT_DOCUMENT { - self.read_stream_arg(&args, 1, &member).await?; + .get(member) + .cloned() + } + + async fn return_fault( + &self, + member: &str, + args: &Value, + (name, message): (String, String), + ) -> tinybus::Result { + let stream_index = match member { + methods::GENERATE_PPTX if !args[1].is_null() => Some(1), + methods::EXTRACT_TEXT | methods::INSPECT_IMAGE => Some(0), + methods::EXTRACT_DOCUMENT | methods::CONVERT_MARKDOWN | methods::RENDER_PDF => { + Some(1) } - return Ok(reply); + _ => None, + }; + if let Some(index) = stream_index { + self.read_stream_arg(args, index, member).await?; + } + Err(Error::MethodFailed { name, message }) + } + + async fn return_malformed_reply( + &self, + member: &str, + args: &Value, + reply: Value, + ) -> tinybus::Result { + if member == methods::EXTRACT_DOCUMENT { + self.read_stream_arg(args, 1, member).await?; } + Ok(reply) + } - match member.as_str() { + async fn dispatch_member(&self, member: &str, args: Value) -> tinybus::Result { + match member { methods::GENERATE_DOCX => { let bytes = self .state @@ -229,18 +234,16 @@ impl Interface for RecordingService { } methods::GENERATE_PPTX => { if !args[1].is_null() { - self.read_stream_arg(&args, 1, &member).await?; + self.read_stream_arg(&args, 1, member).await?; } Ok(serde_json::to_value(self.hold(b"pptx bytes".to_vec()))?) } methods::EXTRACT_TEXT => { - self.read_stream_arg(&args, 0, &member).await?; - Ok(serde_json::to_value( - self.hold_for(&member, b"extracted text"), - )?) + self.read_stream_arg(&args, 0, member).await?; + Ok(serde_json::to_value(self.hold_for(member, b"extracted text"))?) } methods::EXTRACT_DOCUMENT => { - self.read_stream_arg(&args, 1, &member).await?; + self.read_stream_arg(&args, 1, member).await?; Ok(json!({ "format": "pdf", "section_count": 1, @@ -254,17 +257,15 @@ impl Interface for RecordingService { })) } methods::CONVERT_MARKDOWN => { - self.read_stream_arg(&args, 1, &member).await?; - Ok(serde_json::to_value( - self.hold_for(&member, b"# converted\n"), - )?) + self.read_stream_arg(&args, 1, member).await?; + Ok(serde_json::to_value(self.hold_for(member, b"# converted\n"))?) } methods::INSPECT_IMAGE => { - self.read_stream_arg(&args, 0, &member).await?; + self.read_stream_arg(&args, 0, member).await?; Ok(json!({ "format": "PNG", "width_px": 2, "height_px": 3 })) } methods::RENDER_PDF => { - self.read_stream_arg(&args, 1, &member).await?; + self.read_stream_arg(&args, 1, member).await?; let first = self.hold(b"page one".to_vec()); let second = self.hold(b"page two".to_vec()); Ok(json!({ @@ -306,6 +307,34 @@ impl Interface for RecordingService { } } +#[async_trait] +impl Interface for RecordingService { + fn name(&self) -> InterfaceName { + InterfaceName::new(BUS_NAME).expect("valid TinyDocs interface") + } + + fn members(&self) -> Vec { + METHODS + .iter() + .map(|member| MemberName::new(*member).expect("valid TinyDocs member")) + .collect() + } + + async fn call(&self, member: &MemberName, args: Value) -> tinybus::Result { + let member = member.as_str().to_string(); + let fault = self.record_call(&member, &args); + if let Some((name, message)) = fault { + return self.return_fault(&member, &args, (name, message)).await; + } + if let Some(reply) = self.malformed_reply(&member) { + return self + .return_malformed_reply(&member, &args, reply) + .await; + } + self.dispatch_member(&member, args).await + } +} + impl RecordingService { async fn read_output(&self, args: &Value) -> tinybus::Result { let output_id = args[0].as_str().expect("output id string").to_string(); @@ -367,8 +396,10 @@ async fn test_fixture_with_options(enabled: bool, timeout: Option) -> let server = Connection::connect(bus.connect().await.expect("server transport")) .await .expect("server connection"); - let mut initial_state = State::default(); - initial_state.generated_bytes = b"docx bytes".to_vec(); + let initial_state = State { + generated_bytes: b"docx bytes".to_vec(), + ..State::default() + }; let state = Arc::new(Mutex::new(initial_state)); server .serve_at( @@ -702,7 +733,7 @@ async fn credential_like_held_output_error_is_redacted_from_formatted_errors() { } #[tokio::test] -async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure() { +async fn ordinary_module_wire_failures_keep_input_availability_and_execution_distinct() { let fixture = test_fixture().await; fixture .state @@ -758,7 +789,10 @@ async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure fixture.client.generate_docx(&document_spec()).await, Err(DocumentCallError::Unavailable(_)) )); +} +#[tokio::test] +async fn streamed_module_wire_failures_keep_input_and_execution_distinct() { let fixture = test_fixture().await; fixture .state @@ -800,7 +834,10 @@ async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure fixture.client.inspect_image(b"PNG bytes").await, Err(DocumentCallError::Failed(_)) )); +} +#[tokio::test] +async fn held_output_invalid_input_is_classified_and_still_released() { let fixture = test_fixture().await; fixture .state @@ -818,15 +855,19 @@ async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure fixture.client.generate_docx(&document_spec()).await, Err(DocumentCallError::InvalidInput(_)) )); - let state = fixture.state.lock().expect("fixture state lock"); - assert!( + let released = { + let state = fixture.state.lock().expect("fixture state lock"); state .calls .iter() .any(|call| call.member == methods::RELEASE_OUTPUT) - ); - assert!(state.outputs.is_empty()); + && state.outputs.is_empty() + }; + assert!(released); +} +#[tokio::test] +async fn malformed_typed_stream_reply_is_a_failed_call() { let fixture = test_fixture().await; fixture .state @@ -848,7 +889,7 @@ async fn module_wire_failures_keep_invalid_input_distinct_from_execution_failure #[tokio::test] async fn held_outputs_are_chunked_in_order_verified_and_released() { let fixture = test_fixture().await; - let bytes = vec![0x5a; (CHUNK_BYTES * 2 + 13) as usize]; + let bytes = vec![0x5a; CHUNK_BYTES_USIZE * 2 + 13]; { let mut state = fixture.state.lock().expect("fixture state lock"); state.generated_bytes = bytes.clone(); @@ -928,7 +969,7 @@ async fn held_outputs_are_chunked_in_order_verified_and_released() { let mut state = fixture.state.lock().expect("fixture state lock"); state.read_mode = mode; if matches!(mode, ReadMode::Overlong) { - state.generated_bytes = vec![0x33; CHUNK_BYTES as usize + 16]; + state.generated_bytes = vec![0x33; CHUNK_BYTES_USIZE + 16]; state.declared_size = Some(CHUNK_BYTES); let declared_digest = digest(&state.generated_bytes); state.declared_digest = Some(declared_digest); From afe1410dcdffa236e10c1d65927a11134b53f2cd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:24:23 +0530 Subject: [PATCH 26/50] test(client): scope fixture state locks to avoid holding them across awaits The assertions in the held-output test now take the fixture state lock inside a block so the guard is dropped before the next fixture is created, keeping the test free of a lock held across subsequent async work. Auto-committed-on: macbook --- .../tinydocs-client/tests/recording_broker.rs | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 8664d19..2bc1fef 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -930,14 +930,16 @@ async fn held_outputs_are_chunked_in_order_verified_and_released() { fixture.client.generate_docx(&document_spec()).await, Err(DocumentCallError::Failed(_)) )); - let state = fixture.state.lock().expect("fixture state lock"); - assert!( - state - .calls - .iter() - .any(|call| call.member == methods::RELEASE_OUTPUT) - ); - assert!(state.outputs.is_empty()); + { + let state = fixture.state.lock().expect("fixture state lock"); + assert!( + state + .calls + .iter() + .any(|call| call.member == methods::RELEASE_OUTPUT) + ); + assert!(state.outputs.is_empty()); + } let fixture = test_fixture().await; fixture @@ -949,19 +951,21 @@ async fn held_outputs_are_chunked_in_order_verified_and_released() { fixture.client.generate_docx(&document_spec()).await, Err(DocumentCallError::Failed(_)) )); - let state = fixture.state.lock().expect("fixture state lock"); - assert!( - !state - .calls - .iter() - .any(|call| call.member == methods::READ_OUTPUT) - ); - assert!( - state - .calls - .iter() - .any(|call| call.member == methods::RELEASE_OUTPUT) - ); + { + let state = fixture.state.lock().expect("fixture state lock"); + assert!( + !state + .calls + .iter() + .any(|call| call.member == methods::READ_OUTPUT) + ); + assert!( + state + .calls + .iter() + .any(|call| call.member == methods::RELEASE_OUTPUT) + ); + } for mode in [ReadMode::Empty, ReadMode::InvalidBase64, ReadMode::Overlong] { let fixture = test_fixture().await; From edebe99e0c01fd90503427112c6b3c5a492edb03 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:24:37 +0530 Subject: [PATCH 27/50] test(tinydocs-client): scope fixture state lock in release error test Reformat the recording broker test to satisfy rustfmt and drop the fixture state lock before the test continues, so the guard is not held across the remaining assertions. Auto-committed-on: macbook --- .../tinydocs-client/tests/recording_broker.rs | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 2bc1fef..9fd102e 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -198,9 +198,7 @@ impl RecordingService { let stream_index = match member { methods::GENERATE_PPTX if !args[1].is_null() => Some(1), methods::EXTRACT_TEXT | methods::INSPECT_IMAGE => Some(0), - methods::EXTRACT_DOCUMENT | methods::CONVERT_MARKDOWN | methods::RENDER_PDF => { - Some(1) - } + methods::EXTRACT_DOCUMENT | methods::CONVERT_MARKDOWN | methods::RENDER_PDF => Some(1), _ => None, }; if let Some(index) = stream_index { @@ -240,7 +238,9 @@ impl RecordingService { } methods::EXTRACT_TEXT => { self.read_stream_arg(&args, 0, member).await?; - Ok(serde_json::to_value(self.hold_for(member, b"extracted text"))?) + Ok(serde_json::to_value( + self.hold_for(member, b"extracted text"), + )?) } methods::EXTRACT_DOCUMENT => { self.read_stream_arg(&args, 1, member).await?; @@ -258,7 +258,9 @@ impl RecordingService { } methods::CONVERT_MARKDOWN => { self.read_stream_arg(&args, 1, member).await?; - Ok(serde_json::to_value(self.hold_for(member, b"# converted\n"))?) + Ok(serde_json::to_value( + self.hold_for(member, b"# converted\n"), + )?) } methods::INSPECT_IMAGE => { self.read_stream_arg(&args, 0, member).await?; @@ -327,9 +329,7 @@ impl Interface for RecordingService { return self.return_fault(&member, &args, (name, message)).await; } if let Some(reply) = self.malformed_reply(&member) { - return self - .return_malformed_reply(&member, &args, reply) - .await; + return self.return_malformed_reply(&member, &args, reply).await; } self.dispatch_member(&member, args).await } @@ -595,14 +595,16 @@ async fn text_outputs_must_be_utf8_and_release_errors_do_not_discard_good_bytes( .map(|_| ()) }; assert!(matches!(result, Err(DocumentCallError::Failed(_)))); - let state = fixture.state.lock().expect("fixture state lock"); - assert!(state.outputs.is_empty()); - assert!( - state - .calls - .iter() - .any(|call| call.member == methods::RELEASE_OUTPUT) - ); + { + let state = fixture.state.lock().expect("fixture state lock"); + assert!(state.outputs.is_empty()); + assert!( + state + .calls + .iter() + .any(|call| call.member == methods::RELEASE_OUTPUT) + ); + } } let fixture = test_fixture().await; From 8c0c760745496f9f3cb468594310b21b3fa2e066 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 03:24:52 +0530 Subject: [PATCH 28/50] test(client): split held output release tests into focused cases The single held-output test was divided into three separate tests covering in-order reads with release after success, release on bad digest or declared size, and release for truncated, invalid, and overlong chunks. This makes failures easier to localise to a specific release path. Auto-committed-on: macbook --- crates/tinydocs-client/tests/recording_broker.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 9fd102e..8619ef3 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -889,7 +889,7 @@ async fn malformed_typed_stream_reply_is_a_failed_call() { } #[tokio::test] -async fn held_outputs_are_chunked_in_order_verified_and_released() { +async fn held_outputs_are_read_in_order_and_released_after_success() { let fixture = test_fixture().await; let bytes = vec![0x5a; CHUNK_BYTES_USIZE * 2 + 13]; { @@ -921,7 +921,10 @@ async fn held_outputs_are_chunked_in_order_verified_and_released() { .outputs .is_empty() ); +} +#[tokio::test] +async fn held_outputs_with_bad_digest_or_declared_size_are_released() { let fixture = test_fixture().await; { let mut state = fixture.state.lock().expect("fixture state lock"); @@ -968,7 +971,10 @@ async fn held_outputs_are_chunked_in_order_verified_and_released() { .any(|call| call.member == methods::RELEASE_OUTPUT) ); } +} +#[tokio::test] +async fn truncated_invalid_and_overlong_chunks_are_released() { for mode in [ReadMode::Empty, ReadMode::InvalidBase64, ReadMode::Overlong] { let fixture = test_fixture().await; { From 787e738f5fa79aac0ff2fe5fd03b1ad950d4082d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 13:38:50 +0530 Subject: [PATCH 29/50] test(client): cover peer handover during held output Add regression tests that transfer the well-known bus name to a replacement peer after the output object has resolved, asserting that generation, read, and release all stay pinned to the original peer for both docx and pptx flows. Auto-committed-on: macbook --- .../tinydocs-client/tests/recording_broker.rs | 115 +++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 8619ef3..0cf0102 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -6,7 +6,7 @@ //! Product-facing `TinyDocs` client tests over an in-process recording broker. use std::collections::{BTreeMap, HashMap}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, atomic::{AtomicUsize, Ordering}}; use std::time::Duration; use async_trait::async_trait; @@ -54,6 +54,42 @@ struct FixtureAccess { enabled: bool, } +/// Transfers the well-known name after the output object has resolved, while +/// leaving the original peer connected for the in-flight operation. +struct HandoverAccess { + client_connection: Connection, + original: Connection, + replacement: Connection, + reporter: FailureReporter, + lookups: AtomicUsize, +} + +impl ModuleAccess for HandoverAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == MODULE_ID).then_some(&RECORD) + } + + fn enabled(&self) -> bool { true } + + fn reporter(&self) -> &FailureReporter { &self.reporter } + + fn ensure_loaded<'a>(&'a self, _id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async move { + if self.lookups.fetch_add(1, Ordering::SeqCst) == 1 { + self.original.release_name(BUS_NAME).await.map_err(|_| "release failed".to_string())?; + self.replacement.request_name(BUS_NAME).await.map_err(|_| "request failed".to_string())?; + } + Ok(()) + }) + } + + fn proxy<'a>(&'a self, bus_name: &'a str, object_path: &'a str) -> AccessFuture<'a, Result> { + Box::pin(async move { + self.client_connection.proxy(bus_name, object_path, bus_name).map_err(|_| ProxyFailure::Contract) + }) + } +} + impl ModuleAccess for FixtureAccess { fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { (id == MODULE_ID).then_some(&RECORD) @@ -439,6 +475,40 @@ async fn test_fixture_with_options(enabled: bool, timeout: Option) -> } } +struct PeerHandoverFixture { + client: TinyDocsClient, + original: Arc>, + replacement: Arc>, + _broker: tokio::task::JoinHandle>, +} + +async fn peer_handover_fixture() -> PeerHandoverFixture { + let bus = MemoryBus::new(); + let broker = Broker::new().spawn(bus.clone()); + let original_connection = Connection::connect(bus.connect().await.expect("original transport")) + .await.expect("original connection"); + let replacement_connection = Connection::connect(bus.connect().await.expect("replacement transport")) + .await.expect("replacement connection"); + let original = Arc::new(Mutex::new(State { generated_bytes: b"original bytes".to_vec(), ..State::default() })); + let replacement = Arc::new(Mutex::new(State { generated_bytes: b"replacement bytes".to_vec(), ..State::default() })); + for (connection, state) in [(&original_connection, original.clone()), (&replacement_connection, replacement.clone())] { + connection.serve_at(ObjectPath::new(OBJECT_PATH).expect("valid TinyDocs path"), RecordingService { + connection: connection.clone(), state, + }).await.expect("serve peer"); + } + original_connection.request_name(BUS_NAME).await.expect("original owns alias"); + let client_connection = Connection::connect(bus.connect().await.expect("client transport")) + .await.expect("client connection"); + let access = HandoverAccess { + client_connection, + original: original_connection, + replacement: replacement_connection, + reporter: FailureReporter::new(Arc::new(NoopSink)), + lookups: AtomicUsize::new(0), + }; + PeerHandoverFixture { client: TinyDocsClient::new(ModuleClient::new(access)), original, replacement, _broker: broker } +} + fn digest(bytes: &[u8]) -> String { let mut output = String::new(); for byte in Sha256::digest(bytes) { @@ -456,6 +526,49 @@ fn document_spec() -> DocumentSpec { } } +#[tokio::test] +async fn held_docx_uses_the_peer_that_owns_its_generation() { + let fixture = peer_handover_fixture().await; + let result = fixture.client.generate_docx(&document_spec()).await; + let replacement_control = fixture.client.generate_docx(&document_spec()).await; + + // This is the currently failing regression draft: object_client resolves + // the original peer, then the producer call sees the replacement alias. + // A per-operation pin must keep both calls on the original broker peer. + assert_eq!(result.expect("generation and held output stay on original"), b"original bytes"); + assert_eq!(replacement_control.expect("replacement remains usable at the same object path"), b"replacement bytes"); + let original = fixture.original.lock().expect("original state lock"); + let replacement = fixture.replacement.lock().expect("replacement state lock"); + assert_eq!(original.calls.iter().filter(|call| call.member == methods::GENERATE_DOCX).count(), 1); + assert_eq!(original.calls.iter().filter(|call| call.member == methods::READ_OUTPUT).count(), 1); + assert_eq!(original.calls.iter().filter(|call| call.member == methods::RELEASE_OUTPUT).count(), 1); + assert_eq!(replacement.calls.iter().filter(|call| call.member == methods::GENERATE_DOCX).count(), 2); + assert!(replacement.calls.iter().any(|call| call.member == methods::READ_OUTPUT)); + assert!(replacement.calls.iter().any(|call| call.member == methods::RELEASE_OUTPUT)); +} + +#[tokio::test] +async fn held_pptx_upload_and_output_use_the_peer_that_owns_generation() { + let fixture = peer_handover_fixture().await; + let deck = tinydocs_bus::WirePresentationSpec { title: "Deck".into(), author: None, theme: None, slides: vec![] }; + let images = b"fresh image bytes"; + let result = fixture.client.generate_pptx(&deck, images).await; + let replacement_control = fixture.client.generate_pptx(&deck, images).await; + + assert_eq!(result.expect("streamed generation and held output stay on original"), b"original bytes"); + assert_eq!(replacement_control.expect("replacement remains usable at the same object path"), b"replacement bytes"); + let original = fixture.original.lock().expect("original state lock"); + let replacement = fixture.replacement.lock().expect("replacement state lock"); + assert_eq!(original.calls.iter().filter(|call| call.member == methods::GENERATE_PPTX).count(), 1); + assert_eq!(original.calls.iter().filter(|call| call.member == methods::READ_OUTPUT).count(), 1); + assert_eq!(original.calls.iter().filter(|call| call.member == methods::RELEASE_OUTPUT).count(), 1); + assert_eq!(original.input_streams.get(methods::GENERATE_PPTX).map(Vec::as_slice), Some(images.as_slice())); + assert_eq!(replacement.calls.iter().filter(|call| call.member == methods::GENERATE_PPTX).count(), 2); + assert_eq!(replacement.input_streams.get(methods::GENERATE_PPTX).map(Vec::as_slice), Some(images.as_slice())); + assert!(replacement.calls.iter().any(|call| call.member == methods::READ_OUTPUT)); + assert!(replacement.calls.iter().any(|call| call.member == methods::RELEASE_OUTPUT)); +} + #[tokio::test] async fn typed_operations_use_contract_members_and_stream_document_bytes() { let fixture = test_fixture().await; From a049f5968609a20bed350ad734edc1b8c8ad33ec Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 13:39:02 +0530 Subject: [PATCH 30/50] test(tinydocs-client): remove unused handover access fixture The HandoverAccess test double and its atomic lookup counter were no longer referenced, so they have been deleted to keep the recording broker fixture lean. A handover_to field was added to the broker state to support the same scenario through the shared fixture instead. Auto-committed-on: macbook --- .../tinydocs-client/tests/recording_broker.rs | 39 +------------------ 1 file changed, 2 insertions(+), 37 deletions(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 0cf0102..6d9ff22 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -6,7 +6,7 @@ //! Product-facing `TinyDocs` client tests over an in-process recording broker. use std::collections::{BTreeMap, HashMap}; -use std::sync::{Arc, Mutex, atomic::{AtomicUsize, Ordering}}; +use std::sync::{Arc, Mutex}; use std::time::Duration; use async_trait::async_trait; @@ -54,42 +54,6 @@ struct FixtureAccess { enabled: bool, } -/// Transfers the well-known name after the output object has resolved, while -/// leaving the original peer connected for the in-flight operation. -struct HandoverAccess { - client_connection: Connection, - original: Connection, - replacement: Connection, - reporter: FailureReporter, - lookups: AtomicUsize, -} - -impl ModuleAccess for HandoverAccess { - fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { - (id == MODULE_ID).then_some(&RECORD) - } - - fn enabled(&self) -> bool { true } - - fn reporter(&self) -> &FailureReporter { &self.reporter } - - fn ensure_loaded<'a>(&'a self, _id: &'a str) -> AccessFuture<'a, Result<(), String>> { - Box::pin(async move { - if self.lookups.fetch_add(1, Ordering::SeqCst) == 1 { - self.original.release_name(BUS_NAME).await.map_err(|_| "release failed".to_string())?; - self.replacement.request_name(BUS_NAME).await.map_err(|_| "request failed".to_string())?; - } - Ok(()) - }) - } - - fn proxy<'a>(&'a self, bus_name: &'a str, object_path: &'a str) -> AccessFuture<'a, Result> { - Box::pin(async move { - self.client_connection.proxy(bus_name, object_path, bus_name).map_err(|_| ProxyFailure::Contract) - }) - } -} - impl ModuleAccess for FixtureAccess { fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { (id == MODULE_ID).then_some(&RECORD) @@ -145,6 +109,7 @@ struct State { resume_read: Arc, release_started: Arc, release_delay: Option, + handover_to: Option, } #[derive(Clone, Copy, Default)] From 0f2d0e521c113f6cda7d1f3cc3e02e4f5bfd89cc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 13:39:46 +0530 Subject: [PATCH 31/50] test(client): cover alias handover after generation retains output The recording broker fixture now transfers the well-known bus name to the replacement peer only after the original peer has retained its generated output. Assertions were updated so each peer is expected to handle exactly one generate, read, and release call, verifying that reads and releases still reach the peer that owns the generation. Auto-committed-on: macbook --- .../tinydocs-client/tests/recording_broker.rs | 246 +++++++++++++++--- 1 file changed, 205 insertions(+), 41 deletions(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 6d9ff22..e93ae58 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -229,13 +229,17 @@ impl RecordingService { .expect("fixture state lock") .generated_bytes .clone(); - Ok(serde_json::to_value(self.hold(bytes))?) + let output = self.hold(bytes); + self.handover_alias().await?; + Ok(serde_json::to_value(output)?) } methods::GENERATE_PPTX => { if !args[1].is_null() { self.read_stream_arg(&args, 1, member).await?; } - Ok(serde_json::to_value(self.hold(b"pptx bytes".to_vec()))?) + let output = self.hold(b"pptx bytes".to_vec()); + self.handover_alias().await?; + Ok(serde_json::to_value(output)?) } methods::EXTRACT_TEXT => { self.read_stream_arg(&args, 0, member).await?; @@ -337,6 +341,20 @@ impl Interface for RecordingService { } impl RecordingService { + async fn handover_alias(&self) -> tinybus::Result<()> { + let replacement = self + .state + .lock() + .expect("fixture state lock") + .handover_to + .take(); + if let Some(replacement) = replacement { + self.connection.release_name(BUS_NAME).await?; + replacement.request_name(BUS_NAME).await?; + } + Ok(()) + } + async fn read_output(&self, args: &Value) -> tinybus::Result { let output_id = args[0].as_str().expect("output id string").to_string(); let offset = args[1].as_u64().expect("output offset"); @@ -451,27 +469,54 @@ async fn peer_handover_fixture() -> PeerHandoverFixture { let bus = MemoryBus::new(); let broker = Broker::new().spawn(bus.clone()); let original_connection = Connection::connect(bus.connect().await.expect("original transport")) - .await.expect("original connection"); - let replacement_connection = Connection::connect(bus.connect().await.expect("replacement transport")) - .await.expect("replacement connection"); - let original = Arc::new(Mutex::new(State { generated_bytes: b"original bytes".to_vec(), ..State::default() })); - let replacement = Arc::new(Mutex::new(State { generated_bytes: b"replacement bytes".to_vec(), ..State::default() })); - for (connection, state) in [(&original_connection, original.clone()), (&replacement_connection, replacement.clone())] { - connection.serve_at(ObjectPath::new(OBJECT_PATH).expect("valid TinyDocs path"), RecordingService { - connection: connection.clone(), state, - }).await.expect("serve peer"); + .await + .expect("original connection"); + let replacement_connection = + Connection::connect(bus.connect().await.expect("replacement transport")) + .await + .expect("replacement connection"); + let original = Arc::new(Mutex::new(State { + generated_bytes: b"original bytes".to_vec(), + handover_to: Some(replacement_connection.clone()), + ..State::default() + })); + let replacement = Arc::new(Mutex::new(State { + generated_bytes: b"replacement bytes".to_vec(), + ..State::default() + })); + for (connection, state) in [ + (&original_connection, original.clone()), + (&replacement_connection, replacement.clone()), + ] { + connection + .serve_at( + ObjectPath::new(OBJECT_PATH).expect("valid TinyDocs path"), + RecordingService { + connection: connection.clone(), + state, + }, + ) + .await + .expect("serve peer"); } - original_connection.request_name(BUS_NAME).await.expect("original owns alias"); + original_connection + .request_name(BUS_NAME) + .await + .expect("original owns alias"); let client_connection = Connection::connect(bus.connect().await.expect("client transport")) - .await.expect("client connection"); - let access = HandoverAccess { - client_connection, - original: original_connection, - replacement: replacement_connection, + .await + .expect("client connection"); + let access = FixtureAccess { + connection: client_connection, reporter: FailureReporter::new(Arc::new(NoopSink)), - lookups: AtomicUsize::new(0), + enabled: true, }; - PeerHandoverFixture { client: TinyDocsClient::new(ModuleClient::new(access)), original, replacement, _broker: broker } + PeerHandoverFixture { + client: TinyDocsClient::new(ModuleClient::new(access)), + original, + replacement, + _broker: broker, + } } fn digest(bytes: &[u8]) -> String { @@ -497,41 +542,160 @@ async fn held_docx_uses_the_peer_that_owns_its_generation() { let result = fixture.client.generate_docx(&document_spec()).await; let replacement_control = fixture.client.generate_docx(&document_spec()).await; - // This is the currently failing regression draft: object_client resolves - // the original peer, then the producer call sees the replacement alias. - // A per-operation pin must keep both calls on the original broker peer. - assert_eq!(result.expect("generation and held output stay on original"), b"original bytes"); - assert_eq!(replacement_control.expect("replacement remains usable at the same object path"), b"replacement bytes"); + // The producer transfers the well-known name only after retaining its + // output. The later read and release must still reach that same peer. + assert_eq!( + result.expect("generation and held output stay on original"), + b"original bytes" + ); + assert_eq!( + replacement_control.expect("replacement remains usable at the same object path"), + b"replacement bytes" + ); let original = fixture.original.lock().expect("original state lock"); let replacement = fixture.replacement.lock().expect("replacement state lock"); - assert_eq!(original.calls.iter().filter(|call| call.member == methods::GENERATE_DOCX).count(), 1); - assert_eq!(original.calls.iter().filter(|call| call.member == methods::READ_OUTPUT).count(), 1); - assert_eq!(original.calls.iter().filter(|call| call.member == methods::RELEASE_OUTPUT).count(), 1); - assert_eq!(replacement.calls.iter().filter(|call| call.member == methods::GENERATE_DOCX).count(), 2); - assert!(replacement.calls.iter().any(|call| call.member == methods::READ_OUTPUT)); - assert!(replacement.calls.iter().any(|call| call.member == methods::RELEASE_OUTPUT)); + assert_eq!( + original + .calls + .iter() + .filter(|call| call.member == methods::GENERATE_DOCX) + .count(), + 1 + ); + assert_eq!( + original + .calls + .iter() + .filter(|call| call.member == methods::READ_OUTPUT) + .count(), + 1 + ); + assert_eq!( + original + .calls + .iter() + .filter(|call| call.member == methods::RELEASE_OUTPUT) + .count(), + 1 + ); + assert_eq!( + replacement + .calls + .iter() + .filter(|call| call.member == methods::GENERATE_DOCX) + .count(), + 1 + ); + assert_eq!( + replacement + .calls + .iter() + .filter(|call| call.member == methods::READ_OUTPUT) + .count(), + 1 + ); + assert_eq!( + replacement + .calls + .iter() + .filter(|call| call.member == methods::RELEASE_OUTPUT) + .count(), + 1 + ); } #[tokio::test] async fn held_pptx_upload_and_output_use_the_peer_that_owns_generation() { let fixture = peer_handover_fixture().await; - let deck = tinydocs_bus::WirePresentationSpec { title: "Deck".into(), author: None, theme: None, slides: vec![] }; + let deck = tinydocs_bus::WirePresentationSpec { + title: "Deck".into(), + author: None, + theme: None, + slides: vec![], + }; let images = b"fresh image bytes"; let result = fixture.client.generate_pptx(&deck, images).await; let replacement_control = fixture.client.generate_pptx(&deck, images).await; - assert_eq!(result.expect("streamed generation and held output stay on original"), b"original bytes"); - assert_eq!(replacement_control.expect("replacement remains usable at the same object path"), b"replacement bytes"); + assert_eq!( + result.expect("streamed generation and held output stay on original"), + b"original bytes" + ); + assert_eq!( + replacement_control.expect("replacement remains usable at the same object path"), + b"replacement bytes" + ); let original = fixture.original.lock().expect("original state lock"); let replacement = fixture.replacement.lock().expect("replacement state lock"); - assert_eq!(original.calls.iter().filter(|call| call.member == methods::GENERATE_PPTX).count(), 1); - assert_eq!(original.calls.iter().filter(|call| call.member == methods::READ_OUTPUT).count(), 1); - assert_eq!(original.calls.iter().filter(|call| call.member == methods::RELEASE_OUTPUT).count(), 1); - assert_eq!(original.input_streams.get(methods::GENERATE_PPTX).map(Vec::as_slice), Some(images.as_slice())); - assert_eq!(replacement.calls.iter().filter(|call| call.member == methods::GENERATE_PPTX).count(), 2); - assert_eq!(replacement.input_streams.get(methods::GENERATE_PPTX).map(Vec::as_slice), Some(images.as_slice())); - assert!(replacement.calls.iter().any(|call| call.member == methods::READ_OUTPUT)); - assert!(replacement.calls.iter().any(|call| call.member == methods::RELEASE_OUTPUT)); + assert_eq!( + original + .calls + .iter() + .filter(|call| call.member == methods::GENERATE_PPTX) + .count(), + 1 + ); + assert_eq!( + original + .calls + .iter() + .filter(|call| call.member == methods::READ_OUTPUT) + .count(), + 1 + ); + assert_eq!( + original + .calls + .iter() + .filter(|call| call.member == methods::RELEASE_OUTPUT) + .count(), + 1 + ); + assert_eq!( + original + .input_streams + .get(methods::GENERATE_PPTX) + .map(Vec::as_slice), + Some(images.as_slice()) + ); + assert_eq!( + replacement + .calls + .iter() + .filter(|call| call.member == methods::GENERATE_PPTX) + .count(), + 1 + ); + assert_eq!( + replacement + .input_streams + .get(methods::GENERATE_PPTX) + .map(Vec::as_slice), + Some(images.as_slice()) + ); + assert_eq!( + replacement + .calls + .iter() + .filter(|call| call.member == methods::READ_OUTPUT) + .count(), + 1 + ); + assert_eq!( + replacement + .calls + .iter() + .filter(|call| call.member == methods::RELEASE_OUTPUT) + .count(), + 1 + ); + assert_eq!( + original + .input_streams + .get(methods::GENERATE_PPTX) + .map(Vec::as_slice), + Some(images.as_slice()) + ); } #[tokio::test] From 1b24b149ea5b9183200b242d4685f9ec73d9c3e8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 13:40:37 +0530 Subject: [PATCH 32/50] test(recording_broker): consolidate peer-hop assertions into single checks The DOCX and PPTX peer-hop tests now gather their byte, call-count, and input-stream expectations into booleans and tuples so a single assert reports every mismatch at once. This replaces the chain of separate assert_eq! calls, which stopped at the first failure and hid the rest of the state. Auto-committed-on: macbook --- .../tinydocs-client/tests/recording_broker.rs | 109 +++++++----------- 1 file changed, 41 insertions(+), 68 deletions(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index e93ae58..cae0d40 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -544,63 +544,54 @@ async fn held_docx_uses_the_peer_that_owns_its_generation() { // The producer transfers the well-known name only after retaining its // output. The later read and release must still reach that same peer. - assert_eq!( - result.expect("generation and held output stay on original"), - b"original bytes" - ); - assert_eq!( - replacement_control.expect("replacement remains usable at the same object path"), - b"replacement bytes" - ); + let original_bytes = result + .as_ref() + .is_ok_and(|bytes| bytes == b"original bytes"); + let replacement_bytes = replacement_control + .as_ref() + .is_ok_and(|bytes| bytes == b"replacement bytes"); let original = fixture.original.lock().expect("original state lock"); let replacement = fixture.replacement.lock().expect("replacement state lock"); - assert_eq!( + let original_counts = ( original .calls .iter() .filter(|call| call.member == methods::GENERATE_DOCX) .count(), - 1 - ); - assert_eq!( original .calls .iter() .filter(|call| call.member == methods::READ_OUTPUT) .count(), - 1 - ); - assert_eq!( original .calls .iter() .filter(|call| call.member == methods::RELEASE_OUTPUT) .count(), - 1 ); - assert_eq!( + let replacement_counts = ( replacement .calls .iter() .filter(|call| call.member == methods::GENERATE_DOCX) .count(), - 1 - ); - assert_eq!( replacement .calls .iter() .filter(|call| call.member == methods::READ_OUTPUT) .count(), - 1 - ); - assert_eq!( replacement .calls .iter() .filter(|call| call.member == methods::RELEASE_OUTPUT) .count(), - 1 + ); + assert!( + original_bytes + && replacement_bytes + && original_counts == (1, 1, 1) + && replacement_counts == (1, 1, 1), + "peer-hop DOCX: original_bytes={original_bytes}, replacement_control_bytes={replacement_bytes}, original generate/read/release={original_counts:?}, replacement generate/read/release={replacement_counts:?}" ); } @@ -617,84 +608,66 @@ async fn held_pptx_upload_and_output_use_the_peer_that_owns_generation() { let result = fixture.client.generate_pptx(&deck, images).await; let replacement_control = fixture.client.generate_pptx(&deck, images).await; - assert_eq!( - result.expect("streamed generation and held output stay on original"), - b"original bytes" - ); - assert_eq!( - replacement_control.expect("replacement remains usable at the same object path"), - b"replacement bytes" - ); + let original_bytes = result + .as_ref() + .is_ok_and(|bytes| bytes == b"original bytes"); + let replacement_bytes = replacement_control + .as_ref() + .is_ok_and(|bytes| bytes == b"replacement bytes"); let original = fixture.original.lock().expect("original state lock"); let replacement = fixture.replacement.lock().expect("replacement state lock"); - assert_eq!( + let original_counts = ( original .calls .iter() .filter(|call| call.member == methods::GENERATE_PPTX) .count(), - 1 - ); - assert_eq!( original .calls .iter() .filter(|call| call.member == methods::READ_OUTPUT) .count(), - 1 - ); - assert_eq!( original .calls .iter() .filter(|call| call.member == methods::RELEASE_OUTPUT) .count(), - 1 ); - assert_eq!( - original - .input_streams - .get(methods::GENERATE_PPTX) - .map(Vec::as_slice), - Some(images.as_slice()) - ); - assert_eq!( + let replacement_counts = ( replacement .calls .iter() .filter(|call| call.member == methods::GENERATE_PPTX) .count(), - 1 - ); - assert_eq!( - replacement - .input_streams - .get(methods::GENERATE_PPTX) - .map(Vec::as_slice), - Some(images.as_slice()) - ); - assert_eq!( replacement .calls .iter() .filter(|call| call.member == methods::READ_OUTPUT) .count(), - 1 - ); - assert_eq!( replacement .calls .iter() .filter(|call| call.member == methods::RELEASE_OUTPUT) .count(), - 1 ); - assert_eq!( - original - .input_streams - .get(methods::GENERATE_PPTX) - .map(Vec::as_slice), - Some(images.as_slice()) + let original_stream = original + .input_streams + .get(methods::GENERATE_PPTX) + .map(Vec::as_slice) + == Some(images.as_slice()); + let replacement_stream = replacement + .input_streams + .get(methods::GENERATE_PPTX) + .map(Vec::as_slice) + == Some(images.as_slice()); + assert!( + original_bytes + && replacement_bytes + && original_counts == (1, 1, 1) + && replacement_counts == (1, 1, 1) + && original_stream + && replacement_stream, + "peer-hop PPTX: original_bytes={original_bytes}, replacement_control_bytes={replacement_bytes}, original generate/read/release={original_counts:?}, replacement generate/read/release={replacement_counts:?}, original_stream={original_stream}, replacement_stream={replacement_stream}" ); } From 2b4e263e6b36724a5500476fc2b3c472b887d576 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 13:41:04 +0530 Subject: [PATCH 33/50] test(recording_broker): cover per-member pptx output overrides The handover fixture now seeds distinct output overrides for the original and replacement members so the held pptx assertion reflects the bytes each member actually serves. The generate_pptx handler holds its output against the calling member rather than a fixed payload. Auto-committed-on: macbook --- .../tinydocs-client/tests/recording_broker.rs | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index cae0d40..f7f14f1 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -237,7 +237,7 @@ impl RecordingService { if !args[1].is_null() { self.read_stream_arg(&args, 1, member).await?; } - let output = self.hold(b"pptx bytes".to_vec()); + let output = self.hold_for(member, b"pptx bytes"); self.handover_alias().await?; Ok(serde_json::to_value(output)?) } @@ -475,15 +475,23 @@ async fn peer_handover_fixture() -> PeerHandoverFixture { Connection::connect(bus.connect().await.expect("replacement transport")) .await .expect("replacement connection"); - let original = Arc::new(Mutex::new(State { + let mut original_state = State { generated_bytes: b"original bytes".to_vec(), handover_to: Some(replacement_connection.clone()), ..State::default() - })); - let replacement = Arc::new(Mutex::new(State { + }; + original_state + .output_overrides + .insert(methods::GENERATE_PPTX.into(), b"original pptx bytes".to_vec()); + let mut replacement_state = State { generated_bytes: b"replacement bytes".to_vec(), ..State::default() - })); + }; + replacement_state + .output_overrides + .insert(methods::GENERATE_PPTX.into(), b"replacement pptx bytes".to_vec()); + let original = Arc::new(Mutex::new(original_state)); + let replacement = Arc::new(Mutex::new(replacement_state)); for (connection, state) in [ (&original_connection, original.clone()), (&replacement_connection, replacement.clone()), @@ -549,7 +557,7 @@ async fn held_docx_uses_the_peer_that_owns_its_generation() { .is_ok_and(|bytes| bytes == b"original bytes"); let replacement_bytes = replacement_control .as_ref() - .is_ok_and(|bytes| bytes == b"replacement bytes"); + .is_ok_and(|bytes| bytes == b"replacement pptx bytes"); let original = fixture.original.lock().expect("original state lock"); let replacement = fixture.replacement.lock().expect("replacement state lock"); let original_counts = ( From dc45b7e31b11c238c9802eee1016b0e36077ff34 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 13:41:25 +0530 Subject: [PATCH 34/50] test(recording_broker): correct expected bytes in peer handover tests The pptx handover tests asserted against the wrong byte strings, so the original and replacement expectations were swapped or mismatched. The assertions now compare against the bytes each peer actually returns. Auto-committed-on: macbook --- .../tinydocs-client/tests/recording_broker.rs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index f7f14f1..4c1368a 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -480,16 +480,18 @@ async fn peer_handover_fixture() -> PeerHandoverFixture { handover_to: Some(replacement_connection.clone()), ..State::default() }; - original_state - .output_overrides - .insert(methods::GENERATE_PPTX.into(), b"original pptx bytes".to_vec()); + original_state.output_overrides.insert( + methods::GENERATE_PPTX.into(), + b"original pptx bytes".to_vec(), + ); let mut replacement_state = State { generated_bytes: b"replacement bytes".to_vec(), ..State::default() }; - replacement_state - .output_overrides - .insert(methods::GENERATE_PPTX.into(), b"replacement pptx bytes".to_vec()); + replacement_state.output_overrides.insert( + methods::GENERATE_PPTX.into(), + b"replacement pptx bytes".to_vec(), + ); let original = Arc::new(Mutex::new(original_state)); let replacement = Arc::new(Mutex::new(replacement_state)); for (connection, state) in [ @@ -557,7 +559,7 @@ async fn held_docx_uses_the_peer_that_owns_its_generation() { .is_ok_and(|bytes| bytes == b"original bytes"); let replacement_bytes = replacement_control .as_ref() - .is_ok_and(|bytes| bytes == b"replacement pptx bytes"); + .is_ok_and(|bytes| bytes == b"replacement bytes"); let original = fixture.original.lock().expect("original state lock"); let replacement = fixture.replacement.lock().expect("replacement state lock"); let original_counts = ( @@ -618,10 +620,10 @@ async fn held_pptx_upload_and_output_use_the_peer_that_owns_generation() { let original_bytes = result .as_ref() - .is_ok_and(|bytes| bytes == b"original bytes"); + .is_ok_and(|bytes| bytes == b"original pptx bytes"); let replacement_bytes = replacement_control .as_ref() - .is_ok_and(|bytes| bytes == b"replacement bytes"); + .is_ok_and(|bytes| bytes == b"replacement pptx bytes"); let original = fixture.original.lock().expect("original state lock"); let replacement = fixture.replacement.lock().expect("replacement state lock"); let original_counts = ( From 6524c896ef4109a19f8ff5ca68b47c9066794241 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:48:06 +0530 Subject: [PATCH 35/50] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to the latest upstream commit. Auto-committed-on: macbook --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index b6cc32b..cba2de0 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit b6cc32b309065d9f27cabe9c50eba137c423e2f6 +Subproject commit cba2de0e573f99fe4c7e45ba14e2d6b5a2a7757f From 638408f63f27337b1494423e1be1c240e3c8b87b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:48:22 +0530 Subject: [PATCH 36/50] refactor(client): pin module binding for the duration of each call Each operation now binds the module once and reuses that pinned client for both the method call and the output object client, instead of resolving the object client separately. This keeps the call and its output transfer on the same module instance. Auto-committed-on: macbook --- crates/tinydocs-client/src/client.rs | 65 +++++++++++++++------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/crates/tinydocs-client/src/client.rs b/crates/tinydocs-client/src/client.rs index 3a4cc93..bec0f29 100644 --- a/crates/tinydocs-client/src/client.rs +++ b/crates/tinydocs-client/src/client.rs @@ -5,8 +5,11 @@ use std::fmt::{Debug, Formatter}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use sha2::{Digest, Sha256}; -use tinybus::module::{ModuleCallError, ModuleCallFailure, ModuleClient, ModuleObjectClient}; -use tinydocs_bus::names::{OBJECT_PATH, methods}; +use tinybus::module::{ + ModuleCallError, ModuleCallFailure, ModuleClient, PinnedModuleClient, + PinnedModuleObjectClient, +}; +use tinydocs_bus::names::methods; use tinydocs_bus::spec::presentation::MAX_IMAGE_BYTES; use tinydocs_bus::{ DocumentFormat, DocumentSpec, ExtractDocumentSpec, ExtractedDocument, ImageFacts, OutputRef, @@ -52,10 +55,10 @@ impl TinyDocsClient { /// Returns [`DocumentCallError`] when `TinyDocs` is unavailable, rejects the /// specification, or fails to generate or transfer the output. pub async fn generate_docx(&self, spec: &DocumentSpec) -> Result, DocumentCallError> { - let output = self.output_client().await?; - let handle: OutputRef = self - .module - .call_detailed(MODULE_ID, methods::GENERATE_DOCX, (spec,)) + let module = self.bind().await?; + let output = self.output_client(&module)?; + let handle: OutputRef = module + .call_detailed(methods::GENERATE_DOCX, (spec,)) .await .map_err(classify_failure)?; collect(&output, handle).await @@ -76,20 +79,19 @@ impl TinyDocsClient { deck: &WirePresentationSpec, images: &[u8], ) -> Result, DocumentCallError> { - let output = self.output_client().await?; + let module = self.bind().await?; + let output = self.output_client(&module)?; let handle: OutputRef = if images.is_empty() { - self.module + module .call_detailed( - MODULE_ID, methods::GENERATE_PPTX, (deck, Option::::None), ) .await .map_err(classify_failure)? } else { - self.module + module .call_with_stream_detailed( - MODULE_ID, methods::GENERATE_PPTX, |stream| serde_json::json!([deck, stream]), images, @@ -109,11 +111,10 @@ impl TinyDocsClient { /// Returns [`DocumentCallError`] when extraction, output transfer, or UTF-8 /// decoding fails. pub async fn extract_text(&self, document: &[u8]) -> Result { - let output = self.output_client().await?; - let handle: OutputRef = self - .module + let module = self.bind().await?; + let output = self.output_client(&module)?; + let handle: OutputRef = module .call_with_stream_detailed( - MODULE_ID, methods::EXTRACT_TEXT, |stream| serde_json::json!([stream]), document, @@ -158,11 +159,10 @@ impl TinyDocsClient { format: DocumentFormat, document: &[u8], ) -> Result { - let output = self.output_client().await?; - let handle: OutputRef = self - .module + let module = self.bind().await?; + let output = self.output_client(&module)?; + let handle: OutputRef = module .call_with_stream_detailed( - MODULE_ID, methods::CONVERT_MARKDOWN, |stream| serde_json::json!([format, stream]), document, @@ -210,11 +210,10 @@ impl TinyDocsClient { document: &[u8], spec: &RenderPdfSpec, ) -> Result)>, DocumentCallError> { - let output = self.output_client().await?; - let rendered: RenderedPdf = self - .module + let module = self.bind().await?; + let output = self.output_client(&module)?; + let rendered: RenderedPdf = module .call_with_stream_detailed( - MODULE_ID, methods::RENDER_PDF, |stream| serde_json::json!([spec, stream]), document, @@ -253,17 +252,23 @@ impl TinyDocsClient { } } - async fn output_client(&self) -> Result { - self.module - .object_client(MODULE_ID, OBJECT_PATH) - .await + async fn bind(&self) -> Result { + self.module.bind(MODULE_ID).await.map_err(classify_failure) + } + + fn output_client( + &self, + module: &PinnedModuleClient, + ) -> Result { + module + .object_client(tinydocs_bus::names::OBJECT_PATH) .map_err(classify_sdk_error) } } /// Releases retained module outputs when the collection future is cancelled. struct OutputReleaseGuard { - output: ModuleObjectClient, + output: PinnedModuleObjectClient, ids: Vec, } @@ -294,7 +299,7 @@ impl Drop for OutputReleaseGuard { } async fn collect( - output: &ModuleObjectClient, + output: &PinnedModuleObjectClient, handle: OutputRef, ) -> Result, DocumentCallError> { let mut release = OutputReleaseGuard { @@ -314,7 +319,7 @@ async fn collect( } async fn read_all( - output: &ModuleObjectClient, + output: &PinnedModuleObjectClient, handle: &OutputRef, ) -> Result, DocumentCallError> { if handle.total_bytes > MAX_DOCUMENT_BYTES { From fce0500c7eb95e2276e10deed4caed29fbbd186a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:48:52 +0530 Subject: [PATCH 37/50] chore(deps): bump tinydocs to 0.2.5 and tinybus to 0.3.2 Advance the workspace version to 0.2.5 and update the internal tinydocs-bus dependency to match, while moving the client and module crates onto tinybus 0.3.2. The recording broker test fixture now records the new release version and tag URL. Auto-committed-on: macbook --- Cargo.toml | 4 ++-- crates/tinydocs-client/Cargo.toml | 4 ++-- crates/tinydocs-client/tests/recording_broker.rs | 4 ++-- crates/tinydocs-module/Cargo.toml | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7ff2641..f86d02d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,12 +29,12 @@ resolver = "3" [workspace.package] # Releases update this sole workspace version before packaging every crate. -version = "0.2.4" +version = "0.2.5" [dependencies] # The dependency-light TinyBus contract owns payload types and errors. Re-export # it so library and bus callers use the exact same values. -tinydocs-bus = { version = "0.2.4", path = "crates/tinydocs-bus" } +tinydocs-bus = { version = "0.2.5", path = "crates/tinydocs-bus" } # OOXML `.docx` synthesis. Optional: exclusive to the `docx` feature so a host # that only needs extraction does not pull the writer stack. docx-rs = { version = "0.4.20", optional = true } diff --git a/crates/tinydocs-client/Cargo.toml b/crates/tinydocs-client/Cargo.toml index a1169f9..6c9739c 100644 --- a/crates/tinydocs-client/Cargo.toml +++ b/crates/tinydocs-client/Cargo.toml @@ -13,9 +13,9 @@ categories = ["api-bindings"] [dependencies] # Module loading and typed failure reporting stay host-owned; this is the # published host-facing SDK without its native loader. -tinybus = { version = "0.2.2", default-features = false } +tinybus = { version = "0.3.2", default-features = false } # Shared wire names and payload DTOs. -tinydocs-bus = { version = "0.2.4", path = "../tinydocs-bus" } +tinydocs-bus = { version = "0.2.5", path = "../tinydocs-bus" } # The held-output protocol uses base64 chunks and verifies their SHA-256 digest. base64 = "0.22" sha2 = "0.10" diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 4c1368a..45fbc3c 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -36,8 +36,8 @@ static RECORD: ReleaseRecord = ReleaseRecord { id: MODULE_ID, bus_name: BUS_NAME, object_path: OBJECT_PATH, - version: "0.2.4", - release_url: "https://github.com/tinyhumansai/tinydocs/releases/tag/v0.2.4", + version: "0.2.5", + release_url: "https://github.com/tinyhumansai/tinydocs/releases/tag/v0.2.5", assets: &[], }; diff --git a/crates/tinydocs-module/Cargo.toml b/crates/tinydocs-module/Cargo.toml index d84e1cb..233fc2e 100644 --- a/crates/tinydocs-module/Cargo.toml +++ b/crates/tinydocs-module/Cargo.toml @@ -21,9 +21,9 @@ tinydocs = { path = "../..", default-features = false, features = ["docx", "pptx # The transport-free vocabulary shared with every TinyBus host. tinydocs-bus = { path = "../tinydocs-bus" } # TinyBus provides the typed service interface and dynamic module host ABI. -tinybus = { version = "0.2.2", default-features = false, features = ["macros", "modules"] } +tinybus = { version = "0.3.2", default-features = false, features = ["macros", "modules"] } # The module-side SDK owns its runtime and exports the stable C entrypoints. -tinybus-module = "0.2.2" +tinybus-module = "0.3.2" # Module methods move CPU-bound document synthesis onto Tokio's blocking pool. tokio = { version = "1", features = ["macros", "rt-multi-thread"] } # Bus frames are JSON, where a byte array costs ~3.5 bytes per byte. Chunks are @@ -54,4 +54,4 @@ base64 = "0.23" serde_json = "1" zip = { version = "8", default-features = false, features = ["deflate"] } # Enable TinyBus loader helpers only in test builds, not downstream production builds. -tinybus = { version = "0.2.2", default-features = false, features = ["test-support"] } +tinybus = { version = "0.3.2", default-features = false, features = ["test-support"] } From ce05a680962f212f95666974e297ff24f8cc0880 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:49:06 +0530 Subject: [PATCH 38/50] test(recording-broker): cover alias handover for text and markdown methods The recording broker now triggers a peer handover after extract_text and convert_markdown, and the handover fixture seeds distinct original and replacement outputs for both methods so tests can verify which peer served the result. Auto-committed-on: macbook --- .../tinydocs-client/tests/recording_broker.rs | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 45fbc3c..43b7b70 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -243,9 +243,9 @@ impl RecordingService { } methods::EXTRACT_TEXT => { self.read_stream_arg(&args, 0, member).await?; - Ok(serde_json::to_value( - self.hold_for(member, b"extracted text"), - )?) + let output = self.hold_for(member, b"extracted text"); + self.handover_alias().await?; + Ok(serde_json::to_value(output)?) } methods::EXTRACT_DOCUMENT => { self.read_stream_arg(&args, 1, member).await?; @@ -263,9 +263,9 @@ impl RecordingService { } methods::CONVERT_MARKDOWN => { self.read_stream_arg(&args, 1, member).await?; - Ok(serde_json::to_value( - self.hold_for(member, b"# converted\n"), - )?) + let output = self.hold_for(member, b"# converted\n"); + self.handover_alias().await?; + Ok(serde_json::to_value(output)?) } methods::INSPECT_IMAGE => { self.read_stream_arg(&args, 0, member).await?; @@ -275,6 +275,7 @@ impl RecordingService { self.read_stream_arg(&args, 1, member).await?; let first = self.hold(b"page one".to_vec()); let second = self.hold(b"page two".to_vec()); + self.handover_alias().await?; Ok(json!({ "page_count": 3, "pages": [ @@ -492,6 +493,22 @@ async fn peer_handover_fixture() -> PeerHandoverFixture { methods::GENERATE_PPTX.into(), b"replacement pptx bytes".to_vec(), ); + original_state.output_overrides.insert( + methods::EXTRACT_TEXT.into(), + b"original text".to_vec(), + ); + replacement_state.output_overrides.insert( + methods::EXTRACT_TEXT.into(), + b"replacement text".to_vec(), + ); + original_state.output_overrides.insert( + methods::CONVERT_MARKDOWN.into(), + b"original markdown\n".to_vec(), + ); + replacement_state.output_overrides.insert( + methods::CONVERT_MARKDOWN.into(), + b"replacement markdown\n".to_vec(), + ); let original = Arc::new(Mutex::new(original_state)); let replacement = Arc::new(Mutex::new(replacement_state)); for (connection, state) in [ From 87001d1f11a74ce6efd9fee96cbbb2e0fd2d21ca Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:49:43 +0530 Subject: [PATCH 39/50] test(tinydocs-client): cover held outputs across peer handover Add tests asserting that streamed text and multi-page render outputs stay bound to their producing peer after a module alias handover, and update the README to describe per-operation pinning. Auto-committed-on: macbook --- crates/tinydocs-client/README.md | 14 ++- crates/tinydocs-client/src/client.rs | 3 +- .../tinydocs-client/tests/recording_broker.rs | 108 ++++++++++++++++-- 3 files changed, 109 insertions(+), 16 deletions(-) diff --git a/crates/tinydocs-client/README.md b/crates/tinydocs-client/README.md index 82afe77..efc173f 100644 --- a/crates/tinydocs-client/README.md +++ b/crates/tinydocs-client/README.md @@ -5,12 +5,14 @@ module. It translates contract methods and held outputs into typed Rust calls. It does not load modules, decide readiness, configure reporting, or choose deadlines; those choices belong to the embedding host. -Ordinary typed calls use `ModuleClient::call_detailed`. Upload operations use -`ModuleClient::call_with_stream_detailed`, which keeps readiness, registry -validation, timeouts, and failure reporting inside the SDK. Held-output reads -use `ModuleObjectClient::call_detailed` so their wire errors retain the same -contract distinctions. The host continues to own module loading and transport -lifecycle. +Each operation that produces a held output binds an ordinary +`PinnedModuleClient` for that operation. Its producer call, optional streamed +upload, output reads, releases, and cancellation cleanup stay with the same +serving peer even if the public module alias changes during the operation. +Binding does not claim native artifact proof. Stateless `ExtractDocument` and +`InspectImage` calls continue to resolve through the host's `ModuleClient` +without retaining a peer beyond the call. The host continues to own module +loading and transport lifecycle. `DocumentCallError` keeps unavailable, invalid-input, and execution failures distinct. Its `Debug` and `Display` forms expose only the category. A caller diff --git a/crates/tinydocs-client/src/client.rs b/crates/tinydocs-client/src/client.rs index bec0f29..5c032f5 100644 --- a/crates/tinydocs-client/src/client.rs +++ b/crates/tinydocs-client/src/client.rs @@ -6,8 +6,7 @@ use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use sha2::{Digest, Sha256}; use tinybus::module::{ - ModuleCallError, ModuleCallFailure, ModuleClient, PinnedModuleClient, - PinnedModuleObjectClient, + ModuleCallError, ModuleCallFailure, ModuleClient, PinnedModuleClient, PinnedModuleObjectClient, }; use tinydocs_bus::names::methods; use tinydocs_bus::spec::presentation::MAX_IMAGE_BYTES; diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 43b7b70..c3fb720 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -493,14 +493,12 @@ async fn peer_handover_fixture() -> PeerHandoverFixture { methods::GENERATE_PPTX.into(), b"replacement pptx bytes".to_vec(), ); - original_state.output_overrides.insert( - methods::EXTRACT_TEXT.into(), - b"original text".to_vec(), - ); - replacement_state.output_overrides.insert( - methods::EXTRACT_TEXT.into(), - b"replacement text".to_vec(), - ); + original_state + .output_overrides + .insert(methods::EXTRACT_TEXT.into(), b"original text".to_vec()); + replacement_state + .output_overrides + .insert(methods::EXTRACT_TEXT.into(), b"replacement text".to_vec()); original_state.output_overrides.insert( methods::CONVERT_MARKDOWN.into(), b"original markdown\n".to_vec(), @@ -698,6 +696,100 @@ async fn held_pptx_upload_and_output_use_the_peer_that_owns_generation() { ); } +fn output_call_counts(state: &State, producer: &str) -> (usize, usize, usize) { + ( + state + .calls + .iter() + .filter(|call| call.member == producer) + .count(), + state + .calls + .iter() + .filter(|call| call.member == methods::READ_OUTPUT) + .count(), + state + .calls + .iter() + .filter(|call| call.member == methods::RELEASE_OUTPUT) + .count(), + ) +} + +#[tokio::test] +async fn streamed_text_outputs_stay_with_their_producer_after_alias_handover() { + for (member, original_text, replacement_text) in [ + (methods::EXTRACT_TEXT, "original text", "replacement text"), + ( + methods::CONVERT_MARKDOWN, + "original markdown\n", + "replacement markdown\n", + ), + ] { + let fixture = peer_handover_fixture().await; + let first = if member == methods::EXTRACT_TEXT { + fixture.client.extract_text(b"PDF input").await + } else { + fixture + .client + .convert_markdown(DocumentFormat::Pdf, b"PDF input") + .await + }; + let replacement_control = if member == methods::EXTRACT_TEXT { + fixture.client.extract_text(b"PDF input").await + } else { + fixture + .client + .convert_markdown(DocumentFormat::Pdf, b"PDF input") + .await + }; + let original_bytes = first.as_ref().is_ok_and(|text| text == original_text); + let replacement_bytes = replacement_control + .as_ref() + .is_ok_and(|text| text == replacement_text); + let original = fixture.original.lock().expect("original state lock"); + let replacement = fixture.replacement.lock().expect("replacement state lock"); + let original_counts = output_call_counts(&original, member); + let replacement_counts = output_call_counts(&replacement, member); + assert!( + original_bytes + && replacement_bytes + && original_counts == (1, 1, 1) + && replacement_counts == (1, 1, 1), + "peer-hop {member}: original_bytes={original_bytes}, replacement_control_bytes={replacement_bytes}, original producer/read/release={original_counts:?}, replacement producer/read/release={replacement_counts:?}" + ); + } +} + +#[tokio::test] +async fn multi_page_render_outputs_stay_with_their_producer_after_alias_handover() { + let fixture = peer_handover_fixture().await; + let spec = RenderPdfSpec { + pages: vec![1, 3], + max_dimension: 256, + max_total_pixels: 65_536, + max_output_bytes: 1_048_576, + }; + let first = fixture.client.render_pdf(b"PDF input", &spec).await; + let replacement_control = fixture.client.render_pdf(b"PDF input", &spec).await; + let expected = vec![(1, b"page one".to_vec()), (3, b"page two".to_vec())]; + let original_pages = first.as_ref().is_ok_and(|pages| pages == &expected); + let replacement_pages = replacement_control + .as_ref() + .is_ok_and(|pages| pages == &expected); + let original = fixture.original.lock().expect("original state lock"); + let replacement = fixture.replacement.lock().expect("replacement state lock"); + let original_counts = output_call_counts(&original, methods::RENDER_PDF); + let replacement_counts = output_call_counts(&replacement, methods::RENDER_PDF); + assert!( + original_pages + && replacement_pages + && original_counts == (1, 2, 2) + && replacement_counts == (1, 2, 2), + "peer-hop RenderPdf: original_pages={original_pages}, replacement_control_pages={replacement_pages}, original render/read/release={original_counts:?}, replacement render/read/release={replacement_counts:?}" + ); +} + #[tokio::test] async fn typed_operations_use_contract_members_and_stream_document_bytes() { let fixture = test_fixture().await; From d50506de57462dbbd5438e3718f6195bc5a0be94 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:50:26 +0530 Subject: [PATCH 40/50] docs(tinydocs-client): clarify per-peer binding for held outputs Updated the client documentation to explain that operations returning held outputs bind the serving peer for their whole lifetime, while stateless one-call operations still go through ModuleClient. Added a test asserting that a detached output owner fails rather than falling through to an alias replacement. Auto-committed-on: macbook --- crates/tinydocs-client/src/client.rs | 7 ++-- .../tinydocs-client/tests/recording_broker.rs | 39 ++++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/tinydocs-client/src/client.rs b/crates/tinydocs-client/src/client.rs index 5c032f5..17a9e95 100644 --- a/crates/tinydocs-client/src/client.rs +++ b/crates/tinydocs-client/src/client.rs @@ -24,9 +24,10 @@ const INVALID_INPUT_WIRE_NAME: &str = "ai.tinyhumans.tinydocs.Error.InvalidInput /// Typed client for the `TinyDocs` module. /// -/// The embedding host supplies its process-owned [`ModuleClient`], which keeps -/// module loading, readiness, timeouts, and failure reporting outside this -/// crate. +/// The embedding host supplies its process-owned [`ModuleClient`]. Each +/// operation that returns held outputs binds the serving peer for that +/// operation, keeping its producer, output reads, releases, and cleanup on the +/// same peer. Stateless one-call operations continue through `ModuleClient`. #[derive(Clone)] pub struct TinyDocsClient { module: ModuleClient, diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index c3fb720..bfe4aec 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -110,6 +110,7 @@ struct State { release_started: Arc, release_delay: Option, handover_to: Option, + detach_on_read: bool, } #[derive(Clone, Copy, Default)] @@ -360,16 +361,20 @@ impl RecordingService { let output_id = args[0].as_str().expect("output id string").to_string(); let offset = args[1].as_u64().expect("output offset"); let requested = args[2].as_u64().expect("requested length"); - let (mode, block, started, resume, bytes) = { + let (mode, block, started, resume, detach, bytes) = { let state = self.state.lock().expect("fixture state lock"); ( state.read_mode, state.block_reads, state.read_started.clone(), state.resume_read.clone(), + state.detach_on_read, state.outputs.get(&output_id).cloned().unwrap_or_default(), ) }; + if detach { + self.connection.close().await?; + } if block { started.notify_one(); resume.notified().await; @@ -790,6 +795,38 @@ async fn multi_page_render_outputs_stay_with_their_producer_after_alias_handover ); } +#[tokio::test] +async fn detached_output_owner_does_not_fall_through_to_alias_replacement() { + let fixture = peer_handover_fixture().await; + fixture + .original + .lock() + .expect("original state lock") + .detach_on_read = true; + // The replacement deliberately has the same opaque resource id and bytes. + // A mistaken alias retry could therefore appear successful without the + // per-peer call counters below. + fixture + .replacement + .lock() + .expect("replacement state lock") + .outputs + .insert("output-1".into(), b"original bytes".to_vec()); + + let result = fixture.client.generate_docx(&document_spec()).await; + assert!(matches!(result, Err(DocumentCallError::Failed(_)))); + let original = fixture.original.lock().expect("original state lock"); + let replacement = fixture.replacement.lock().expect("replacement state lock"); + assert_eq!( + output_call_counts(&original, methods::GENERATE_DOCX), + (1, 1, 0) + ); + assert!( + replacement.calls.is_empty(), + "detached output work must not reach the alias replacement" + ); +} + #[tokio::test] async fn typed_operations_use_contract_members_and_stream_document_bytes() { let fixture = test_fixture().await; From 2338658b94fd9ec725e8fb1073e2c12e16b10caf Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 15:51:10 +0530 Subject: [PATCH 41/50] ci(release): bump reusable release workflow pin Updated the pinned commit for the shared module-release workflow to pick up the latest changes from the central repository. Auto-committed-on: macbook --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e817f7..b87d4bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,7 +61,7 @@ jobs: needs: [resolve, source-packages] permissions: contents: write - uses: tinyhumansai/.github/.github/workflows/module-release.yml@4adc1080fb495098e7af1aade82ed7e6e3c69bb7 + uses: tinyhumansai/.github/.github/workflows/module-release.yml@1e28317c435a9c714002199e65d18a1067fa5777 with: tag: ${{ needs.resolve.outputs.tag }} version: ${{ needs.resolve.outputs.version }} From 5f5e7f5c04175d3fed3e4745e64b7f8c8c680252 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:02:38 +0530 Subject: [PATCH 42/50] feat(ci): add reviewed-epic release mode to release workflow The release workflow now accepts a release_mode input that selects either the ordinary main path or a reviewed-epic admission path, with reviewed_pr required in the latter case. The resolve job validates both inputs and gates the existing packaging and module-release jobs to main mode while routing reviewed-epic releases to a dedicated reusable workflow. Auto-committed-on: macbook --- .github/workflows/release.yml | 50 +++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b87d4bd..2df1249 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,16 @@ on: description: Existing version tag (vX.Y.Z) committed through a reviewed PR type: string required: true + release_mode: + description: Use the ordinary main path or reviewed-epic release admission + type: choice + options: [main, reviewed-epic] + default: main + required: true + reviewed_pr: + description: Positive canonical PR number whose merge commit the tag names (reviewed-epic mode only) + type: number + required: false push: tags: ['v*'] permissions: {} @@ -18,11 +28,15 @@ jobs: outputs: tag: ${{ steps.tag.outputs.tag }} version: ${{ steps.tag.outputs.version }} + release_mode: ${{ steps.tag.outputs.release_mode }} + reviewed_pr: ${{ steps.tag.outputs.reviewed_pr }} steps: - id: tag shell: bash env: TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} + RELEASE_MODE: ${{ github.event_name == 'push' && 'main' || inputs.release_mode }} + REVIEWED_PR: ${{ inputs.reviewed_pr }} run: | set -euo pipefail [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]] || { @@ -31,8 +45,30 @@ jobs: } echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + [[ "$RELEASE_MODE" == main || "$RELEASE_MODE" == reviewed-epic ]] || { + echo "release_mode must be main or reviewed-epic" >&2 + exit 2 + } + if [[ "$RELEASE_MODE" == reviewed-epic ]]; then + [[ "$REVIEWED_PR" =~ ^[1-9][0-9]*$ ]] || { + echo "reviewed_pr must be a positive integer in reviewed-epic mode" >&2 + exit 2 + } + else + [[ -z "$REVIEWED_PR" || "$REVIEWED_PR" == 0 ]] || { + echo "reviewed_pr is only valid in reviewed-epic mode" >&2 + exit 2 + } + REVIEWED_PR="" + fi + { + echo "release_mode=$RELEASE_MODE" + # The reusable workflow input is numeric; zero is the ordinary-path sentinel. + echo "reviewed_pr=${REVIEWED_PR:-0}" + } >> "$GITHUB_OUTPUT" source-packages: needs: resolve + if: needs.resolve.outputs.release_mode == 'main' runs-on: ubuntu-latest permissions: contents: read @@ -59,6 +95,7 @@ jobs: if-no-files-found: error module-release: needs: [resolve, source-packages] + if: needs.resolve.outputs.release_mode == 'main' permissions: contents: write uses: tinyhumansai/.github/.github/workflows/module-release.yml@1e28317c435a9c714002199e65d18a1067fa5777 @@ -68,3 +105,16 @@ jobs: module-crate: tinydocs-module extra-packaged-files: '["docs/specs/tinybus-module.md"]' source-artifact: source-packages + module-reviewed-epic-release: + needs: resolve + if: needs.resolve.outputs.release_mode == 'reviewed-epic' + permissions: + contents: write + pull-requests: read + checks: read + actions: read + uses: tinyhumansai/.github/.github/workflows/module-reviewed-epic-release.yml@1e28317c435a9c714002199e65d18a1067fa5777 + with: + tag: ${{ needs.resolve.outputs.tag }} + version: ${{ needs.resolve.outputs.version }} + reviewed-pr: ${{ fromJSON(needs.resolve.outputs.reviewed_pr || '0') }} From e49eeb85d4404284cd765ed48c2c550815803c61 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:18:39 +0530 Subject: [PATCH 43/50] chore(deps): bump tinybus to 0.3.2 and tinydocs to 0.2.5 Updated the locked versions of the tinybus and tinydocs workspace crates to reflect their new releases. Auto-committed-on: macbook --- Cargo.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 858de8f..c73a9b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2006,7 +2006,7 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.2.2" +version = "0.3.2" dependencies = [ "async-trait", "flate2", @@ -2029,7 +2029,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.2.2" +version = "0.3.2" dependencies = [ "proc-macro2", "quote", @@ -2038,7 +2038,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.2.2" +version = "0.3.2" dependencies = [ "async-trait", "serde", @@ -2050,7 +2050,7 @@ dependencies = [ [[package]] name = "tinydocs" -version = "0.2.4" +version = "0.2.5" dependencies = [ "calamine", "docx-rs", @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "tinydocs-bus" -version = "0.2.4" +version = "0.2.5" dependencies = [ "serde", "serde_json", @@ -2075,7 +2075,7 @@ dependencies = [ [[package]] name = "tinydocs-client" -version = "0.2.4" +version = "0.2.5" dependencies = [ "async-trait", "base64 0.22.1", @@ -2089,7 +2089,7 @@ dependencies = [ [[package]] name = "tinydocs-module" -version = "0.2.4" +version = "0.2.5" dependencies = [ "base64 0.23.1", "getrandom 0.4.3", From 7cc8fadee16da88fb4419017d7f313731a92adad Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:19:34 +0530 Subject: [PATCH 44/50] refactor(tinydocs-client): make output_client an associated function The output_client helper no longer takes &self since it never used the client instance, so it is now called as Self::output_client from each method. Auto-committed-on: macbook --- crates/tinydocs-client/src/client.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/tinydocs-client/src/client.rs b/crates/tinydocs-client/src/client.rs index 17a9e95..d4b088c 100644 --- a/crates/tinydocs-client/src/client.rs +++ b/crates/tinydocs-client/src/client.rs @@ -56,7 +56,7 @@ impl TinyDocsClient { /// specification, or fails to generate or transfer the output. pub async fn generate_docx(&self, spec: &DocumentSpec) -> Result, DocumentCallError> { let module = self.bind().await?; - let output = self.output_client(&module)?; + let output = Self::output_client(&module)?; let handle: OutputRef = module .call_detailed(methods::GENERATE_DOCX, (spec,)) .await @@ -80,7 +80,7 @@ impl TinyDocsClient { images: &[u8], ) -> Result, DocumentCallError> { let module = self.bind().await?; - let output = self.output_client(&module)?; + let output = Self::output_client(&module)?; let handle: OutputRef = if images.is_empty() { module .call_detailed( @@ -112,7 +112,7 @@ impl TinyDocsClient { /// decoding fails. pub async fn extract_text(&self, document: &[u8]) -> Result { let module = self.bind().await?; - let output = self.output_client(&module)?; + let output = Self::output_client(&module)?; let handle: OutputRef = module .call_with_stream_detailed( methods::EXTRACT_TEXT, @@ -160,7 +160,7 @@ impl TinyDocsClient { document: &[u8], ) -> Result { let module = self.bind().await?; - let output = self.output_client(&module)?; + let output = Self::output_client(&module)?; let handle: OutputRef = module .call_with_stream_detailed( methods::CONVERT_MARKDOWN, @@ -211,7 +211,7 @@ impl TinyDocsClient { spec: &RenderPdfSpec, ) -> Result)>, DocumentCallError> { let module = self.bind().await?; - let output = self.output_client(&module)?; + let output = Self::output_client(&module)?; let rendered: RenderedPdf = module .call_with_stream_detailed( methods::RENDER_PDF, @@ -257,7 +257,6 @@ impl TinyDocsClient { } fn output_client( - &self, module: &PinnedModuleClient, ) -> Result { module From 84b38907afcae3ba23a60789743b03536c9c9b9e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:21:27 +0530 Subject: [PATCH 45/50] test(client): add timeout to peer handover fixture client The recording broker test fixture now builds its client with a two-second timeout so the handover test cannot hang indefinitely when a peer stops responding. Auto-committed-on: macbook --- crates/tinydocs-client/tests/recording_broker.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index bfe4aec..39ffdf2 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -542,7 +542,7 @@ async fn peer_handover_fixture() -> PeerHandoverFixture { enabled: true, }; PeerHandoverFixture { - client: TinyDocsClient::new(ModuleClient::new(access)), + client: TinyDocsClient::new(ModuleClient::new(access).with_timeout(Duration::from_secs(2))), original, replacement, _broker: broker, From 55b388df486d6ca48ffb797cd500407a1669ad40 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:43:41 +0530 Subject: [PATCH 46/50] test(tinydocs-client): assert recorded call count is zero Replaced the emptiness assertions in the recording broker test with explicit length checks against zero, so a failure reports the actual number of recorded calls instead of only that the list was non-empty. Auto-committed-on: macbook --- crates/tinydocs-client/tests/recording_broker.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 39ffdf2..9117f5d 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -917,13 +917,14 @@ async fn disabled_modules_are_unavailable_and_local_image_bounds_send_no_call() fixture.client.generate_docx(&document_spec()).await, Err(DocumentCallError::Unavailable(_)) )); - assert!( + assert_eq!( fixture .state .lock() .expect("fixture state lock") .calls - .is_empty() + .len(), + 0 ); let fixture = test_fixture().await; @@ -936,13 +937,14 @@ async fn disabled_modules_are_unavailable_and_local_image_bounds_send_no_call() fixture.client.inspect_image(&oversized).await, Err(DocumentCallError::InvalidInput(_)) )); - assert!( + assert_eq!( fixture .state .lock() .expect("fixture state lock") .calls - .is_empty() + .len(), + 0 ); } From 9af97337a981897cc820d731c5eaa095423b9864 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:54:57 +0530 Subject: [PATCH 47/50] test(client): cover incompatible contract classification Add tests asserting that an incompatible contract is classified as unavailable for both detailed and SDK errors, and that the peer's remote detail text never leaks into the message or debug output. Also verify that a module fault carrying a compatibility failure code is still classified as failed. Auto-committed-on: macbook --- crates/tinydocs-client/src/client_tests.rs | 28 ++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/tinydocs-client/src/client_tests.rs b/crates/tinydocs-client/src/client_tests.rs index 672e38a..408c2c4 100644 --- a/crates/tinydocs-client/src/client_tests.rs +++ b/crates/tinydocs-client/src/client_tests.rs @@ -55,11 +55,35 @@ fn unavailable_sdk_outcomes_stay_distinct_from_module_failures() { DocumentCallError::Unavailable(_) )); assert!(matches!( - classify_sdk_error(ModuleCallError::IncompatibleContract), + classify_sdk_error(ModuleCallError::TransportFailed), DocumentCallError::Failed(_) )); +} + +#[test] +fn incompatible_contract_is_unavailable_without_exposing_peer_text() { + let remote_detail = "untrusted compatibility detail"; + let detailed = classify_failure(failure( + ModuleCallError::IncompatibleContract, + "ai.tinyhumans.tinybus.Error.IncompatibleVersion", + remote_detail, + )); + let sdk = classify_sdk_error(ModuleCallError::IncompatibleContract); + let expected_message = "MODULE_CALL_REPORTED: module incompatible contract"; + + for error in [detailed, sdk] { + assert!(matches!(&error, DocumentCallError::Unavailable(_))); + assert_eq!(error.raw_message(), expected_message); + assert!(!format!("{error:?}").contains(remote_detail)); + assert!(!error.to_string().contains(remote_detail)); + } + assert!(matches!( - classify_sdk_error(ModuleCallError::TransportFailed), + classify_failure(failure( + ModuleCallError::ModuleFault, + "ai.tinyhumans.tinydocs.Error.CompatibilityFailure", + remote_detail, + )), DocumentCallError::Failed(_) )); } From 87bec2dbcbd27232055c3c912e1381963f88cb79 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 16:55:28 +0530 Subject: [PATCH 48/50] test(tinydocs-client): split incompatible contract test into per-path cases Extracted the shared unavailable-error assertions into a helper and split the combined test into separate cases for the detailed failure and SDK error paths. The module fault check was dropped from this test, leaving each case focused on a single classification path. Auto-committed-on: macbook --- crates/tinydocs-client/src/client_tests.rs | 37 +++++++++++----------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/crates/tinydocs-client/src/client_tests.rs b/crates/tinydocs-client/src/client_tests.rs index 408c2c4..e0aa6ae 100644 --- a/crates/tinydocs-client/src/client_tests.rs +++ b/crates/tinydocs-client/src/client_tests.rs @@ -60,32 +60,31 @@ fn unavailable_sdk_outcomes_stay_distinct_from_module_failures() { )); } +fn assert_unavailable_without_remote_detail(error: &DocumentCallError, remote_detail: &str) { + assert!(matches!(error, DocumentCallError::Unavailable(_))); + assert_eq!( + error.raw_message(), + "MODULE_CALL_REPORTED: module incompatible contract" + ); + assert!(!format!("{error:?}").contains(remote_detail)); + assert!(!error.to_string().contains(remote_detail)); +} + #[test] -fn incompatible_contract_is_unavailable_without_exposing_peer_text() { +fn detailed_incompatible_contract_maps_to_unavailable_without_exposing_peer_text() { let remote_detail = "untrusted compatibility detail"; - let detailed = classify_failure(failure( + let error = classify_failure(failure( ModuleCallError::IncompatibleContract, "ai.tinyhumans.tinybus.Error.IncompatibleVersion", remote_detail, )); - let sdk = classify_sdk_error(ModuleCallError::IncompatibleContract); - let expected_message = "MODULE_CALL_REPORTED: module incompatible contract"; - - for error in [detailed, sdk] { - assert!(matches!(&error, DocumentCallError::Unavailable(_))); - assert_eq!(error.raw_message(), expected_message); - assert!(!format!("{error:?}").contains(remote_detail)); - assert!(!error.to_string().contains(remote_detail)); - } + assert_unavailable_without_remote_detail(&error, remote_detail); +} - assert!(matches!( - classify_failure(failure( - ModuleCallError::ModuleFault, - "ai.tinyhumans.tinydocs.Error.CompatibilityFailure", - remote_detail, - )), - DocumentCallError::Failed(_) - )); +#[test] +fn sdk_incompatible_contract_maps_to_unavailable_without_exposing_peer_text() { + let error = classify_sdk_error(ModuleCallError::IncompatibleContract); + assert_unavailable_without_remote_detail(&error, "untrusted compatibility detail"); } #[test] From ce2d6171221eb247abe07521870ffef813f3e666 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 17:12:55 +0530 Subject: [PATCH 49/50] fix(client): treat incompatible contract as unavailable Failures caused by an incompatible module contract are now classified as unavailable rather than a generic failure, matching how an unavailable module is already handled so callers can react to both conditions the same way. Auto-committed-on: macbook --- crates/tinydocs-client/src/client.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/tinydocs-client/src/client.rs b/crates/tinydocs-client/src/client.rs index d4b088c..2468f06 100644 --- a/crates/tinydocs-client/src/client.rs +++ b/crates/tinydocs-client/src/client.rs @@ -371,6 +371,7 @@ fn classify_failure(failure: ModuleCallFailure) -> DocumentCallError { if failure.wire_name == INVALID_INPUT_WIRE_NAME { DocumentCallError::InvalidInput(failure.message) } else if failure.kind == ModuleCallError::Unavailable + || failure.kind == ModuleCallError::IncompatibleContract || failure.wire_name.contains("ModuleUnavailable") { DocumentCallError::Unavailable(failure.to_string()) @@ -380,7 +381,10 @@ fn classify_failure(failure: ModuleCallFailure) -> DocumentCallError { } fn classify_sdk_error(error: ModuleCallError) -> DocumentCallError { - if error == ModuleCallError::Unavailable { + if matches!( + error, + ModuleCallError::Unavailable | ModuleCallError::IncompatibleContract + ) { DocumentCallError::Unavailable(error.to_string()) } else { DocumentCallError::Failed(error.to_string()) From 26c533047edd80b7f53af02540523580e79dc482 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 17:13:30 +0530 Subject: [PATCH 50/50] test(tinydocs-client): expect unavailable for malformed typed stream replies The malformed typed stream reply test now asserts that a contract mismatch surfaces as DocumentCallError::Unavailable rather than Failed, and the test name was updated to reflect that expectation. Auto-committed-on: macbook --- crates/tinydocs-client/tests/recording_broker.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinydocs-client/tests/recording_broker.rs b/crates/tinydocs-client/tests/recording_broker.rs index 9117f5d..35ac4a8 100644 --- a/crates/tinydocs-client/tests/recording_broker.rs +++ b/crates/tinydocs-client/tests/recording_broker.rs @@ -1242,7 +1242,7 @@ async fn held_output_invalid_input_is_classified_and_still_released() { } #[tokio::test] -async fn malformed_typed_stream_reply_is_a_failed_call() { +async fn malformed_typed_stream_reply_is_unavailable_for_contract_mismatch() { let fixture = test_fixture().await; fixture .state @@ -1258,7 +1258,7 @@ async fn malformed_typed_stream_reply_is_a_failed_call() { .extract_document(b"PDF bytes", &ExtractDocumentSpec::new(DocumentFormat::Pdf)) .await .expect_err("a malformed typed reply is rejected"); - assert!(matches!(error, DocumentCallError::Failed(_))); + assert!(matches!(error, DocumentCallError::Unavailable(_))); } #[tokio::test]