Skip to content

fix(global): give the bus its own runtime so it outlives the caller's - #2

Open
senamakel wants to merge 6 commits into
mainfrom
bus-runtime
Open

fix(global): give the bus its own runtime so it outlives the caller's#2
senamakel wants to merge 6 commits into
mainfrom
bus-runtime

Conversation

@senamakel

@senamakel senamakel commented Aug 8, 2026

Copy link
Copy Markdown
Member

The bug

A process-wide bus outlives any one runtime — but its tasks did not. OnceBus::init_in_process spawned the broker and this peer's reader/writer loops with plain tokio::spawn, so they belonged to whichever runtime called init first.

That is fine in production, where the bus is initialised on the runtime that then runs for the life of the process. It is a trap under cargo test, where every #[tokio::test] builds and tears down its own: the first test to win the OnceLock leaves every later test holding a bus attached to a dead reactor. Publishes go nowhere, subscribers never wake, and nothing anywhere reports an error — the failure is silent, which is what makes it worth a fix rather than a note.

It is not hypothetical: it showed up in the OpenHuman migration as init() returning an error for every test after the first, because announcing the peer manifest was hitting a dead connection. That was mitigated downstream by making the announce advisory, which is correct on its own merits but treats the symptom — this is the cause.

Scope, stated honestly: I initially thought this also explained memory::sync_pipeline_e2e_tests::multi_batch_volume_builds_full_tree failing downstream. It does not. That test asserted on delivery after a single yield_now, which was enough when the bus was a channel with an inline handler and is not enough now that an event crosses two task hops; it is fixed in the OpenHuman PR by waiting for delivery rather than assuming it. This change is worth having regardless, but it is not what fixed that test.

The fix

OnceBus owns a runtime on a dedicated thread and enters it around the spawning, so the bus's lifetime matches the static's — which is what everything reaching for a global bus already assumes.

Current-thread, not multi-thread. Builder::new_multi_thread needs tokio's rt-multi-thread feature, which the slim --no-default-features build does not have. A bus that could not be a singleton in the slim build would defeat the point of the slim build, so the runtime is a current-thread one driven by its own OS thread. One thread is enough regardless: the broker routes messages and the connection loops shuffle frames, none of it CPU-bound.

Shutdown is drop-shaped, not explicit. BusRuntime holds a oneshot::Sender; dropping it ends the thread's block_on, which drops the runtime on that thread, where blocking is allowed. That is deliberate — an in-place Runtime drop panics inside an async context, so anyone holding an OnceBus in a local rather than a static would have hit it. (I hit it: it is why this shape exists rather than a Drop impl calling shutdown_background.)

Connection::handshake is split out of connect. An EnterGuard is !Send, so holding one across an await would make the future !Send for every caller. attach — where the spawning happens — is synchronous, so the guard is held across it and dropped before the awaited Hello.

init_with is deliberately unchanged: the caller supplied that connection and owns its tasks, so it is left where it was built.

Testing

a_bus_outlives_the_runtime_that_initialised_it reproduces the exact shape — initialise on one runtime, drop it, use the bus from another — and fails without this change.

All gates green under the CI toolchain (1.97.1, RUSTFLAGS=-D warnings): fmt, clippy, --all-features (129 tests), --no-default-features --features macros (126), the slim cargo check, and docs.

senamakel and others added 6 commits August 9, 2026 00:31
Checkpoint of work in progress, touching crates/tinybus/src/connection.rs.

Auto-committed-on: dragonfly
Co-authored-by: Medulla <medulla@tinyhumans.ai>
The bus's broker and connection loops now run on a single-threaded runtime owned by the singleton, rather than the caller's runtime. This ensures the bus outlives any one runtime, such as under `cargo test` where each test tears down its own, preventing silent failures from a dead reactor. The runtime is built lazily on first use and never dropped, with `init_with` remaining the exception as the caller owns that connection's tasks.

Auto-committed-on: dragonfly
Co-authored-by: Medulla <medulla@tinyhumans.ai>
Add two tests that verify the global OnceBus continues to work after the runtime that initialised it has been dropped, and that the shared runtime is created only once regardless of how many times init is called.

Auto-committed-on: dragonfly
Co-authored-by: Medulla <medulla@tinyhumans.ai>
Dropping a `Runtime` blocks until its tasks finish, which is forbidden in async contexts and would panic for anyone holding an `OnceBus` in a local rather than a `static`. The new `Drop` implementation calls `shutdown_background` to return immediately, making local use safe.

Auto-committed-on: dragonfly
Co-authored-by: Medulla <medulla@tinyhumans.ai>
The test helper was refactored to separate subscription from event collection, ensuring a subscription is established before publishing to avoid missing events. This makes the delivery test more reliable by guaranteeing the subscriber is ready before any publish occurs.

Auto-committed-on: dragonfly
Co-authored-by: Medulla <medulla@tinyhumans.ai>
The bus runtime now lives on its own OS thread using a current-thread tokio runtime instead of a multi-threaded one, because the slim no-default-features build lacks the rt-multi-thread feature. A dedicated thread also avoids panics when dropping the runtime from an async context, and one thread is sufficient for the broker and connection loops.

Auto-committed-on: dragonfly
Co-authored-by: Medulla <medulla@tinyhumans.ai>
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds a public connection handshake method and moves OnceBus broker and connection tasks to a dedicated, reusable Tokio runtime. Initialization paths enter the owned runtime before attaching and handshaking connections. Tests cover runtime persistence and reuse.

Changes

Owned runtime and connection initialization

Layer / File(s) Summary
Reusable connection handshake
crates/tinybus/src/connection.rs
Connection::handshake performs the broker Hello call, stores the assigned unique name, and is used by Connection::connect.
Owned runtime lifecycle
crates/tinybus/src/global.rs
OnceBus creates and reuses a dedicated current-thread Tokio runtime. Runtime startup failures are reported, and shutdown remains owned by OnceBus.
Runtime-backed initialization and validation
crates/tinybus/src/global.rs
init_in_process and init_over enter the owned runtime before connection setup and handshake. Tests verify event delivery after runtime drop and runtime reuse across initialization.
Estimated code review effort: 4 (Complex) ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OnceBus
  participant BusRuntime
  participant Connection
  participant Broker
  OnceBus->>BusRuntime: enter owned runtime
  BusRuntime->>Connection: attach transport
  Connection->>Broker: send Hello
  Broker-->>Connection: return unique name
  Connection-->>OnceBus: complete initialization
Loading

Poem

I’m a rabbit with a runtime to tend,
Handshakes now start where connections begin.
The broker names each link with care,
Events keep flowing through dedicated air.
Reused threads make the bus hop bright. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: giving the global bus its own runtime that outlives the caller's runtime.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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/connection.rs`:
- Around line 137-138: Update the repeated-handshake documentation near the
connection handshake method to accurately state that each repeated call sends
another Hello and replaces unique_name with the broker’s returned from_name; do
not describe it as requesting a second unique name.

In `@crates/tinybus/src/global.rs`:
- Around line 399-407: Update the test the_bus_runtime_is_built_once_and_shared
to invoke BUS.runtime() twice after initialization and assert that both calls
return the same runtime reference, rather than only checking BUS.runtime.get().
Keep the existing repeated init calls to establish initialization before
exercising runtime reuse.
- Around line 143-149: Serialize first-time bus initialization across
init_in_process, init_over, and init_with using one shared async initialization
guard. After acquiring the guard, recheck self.bus and return the existing bus
when initialization already completed; otherwise perform runtime, connection,
and broker creation while holding the guard. Add a concurrent initialization
test asserting that exactly one broker and connection are created.
- Around line 351-366: Update wait_for and an_in_process_bus_delivers_end_to_end
to use a channel for event delivery instead of polling the shared Vec<Tick>
state. Receive events asynchronously until n items are collected, wrapping the
receive operation in tokio::time::timeout and preserving the existing
deadline/failure behavior.
🪄 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

Run ID: 5879d524-4092-401b-a35a-a3ec50402f0b

📥 Commits

Reviewing files that changed from the base of the PR and between dfcdd2c and a126943.

📒 Files selected for processing (2)
  • crates/tinybus/src/connection.rs
  • crates/tinybus/src/global.rs

Comment on lines +137 to +138
/// Idempotent in the only sense that matters: calling it twice would
/// request a second unique name, so don't.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the repeated-handshake documentation.

The broker returns the attached peer's from_name for every Hello. A second call does not request a second unique name. State that repeated calls send Hello again and replace unique_name with the broker response, or reject repeated calls explicitly.

🤖 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 137 - 138, Update the
repeated-handshake documentation near the connection handshake method to
accurately state that each repeated call sends another Hello and replaces
unique_name with the broker’s returned from_name; do not describe it as
requesting a second unique name.

Comment on lines +143 to +149
/// The runtime this bus's tasks live on, started on first use.
fn runtime(&self) -> Result<&BusRuntime> {
if let Some(existing) = self.runtime.get() {
return Ok(existing);
}
let started = BusRuntime::start()?;
Ok(self.runtime.get_or_init(|| started))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Serialize first-time initialization before spawning tasks.

Two concurrent callers can both observe an unset bus. Each caller can then start a runtime, attach a connection, and, for init_in_process, spawn a broker before init_with selects one bus. The losing in-process broker remains on the owned runtime after its connection is dropped.

Use one async initialization guard for init_in_process, init_over, and init_with. Recheck self.bus after acquiring that guard. Add a concurrent initialization test that verifies only one broker and connection are created.

Also applies to: 160-176, 185-195

🤖 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/global.rs` around lines 143 - 149, Serialize first-time
bus initialization across init_in_process, init_over, and init_with using one
shared async initialization guard. After acquiring the guard, recheck self.bus
and return the existing bus when initialization already completed; otherwise
perform runtime, connection, and broker creation while holding the guard. Add a
concurrent initialization test asserting that exactly one broker and connection
are created.

Comment on lines +351 to +366
/// Wait for `n` events to land, or fail on a deadline.
async fn wait_for(seen: &Arc<Mutex<Vec<Tick>>>, n: usize) -> Vec<Tick> {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
{
let guard = seen.lock().await;
if guard.len() >= n {
return guard.clone();
}
}
assert!(
tokio::time::Instant::now() < deadline,
"the bus delivered nothing"
);
tokio::task::yield_now().await;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate file map ---'
ast-grep outline crates/tinybus/src/global.rs --lang rust 2>/dev/null || true

printf '%s\n' '--- target implementation ---'
sed -n '300,390p' crates/tinybus/src/global.rs

printf '%s\n' '--- event capture and wait_for references ---'
rg -n -C 4 'wait_for|seen|Tick|mpsc|channel|send\(' crates/tinybus/src/global.rs

Repository: tinyhumansai/tinybus

Length of output: 11661


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

path = Path("crates/tinybus/src/global.rs")
text = path.read_text()
match = re.search(
    r"async fn wait_for\(seen:.*?\n    \}\n",
    text,
    re.S,
)
if not match:
    raise SystemExit("wait_for not found")
body = match.group(0)
print("--- wait_for behavioral markers ---")
for marker in ("seen.lock().await", "tokio::task::yield_now().await", "tokio::time::timeout", "recv().await"):
    print(f"{marker}: {marker in body}")
print("--- wait_for call sites ---")
for number, line in enumerate(text.splitlines(), 1):
    if "wait_for(" in line:
        print(f"{number}: {line.strip()}")
print("--- direct polling loop markers ---")
for number, line in enumerate(text.splitlines(), 1):
    if "while seen.lock().await.is_empty()" in line or "tokio::task::yield_now().await" in line:
        print(f"{number}: {line.strip()}")
PY

printf '%s\n' '--- surrounding helper callers ---'
sed -n '335,410p' crates/tinybus/src/global.rs

Repository: tinyhumansai/tinybus

Length of output: 3729


Await delivery instead of polling shared state.

Replace the shared Vec polling in wait_for with a channel and receive events under tokio::time::timeout. Apply the same change to an_in_process_bus_delivers_end_to_end.

🤖 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/global.rs` around lines 351 - 366, Update wait_for and
an_in_process_bus_delivers_end_to_end to use a channel for event delivery
instead of polling the shared Vec<Tick> state. Receive events asynchronously
until n items are collected, wrapping the receive operation in
tokio::time::timeout and preserving the existing deadline/failure behavior.

Source: Coding guidelines

Comment on lines +399 to +407
fn the_bus_runtime_is_built_once_and_shared() {
static BUS: OnceBus<Tick> = OnceBus::new();
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
BUS.init_in_process(config()).await.unwrap();
BUS.init_in_process(config()).await.unwrap();
});
// One runtime, however many times `init` is called.
assert!(BUS.runtime.get().is_some());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the runtime-reuse test exercise runtime reuse.

The second init_in_process returns at line 160 because bus already exists. It does not call runtime(). The assertion only proves that a runtime exists.

Call BUS.runtime() twice and compare the returned references, or add test-only startup instrumentation.

🤖 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/global.rs` around lines 399 - 407, Update the test
the_bus_runtime_is_built_once_and_shared to invoke BUS.runtime() twice after
initialization and assert that both calls return the same runtime reference,
rather than only checking BUS.runtime.get(). Keep the existing repeated init
calls to establish initialization before exercising runtime reuse.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a126943ee5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +190 to +192
let connection = {
let _guard = self.runtime()?.handle.enter();
Connection::attach(transport.into())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Create runtime-bound transports on the owned runtime

When transport is a UnixTransport created by UnixTransport::connect on the caller's runtime, entering the owned runtime only for Connection::attach moves the tasks but does not re-register the underlying Tokio UnixStream with the new reactor. Once the caller's runtime is dropped, its reactor is shut down and this supposedly persistent connection stops doing I/O. Construct or register the socket on the owned runtime rather than accepting an already runtime-bound transport here.

Useful? React with 👍 / 👎.

Comment on lines +383 to +386
let (_handle, seen) = watch(&BUS);
BUS.publish(Tick(1));
assert_eq!(wait_for(&seen, 1).await, vec![Tick(1)]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep long-lived subscribers on the owned runtime

When a subscriber created on the initial runtime is retained or forget() is used for a process-lifetime startup subscription, OnceBus::subscribe still reaches subscriber::spawn, whose plain tokio::spawn binds the handler task to that caller runtime. Dropping the first runtime therefore silently kills the subscription even though the connection survives; this test masks the case by dropping _handle before dropping first and then creating a new subscription. Keep one handle across the boundary and spawn subscription tasks through the owned runtime.

AGENTS.md reference: AGENTS.md:L58-L60

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant