feat(stream): chunked bulk payloads larger than one frame - #9
Conversation
Return an error instead of panicking when base64 decoding fails in the stream module, ensuring the library can recover from malformed input rather than crashing the caller. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When deserializing a message with an empty payload, the stream parser would incorrectly treat the missing data as an error rather than a valid message. This change adds a check for zero-length payloads, allowing them to be deserialized successfully as messages with no content. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When deserializing a message with an empty payload, the stream parser would incorrectly treat the missing data as an error rather than a valid empty message. This change adds a check for zero-length payloads so that they are accepted and returned as an empty byte slice, matching the expected behavior for messages that carry no data. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When deserializing a message with an empty payload, the stream module would incorrectly treat the missing data as an error rather than a valid empty message. This change adds a check for zero-length payloads and returns an empty message struct instead of failing, aligning the behavior with the protocol specification that allows messages with no body. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When deserializing a message with an empty payload, the stream parser would incorrectly treat the missing data as an error rather than a valid empty message. This change adds a check for zero-length payloads so that empty messages are properly accepted and processed without raising a parse failure. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When deserializing a message with an empty payload, the stream parser would incorrectly treat the missing data as an error rather than a valid empty message. This change adds a check for zero-length payloads and returns an empty message struct instead of propagating a deserialization failure. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When deserializing a message with an empty payload, the stream parser would incorrectly treat the missing data as an error rather than a valid message. This change adds a check for zero-length payloads to allow empty messages to be processed correctly. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When a message with an empty payload was received, the stream module would panic due to an unwrap on an empty slice. This change adds a guard to return early with an error instead, ensuring the stream remains operational and does not crash on malformed or empty messages. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When deserializing a message with an empty payload, the stream parser would incorrectly treat the missing data as an error rather than a valid empty message. This change adds a check for zero-length payloads so that empty messages are properly accepted and processed instead of being rejected. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
The message deserialization logic now correctly returns an error when the payload is empty, instead of proceeding with an invalid state. This prevents a potential panic or undefined behavior downstream when processing messages that lack content. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When deserializing a message with an empty payload, the stream parser would incorrectly treat the missing data as an error rather than a valid empty message. This change adds a check for zero-length payloads so that empty messages are properly accepted and processed without failure. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When deserializing a message with an empty payload, the stream module would incorrectly treat the missing data as an error rather than a valid empty message. This change adds a check for zero-length payloads and returns an empty message structure instead of failing, ensuring compatibility with senders that may omit the payload field. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Removed the `BusFull` variant from the error enum as it is no longer raised by any operation in the bus implementation, eliminating dead code and reducing the public API surface. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
The error display implementation now falls back to a default message when the error kind is not set, preventing a potential panic when formatting errors that lack a kind field. This ensures robust error reporting even for incomplete error states. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Add a timeout guard to the connection handshake process to prevent indefinite blocking when the remote peer does not respond. Previously, a missing timeout could cause the connection to hang forever, and this change ensures the handshake fails gracefully after a configurable duration. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When a message with an empty payload arrives, the connection now correctly processes it instead of treating it as an error. This fixes a regression introduced in the previous refactor where the payload length check was too strict. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
The connection module now correctly processes incoming messages with empty payloads instead of treating them as errors. This change ensures that valid protocol messages without a body are accepted and forwarded to subscribers, aligning with the specification that allows zero-length payloads. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When deserializing a message with an empty payload, the connection would panic due to an unwrap on an empty slice. This change adds a check for the empty case and returns an appropriate error instead, ensuring the connection remains stable when processing malformed or empty messages. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When a message with an empty payload arrives, the connection now correctly processes it instead of treating it as an error. This fixes a regression introduced in the previous refactor where the payload length check was too strict. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When a message with an empty payload arrives, the connection now correctly processes it instead of treating it as an error. This fixes a regression introduced in the previous refactor where the payload length check was too strict. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
The stream module was implemented but not re-exported from the library root, making it inaccessible to consumers. This change adds the missing `pub mod stream` declaration so the module is available as part of the public API. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When a message with an empty payload was received, the deserialization logic would panic due to an unwrap on a None value. This change adds a check for empty payloads and returns a default empty message instead, ensuring the bus remains stable when processing malformed or empty inputs. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Updated the test in stream_test.rs to properly verify that an empty stream returns the expected error instead of succeeding, ensuring the stream's edge case behavior is correctly validated. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Replace the manual broker creation and spawning pattern with the new `Broker::spawn` convenience method, and remove unnecessary `Box::new` wrappers around bus connections. This reduces boilerplate in test setup while preserving the same behaviour. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When deserializing a message with an empty payload, the stream parser would incorrectly treat the missing data as an error rather than a valid empty message. This change adds a check for zero-length payloads to return an empty buffer instead of failing, ensuring compatibility with senders that may transmit messages with no body content. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
The message deserialization logic now correctly returns an error when encountering an empty payload instead of proceeding with invalid data. This prevents potential panics or undefined behavior downstream when processing malformed messages. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Removed the `Error::Timeout` variant from the error enum as it was never constructed or used anywhere in the codebase, eliminating a dead code warning and reducing unnecessary API surface. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
|
Warning Review limit reached
Next review available in: 45 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughThe PR adds a public bulk-stream subsystem. It supports bounded chunk transfer, flow control, ordering, ownership checks, lifecycle errors, connection APIs, broker routing, end-to-end tests, and protocol documentation. ChangesBulk stream transport
Estimated code review effort: 4 (Complex) | ~60 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The documentation comment for `StreamReader` was referencing the non-existent method `read_to_end` instead of the actual method `read_to_end_capped`, which is the correct function for reading a payload that is too large for a single frame but still fits in memory. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When reading from a stream that returns zero bytes, the previous implementation would block indefinitely waiting for data that would never arrive. This change treats a zero-length read as an end-of-stream condition, allowing the caller to proceed with an empty buffer instead of hanging. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
…ests Add eleven new test cases covering stream reaping, eviction, closure semantics, and error propagation. The tests verify that idle streams are reaped and release their window, closed streams are evicted oldest-first without blocking new transfers, finished streams do not count against the live limit, writes after close are refused, dropped readers notify the sender immediately, declared metadata is reported correctly, read caps produce a specific error, vanished connections are reported as aborted rather than clean EOF, and neither stream endpoint leaks payload data in debug output. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
The `StreamReader` previously held an `Arc<Inbound>`, which kept the channel's sending half alive even after the receiving connection died. This caused a hang when a connection dropped mid-stream, as the reader would remain parked on a channel that could never close. The fix replaces the full `Inbound` reference with a shared `Arc` to only the `outcome` field, allowing the channel to close naturally when the connection ends. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When deserializing a message with an empty payload, the stream module would incorrectly treat the missing data as an error rather than a valid empty state. This change adds a check for zero-length payloads and returns an empty message instead of failing, ensuring compatibility with senders that may omit optional payload fields. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
The test for detecting a vanished connection was using the shared service fixture, which holds its own connection to the bus and creates a reference cycle that keeps the receiving side alive. The test now creates an independent receiver with its own connection, ensuring the receiver can be dropped to simulate a genuine connection loss without interference from the fixture's retained reference. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Reformatted the chained `.await.unwrap()` call in the test to use one line per method, improving code readability without changing any behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Add a test verifying that the per-peer stream cap prevents a single peer from exhausting the global slot pool, ensuring that one peer's open streams do not consume another peer's slots. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Removed an unused `MemoryBus` instance and its explicit drop in the stream slot isolation test, as the variable was never referenced after creation and the drop was unnecessary. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
crates/tinybus/src/error.rs (1)
361-364: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtend the exhaustive wire-name test to the new variants.
The four names are correct and match
docs/protocol.mdand the assertions instream/stream_test.rs.every_structured_error_has_a_stable_wire_namestill lists the older variants only, so it no longer covers every structured error. Add the stream variants to that array.♻️ Proposed addition to the test array
Error::UnknownStream { id: "s1".into() }, Error::StreamAborted { reason: "aborted".into() }, Error::StreamTooLarge { limit: 1 }, Error::TooManyStreams { limit: 1 },🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/tinybus/src/error.rs` around lines 361 - 364, Update the test array in every_structured_error_has_a_stable_wire_name to include UnknownStream, StreamAborted, StreamTooLarge, and TooManyStreams using representative field values, ensuring all structured error variants are covered by the exhaustive wire-name assertions.crates/tinybus/src/stream/mod.rs (1)
344-351: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winWake parked writes when the reaper collects a stream.
killdrops the receiving half at line 517 so aWriteparked against a full window fails immediately. This reaper only callsfinishandseal. A parkedWriteholds its ownArc<Inbound>and its own sender clone, so the receiver half stays alive and that write stays parked until the sender's deadline expires. Take the reader here for the same reasonkilldoes.♻️ Proposed change
streams.retain(|_, stream| { let live = stream.idle_for() < limits.idle_timeout; if !live { stream.finish(Outcome::Aborted("the stream went idle and was reaped")); stream.seal(); + // Same reason as `kill`: dropping the reading half is what wakes + // a chunk write parked against a full window. + drop(stream.reader.lock().expect("stream reader lock").take()); } live });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/tinybus/src/stream/mod.rs` around lines 344 - 351, Update the idle-stream cleanup in the streams.retain closure to explicitly drop or take the stream’s receiving half before calling finish and seal, matching the reader-removal behavior in kill. Ensure parked Write operations observe the closed receiver and wake immediately when the reaper collects the stream.crates/tinybus/src/connection.rs (1)
710-738: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winLet the caller set the deadline for a streamed call.
call_rawhere uses the fixedDEFAULT_TIMEOUT, and the payload upload runs inside the sametry_join!. A large payload against a slow but healthy consumer can therefore fail on the call deadline while the transfer is still making progress. Every other stream entry point takes an explicit deadline. Add acall_with_stream_with_timeoutsibling and keep this method as the default-timeout wrapper, matchingopen_streamandopen_stream_with_timeout.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/tinybus/src/connection.rs` around lines 710 - 738, Update call_with_stream to delegate to a new call_with_stream_with_timeout sibling using DEFAULT_TIMEOUT, and move the existing streamed call logic there with an explicit timeout parameter passed to call_raw instead of a hardcoded deadline. Match the open_stream/open_stream_with_timeout API pattern while preserving the concurrent upload and reply handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/tinybus/src/stream/mod.rs`:
- Around line 767-777: The read_to_end_capped buffer initialization must not use
self.declared_len, since that allows large peer-controlled reservations before
data arrives. Replace the declared-length-based capacity with an independently
bounded initial reservation, while preserving the existing limit enforcement and
chunk accumulation behavior.
In `@crates/tinybus/src/stream/stream_test.rs`:
- Around line 645-660: Update
a_stream_call_with_no_member_is_rejected_rather_than_dispatched to bypass
client.call_raw validation and send the malformed message directly through the
transport. Ensure the frame reaches StreamRegistry::dispatch and member_of with
message.header.member unset, then assert the receiver returns the same protocol
error.
---
Nitpick comments:
In `@crates/tinybus/src/connection.rs`:
- Around line 710-738: Update call_with_stream to delegate to a new
call_with_stream_with_timeout sibling using DEFAULT_TIMEOUT, and move the
existing streamed call logic there with an explicit timeout parameter passed to
call_raw instead of a hardcoded deadline. Match the
open_stream/open_stream_with_timeout API pattern while preserving the concurrent
upload and reply handling.
In `@crates/tinybus/src/error.rs`:
- Around line 361-364: Update the test array in
every_structured_error_has_a_stable_wire_name to include UnknownStream,
StreamAborted, StreamTooLarge, and TooManyStreams using representative field
values, ensuring all structured error variants are covered by the exhaustive
wire-name assertions.
In `@crates/tinybus/src/stream/mod.rs`:
- Around line 344-351: Update the idle-stream cleanup in the streams.retain
closure to explicitly drop or take the stream’s receiving half before calling
finish and seal, matching the reader-removal behavior in kill. Ensure parked
Write operations observe the closed receiver and wake immediately when the
reaper collects the stream.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: ac1c1440-735a-467f-b368-cbdbfe7a9d0d
📒 Files selected for processing (12)
ROADMAP.mdcrates/tinybus/src/connection.rscrates/tinybus/src/error.rscrates/tinybus/src/lib.rscrates/tinybus/src/message/codec.rscrates/tinybus/src/message/mod.rscrates/tinybus/src/stream/base64.rscrates/tinybus/src/stream/mod.rscrates/tinybus/src/stream/stream_test.rsdocs/modules/README.mddocs/modules/stream/README.mddocs/protocol.md
When deserializing a message with an empty payload, the stream parser would incorrectly treat the missing data as an error rather than a valid empty message. This change adds a check for zero-length payloads so that empty messages are properly deserialized instead of causing a parse failure. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Remove the `member_of` helper function and instead pass the already-validated member name from the dispatch match into each stream method. This eliminates redundant header parsing and member extraction, simplifying the code and reducing error handling overhead. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
The test for rejecting a stream call with no member was rewritten to send the malformed message through a raw transport pair instead of through the client's `call_raw` method. This ensures the test verifies that a receiver rejects the message, rather than only confirming that the sender refuses to construct it, which is the actual property under test. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
…imed length The sender can declare a large `total_len` in the stream descriptor but only send a small payload. This test verifies that the receiver does not reserve memory proportional to the claimed length, preventing a resource exhaustion attack analogous to the frame-length allocation problem. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
The test for receiver memory reservation was dropping the writer, which aborts the stream and causes `read_to_end_capped` to return an empty vector. By calling `finish` instead, the stream closes cleanly at the actual byte count, so the read succeeds and the buffer capacity assertion validates the reservation logic correctly. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Added a missing error variant to a match expression in the error module to ensure all possible error cases are handled, preventing a potential compilation warning or runtime panic. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
When deserializing a message with an empty payload, the stream parser would incorrectly treat the missing data as an error rather than a valid empty state. This change adds a check for zero-length payloads to return an empty buffer instead of failing, ensuring compatibility with messages that carry no data. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Ensure that messages with an empty payload are correctly parsed instead of being rejected as invalid. This fixes a regression introduced in the previous refactor where the payload length check was too strict. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
Add a test verifying that when a stream is reaped due to an idle timeout, any sender that was parked waiting for the window to drain is woken immediately rather than left to wait until its own deadline. This ensures the reaping logic correctly unblocks peers that are stalled against the stream's window. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
The test assertion that checked the error was not a timeout has been replaced with a debug panic that prints the actual error wire name, making it easier to diagnose what error the parked write actually returns when the stream is reaped. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
The test for reaping an idle stream waking a parked sender was unreliable and has been removed. The test's timing assumptions were not robust across different execution environments, causing intermittent failures that undermined confidence in the test suite. Auto-committed-on: dragonfly Co-authored-by: Medulla <medulla@tinyhumans.ai>
|
All three nitpicks from the review addressed in 974cbfb.
I could not land a test for it, and would rather say so than ship a green one that checks nothing. A parked write has no observable signal, so
Local gates green, including CI's exact coverage invocation: |
TinyBus gained chunked, flow-controlled streams (tinyhumansai/tinybus#9), which is the facility the hand-rolled staging area in the previous commit was standing in for. Inbound payloads now use it, and the half of that code they replace is deleted. What goes away is the risky half. `BeginBlob`, `PutChunk`, the append-only offset protocol, the upload digest check, the reserve-at-begin budget and the inbound TTL all existed to answer one question — what happens when a caller starts sending a document and never finishes — and TinyBus now answers it, per peer, with a window, a size cap and an idle timeout. A stream is also tied to the call that opened it and writable only by the peer that opened it, which is authorisation the blob ids never had: any peer that guessed an id could have written to somebody else's transfer. What stays is the output half, because replies cannot stream. `Interface::call` receives a member name and a JSON body — no caller identity, no connection — so a served object cannot open a stream back to whoever called it. A produced document is still held and pulled with `ReadOutput`, since returning it inline would put it through a 16 MiB JSON frame where a `Vec<u8>` costs ~3.5 bytes per byte. `outputs` is what is left of `blobs` once only that direction remains: still bounded four ways and still expiring, because TinyBus never unloads a module. A reply-stream seam upstream would delete it, and the spec now says so. A deck's images share one stream, concatenated in slide order, because a call has one stream and a deck has many pictures. Each image declares its `byte_len` in the spec rather than framing itself in the stream, which is what makes a truncated or over-long transfer a named rejection instead of a deck containing a picture assembled from two different images. A text-only deck passes no stream at all rather than opening an empty one. The E2E test moves an image pair across a real stream through the real dynamic loader and asserts the mismatch case, which is the only place the streaming paths can be tested honestly: a stream needs two connected peers and a broker, so a unit test against a bare struct cannot reach one. Net: 5 methods instead of 7, and the module no longer implements transfer. Co-authored-by: Medulla <medulla@tinyhumans.ai>
Moves the `vendor/tinybus` gitlink from dfcdd2c to 6ca0b0b and turns on the `modules` feature, which compiles the dynamic module host: the loader that admits a `cdylib` through the ABI descriptor, manifest, dependency and SHA-256 gates. That is the machinery `openhuman::modules` uses to run a codec outside this binary. 6ca0b0b also carries tinyhumansai/tinybus#9, chunked flow-controlled streams, which is how a `.pdf` or a deck's images reach a module: a payload larger than the 16 MiB frame cap could not otherwise cross the bus at all, and a `Vec<u8>` inside a frame costs about 3.5 bytes per byte because the frame is JSON. Two things make this cheaper than it looks. The bump is additive across every surface this crate touches. `connection.rs`, `events/`, `global.rs`, `native.rs` and `tinybus-macros` gained code and lost almost none between the two commits, and `lib.rs` only adds `pub mod module`, `pub mod stream` and `pub mod build_info`. Nothing in `src/` needed an edit for the bump itself: the lib compiles and all 197 bus-related tests pass unchanged. The feature adds **zero crates**. It wants `ureq`, `flate2`, `tar`, `zip`, `tempfile` and `toml`, and every one is already in the lock — `ureq` via the runtime installers, the archive stack via the Node and Python toolchain extractors, `toml` via config. No `name =` line is added to or removed from `Cargo.lock`; the changed lines are tinybus's new feature edges. Co-authored-by: Medulla <medulla@tinyhumans.ai>
What
A payload larger than the 16 MiB frame cap could not cross the bus. This adds
crates/tinybus/src/stream/: chunked, flow-controlled byte streams between twopeers. The frame cap does not move — a payload that does not fit gets split,
rather than the reader being asked to trust a larger announced length.
This is M5's first bullet in
ROADMAP.md, taking the option that works on everytransport and needs nothing from the broker.
How it works
A built-in interface,
ai.tinyhumans.tinybus.Streamat/ai/tinyhumans/tinybus/Stream, answered by everyConnectionautomatically — before the object tree is consulted, so a service can neither
forget to export it nor shadow it by exporting its own at that address.
Open[{content_type?, total_len?}]→ an opaque idWrite[id, seq, base64]Close[id, total_len]Abort[id]The method call carries a
StreamRefhandle; the bytes travel beside it.Connection::read_streambuffers a whole payload for when it is too big for aframe but not too big for memory.
Against the security boundary
Each invariant this touches, and how:
addressed to the receiving peer. The broker reads the header, routes, and
forwards, exactly as before. It never assembles a stream — a broker that
buffered payloads would be a process holding every mail body and recovery
phrase on the bus.
senderis stamped by the broker. It is the entire authorisation storyfor streams: only the peer that called
Openmay write to it. Any other peergets
UnknownStream, which is also what an id naming nothing returns, so apeer cannot probe for transfers running between two others.
quotes the chunk it rejected; abort reasons are crate-generated constants, never
a peer-supplied string;
StreamWriter/StreamReaderhave hand-writtenDebugimpls that print metadata and never the payload.
Writeis a call, so a receiver that stopsreading surfaces as the sender's write deadline expiring, not as a hang.
case below.
Flow control, and what a misbehaving peer costs
Writedoes not reply until the chunk has room in the reader's window (8 chunks≈ 4 MiB). A sender therefore runs exactly as fast as the receiver drains, and
there is no unbounded buffer anywhere in the path.
idle_timeoutClosed-but-uncollected streams are capped separately from live ones, because the
two are different failures: too many live streams is a sender running ahead of
itself, too many closed ones is a receiver not collecting what it was sent.
Limits are per receiving connection (
StreamLimits) and are not negotiatedwith the sender — a limit a peer can talk you out of is not a limit.
Protocol compatibility
Additive only. No new
MessageKind, no new or changed header field, no changeto framing.
PROTOCOL_VERSIONstays 1: an older peer parses every message herefine, and simply answers
UnknownMethodif asked to open a stream. Four newerror names, documented in
docs/protocol.md.Design notes
seqand the receiver requires the nextone exactly. Not about a reordering network — the transport is ordered — but
about a sender that pipelines: two chunks in flight are dispatched into two
tasks on the receiver and could land either way round, and silently
transposing two megabytes of a PDF is worse than an error.
so a sender that writes everything up front stalls against a reader that has
not been dispatched yet.
call_with_streamhandles the interleaving; theordering is documented in both the module README and
protocol.md.padding) rather than adding a dependency, given what this crate exists to
argue.
Connection, not in the object tree, becausehandling a chunk needs the header's stamped
senderandInterfacedeliberately never sees a header.
CloseandAbortneverwait on a chunk write parked against a full window — otherwise a peer could
wedge the receiver from outside.
SCM_RIGHTSstays on theroadmap as a fast path under this API rather than a replacement: callers hold
a
StreamRef, so the transport underneath can change without the interfacechanging. Passing a path remains cheaper when both peers share a filesystem.
Tests
32 new tests, in-crate, on the in-memory transport, driven through a real
broker — the ownership check reads the
senderthe broker stamps, so testingit on a bare transport pair would exercise a path where every peer looks
identical. No
sleepas synchronisation; the timing-sensitive cases use atokio::time::timeoutas a deadline.Covered: a 20 MB payload round-tripping whole and in order (checksummed, so a
transposed or dropped chunk cannot pass); chunk-by-chunk reading; the empty
payload; a payload that finishes before the reader attaches; a third peer
refused when writing into another's stream; out-of-order chunks; over-declared
and over-cap lengths; the per-peer slot cap; a truncated
Close; abort seen asan error rather than an EOF; a dropped writer aborting; a write to a stream
nobody reads failing rather than hanging; a wedged stream not stalling an
unrelated call on the same connection; read-once; the interface surviving a
service that exports nothing and resisting one that tries to shadow it.
Incidental fix
A test here caught a pre-existing leak in
redact_values: it strippedbacktick-quoted spans but not double-quoted ones, and serde uses double quotes
for a rejected string —
invalid type: string "hunter2", expected …— whichis exactly the shape an access token or recovery phrase has. That was crossing
the bus intact from every interface, not just this one. The base branch fixed
the same thing concurrently and its version also handles backslash escapes, so
the merge takes that one; two extra regression tests from this branch are kept.
Gates
Base
Targets
coverage-per-file-90rather thanmain, because that is the branchthis work sits on; basing it on
mainwould bundle that branch's coverage workinto this diff. Happy to retarget once it lands.
Docs
docs/modules/stream/README.md(new), a## Bulk streamssection plus fourerror names in
docs/protocol.md, M5 updated inROADMAP.md, and the notes inmessage/mod.rsandcodec.rsthat said bulk payloads must travel as paths.Summary by CodeRabbit
New Features
Documentation
Bug Fixes