Skip to content

module: cache verified release artifacts and bound the loader's network waits - #17

Merged
M3gA-Mind merged 2 commits into
tinyhumansai:mainfrom
YellowSnnowmann:fix/release-cache-connect-timeout
Sep 3, 2026
Merged

M3gA-Mind merged 2 commits into
tinyhumansai:mainfrom
YellowSnnowmann:fix/release-cache-connect-timeout

Conversation

@YellowSnnowmann

Copy link
Copy Markdown
Contributor

Summary

  • ModuleHost::load_github_release_cached loads a pinned release through a directory the host names and that outlives the process: the archive, its extraction and the manifest digest stay on disk, and every later launch re-hashes the archive against the host's pin and maps the library without touching the network.
  • The HTTP client now carries resolve / connect / request / response budgets. ureq's default agent has none, so a connect to an address that drops SYNs waited the OS timeout (75 s on macOS, ~2 min on Linux) before the next address was tried.
  • Asset URLs are built from the tag (releases/download/<tag>/<asset>) instead of looked up through the unauthenticated REST API (60 req/h per address, shared behind NAT); the API remains the fallback for a release whose direct path answers 404.
  • Assets stream to disk instead of being held in memory (up to the 512 MiB cap).

Problem

Observed live in OpenHuman on 2026-09-03: every launch re-downloaded all five modules (no persistent cache — acquire extracts into a TempDir the host keeps alive), serialized, and one of the four Fastly anycast addresses behind release-assets.githubusercontent.com was black-holed from the user's network while the resolver listed it first. Each module cost ~78 s; the memory module landed four minutes after boot with every memory call and the chat turn waiting behind it. The leaked extractions added up to 3.7 GB in $TMPDIR.

Solution

  • module/cache.rs (new, pure, unit-tested): verified lookup (find_verified — archive digest vs pin or recorded manifest digest, exactly one platform module, extracted library vs the release's own modules.toml), sibling staging + one-rename commit, digest marker, URL building.
  • module/github.rs: shared agent with budgets, locate (direct manifest URL first, API fallback on 404), streaming download, acquire_cached. acquire keeps its behaviour and gains the same budgets.
  • module/host.rs: load_github_release_cached(&CachedRelease, config); attestation records the archive digest exactly as the uncached path does. load_file_pinned stays private; its doc names both callers.

A directory that fails verification is a cache miss, never a refusal, so a corrupted extraction re-downloads instead of failing every launch.

Validation

  • cargo fmt --all -- --check
  • cargo clippy --locked --all-targets --all-features -- -D warnings
  • cargo test --locked --all-features — 314 passed, 10 ignored
  • cargo check --locked --no-default-features and --no-default-features --features modules

cache.rs carries its own tests (warm hit, pin mismatch, marker fallback, sidecar agree/disagree/absent, staging commit and replacement, failed-commit cleanup, nested module, error redaction). github.rs is excluded from the per-file coverage gate as before; its pure helpers (manifest_digest, the cache-miss-with-downloads-off refusal, the verified-cache hit) are tested.

Public API

Additive: tinybus::module::CachedRelease and ModuleHost::load_github_release_cached. No existing signature changes.

Related

  • Host side: tinyhumansai/openhuman (wiring install_dir, per-module resolution, boot preload, bounded memory waits) — PR to follow, pinned to this branch.

Every load of a pinned release downloaded it again: `acquire` extracted into
a temporary directory that lived only as long as the process, so a host paid
one archive per module per launch and left the extraction behind whenever it
exited any way but a clean drop. The HTTP client had no timeouts either, so a
TCP connect to an address that drops SYNs waited for the operating system to
give up — 75 seconds on macOS — before the next address was tried. GitHub's
asset CDN publishes several addresses in a fixed resolver order, and one
unreachable address cost every download that full wait.

Add `ModuleHost::load_github_release_cached`: the same two-sided verification
into a directory the host names and that outlives the process. The archive,
its extraction and the manifest digest stay there; a later launch re-hashes
the archive against the host's pin, checks the extracted library against the
release's own `modules.toml`, and maps it without the network. Downloads are
staged beside the directory and committed with one rename, so a cache is
either complete or absent, and a directory that fails verification is a miss
rather than a refusal. The pure cache logic lives in `module/cache.rs` with
its own tests; the network path stays in `github.rs`.

Give the client resolve, connect, request and response budgets so a dead
address costs a share of ten seconds, stream assets to disk instead of
holding up to 512 MiB in memory, and build asset URLs from the tag rather
than looking them up through the unauthenticated REST API, whose sixty
requests per hour are shared by everyone behind one address. The API remains
the fallback for a release whose direct path answers 404.
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: d37fe939-2020-4bfc-98d0-8c534bd19d28

Warning

Your free Security trial is over. An organization admin can upgrade to Advanced for continuous pull request security review or dismiss this notice.


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

@YellowSnnowmann

Copy link
Copy Markdown
Contributor Author

Per-file coverage failed on this run, but the failing threshold is not in this diff.

Every file's line coverage is above 90% except tinybus-module/src/lib.rs, which sits at exactly 576/640 = 90.00% — a mathematically exact tie at the --fail-under-file-lines 90 boundary (full parsed table below). This diff does not touch crates/tinybus-module and makes no Cargo.toml/feature changes, so that crate's compiled surface and test suite are identical to main's. The same lane passed on main's own last run (92b817e) and on the four most recent tinybus PRs I checked (#15, #12, #10, #6). A file landing on an exact deterministic boundary, in a crate outside the diff, reads as timing-sensitive branch coverage in that crate's own async tests (module runtime retry/deadline code) tipping across the line depending on scheduler timing — not a regression here.

I don't have rerun rights on this repo (gh run rerun → "Must have admin rights"). Could a maintainer re-run the job? If it's a genuine flake it should land clear of the boundary; if it reproduces consistently, it's worth its own issue against tinybus-module's coverage rather than blocking this PR.

Full per-file line coverage (this run, sorted ascending)
 90.00%  tinybus-module/src/lib.rs  (576/640)   <- crate not touched by this diff
 90.26%  tinybus/src/module/transport.rs  (454/503)
 90.38%  tinybus/src/module/loader.rs  (141/156)
 90.71%  tinybus/src/native.rs  (332/366)
 91.84%  tinybus/src/message/codec.rs  (45/49)
 91.97%  tinybus/src/transport/memory.rs  (126/137)
 93.06%  tinybus/src/service/macro_test.rs  (134/144)
 93.69%  tinybus-macros/src/lib.rs  (193/206)
 93.84%  tinybus/src/broker.rs  (945/1007)
 93.87%  tinybus/src/module/cache.rs  (291/310)   <- new in this diff
 94.04%  tinybus/src/events/subscriber.rs  (142/151)
 94.37%  tinybus/src/secret.rs  (134/142)
 94.89%  tinybus/src/version.rs  (353/372)
 95.20%  tinybus/src/events/mod.rs  (119/125)
 95.43%  tinybus/src/router.rs  (459/481)
 96.48%  tinybus/src/name.rs  (247/256)
 96.93%  tinybus/src/message/mod.rs  (253/261)
 96.97%  tinybus/src/transport/unix.rs  (160/165)
 97.08%  tinybus/src/module/host_test.rs  (1099/1132)
 98.27%  tinybus/src/error.rs  (227/231)
 98.70%  tinybus/src/global.rs  (152/154)
 98.85%  tinybus/src/module/abi.rs  (86/87)
 99.08%  tinybus/src/stream/mod.rs  (430/434)
 99.18%  tinybus/src/stream/stream_test.rs  (843/850)
100.00%  (10 files at 100%, incl. tinybus/src/module/{resolve,manifest,hash}.rs)

TOTAL: 95.52% lines (9329 total, 418 missed).

@YellowSnnowmann
YellowSnnowmann marked this pull request as ready for review September 3, 2026 15:12

@tinysweeper tinysweeper 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.

tinysweeper found nothing blocking. Approving.

             $0.0187 · 172,695 in / 2,659 out · 16,298 cached (9%) · deepseek/deepseek-v4-flash, openrouter/openai/text-embedding-3-small, z-ai/glm-5.2 · 686 embedded
critique:    $0.0086 · 68,840 in  / 1,684 out · 8,788 cached (13%) · deepseek/deepseek-v4-flash, z-ai/glm-5.2
security:    $0.0061 · 57,424 in  / 398 out   · 7,510 cached (13%) · deepseek/deepseek-v4-flash, z-ai/glm-5.2
tests:       $0.0022 · 25,885 in  / 81 out    · 0 cached (0%)      · deepseek/deepseek-v4-flash
description: $0.0016 · 18,334 in  / 70 out    · 0 cached (0%)      · deepseek/deepseek-v4-flash

@tinysweeper

tinysweeper Bot commented Sep 3, 2026

Copy link
Copy Markdown

How this change flows

2 changed behaviours across 13 relationships. 6 surrounding behaviours are shown (60 graph nodes walked). 41 further behaviours left out to keep the diagram readable.

flowchart LR
  n0["extract<br/>changed"]:::changed
  n1["parse_release_url<br/>changed"]:::changed
  n2["acquire_cached"]:::impacted
  n3["Err"]:::impacted
  n4["refused"]:::impacted
  n5["acquire"]:::impacted
  n6["ModuleInfo"]:::impacted
  n7["register_lazy"]:::impacted
  n0 -->|calls| n3
  n0 -->|calls| n4
  n1 -->|calls| n3
  n1 -->|calls| n4
  n2 -->|calls| n0
  n2 -->|calls| n1
  n2 -->|calls| n3
  n2 -->|calls| n4
  n5 -->|calls| n0
  n5 -->|calls| n1
  n5 -->|calls| n4
  n7 -->|calls| n3
  n7 -->|uses| n6
  classDef changed fill:#0d4429,stroke:#238636,color:#e6edf3
  classDef impacted fill:#161b22,stroke:#6e7681,color:#c9d1d9
  classDef flagged fill:#5a1e02,stroke:#d93f0b,color:#ffffff
  classDef blocking fill:#67060c,stroke:#f85149,color:#ffffff
Loading

Green: changed behaviour. Grey: surrounding behaviour. Arrows name the call, use, implementation, or test relationship. Orange: has findings. Red: has a finding that blocks the merge.

tinysweeper 0.1.0

@tinysweeper tinysweeper Bot added the priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect. label Sep 3, 2026
`tinybus-module/src/lib.rs` sat at exactly 576/640 = 90.00% lines, the
boundary `--fail-under-file-lines 90` compares against. Line coverage also
differs by three lines between Linux and macOS, so the file could land on
either side of the gate depending on the runner — it went red on this PR
without the diff touching that crate.

The uncovered region was worth a test rather than a nudge: the argument
validation in `start_module_with_config` is the first code a `dlopen`ed module
runs, it is handed a raw pointer by the host, and every branch there stands
between a malformed descriptor and a dereference. None of it was exercised.

Cover all five refusals — a null vtable, one shorter than this ABI's, a config
length with no buffer behind it, a config past the 1 MiB cap, and bytes that
do not deserialize into the module's own config type. Each returns before a
runtime, a panic hook or a transport exists, so the test needs no host-state
guard and cannot perturb the process-global runtime capture the other startup
test relies on.

The file moves to 630/677 = 93.06%, which is far enough from the boundary that
the platform delta no longer decides it.
@YellowSnnowmann

Copy link
Copy Markdown
Contributor Author

Pushed b78bcc5, which should clear Per-file coverage — and for a better reason than nudging a number.

The file was at exactly 576/640 = 90.00%, the value --fail-under-file-lines 90 compares against, and its line coverage differs by three lines between Linux and macOS (90.00% on the CI runner, 90.47% locally). So the gate's verdict on that file was decided by which runner it landed on, which is why it went red here without this diff touching that crate.

The uncovered region turned out to be worth a real test rather than a nudge: the argument validation in start_module_with_config had no coverage at all. That is the first code a dlopened module runs, it is handed a raw pointer by the host, and each of those branches stands between a malformed descriptor and a dereference.

configured_startup_refuses_a_host_vtable_it_cannot_trust now covers all five refusals — a null vtable, one shorter than this ABI's, a config length with a null pointer behind it, a config past the 1 MiB cap, and bytes that do not deserialize into the module's own config type. Every branch returns before a runtime, a panic hook or a transport exists, so it needs no host-state guard and cannot perturb the process-global runtime capture configured_startup_builds_a_runtime_announces_ready_and_shuts_down depends on.

90.00% → 93.06% (630/677), verified locally with the workflow's own command:

tinybus-module/src/lib.rs   1025  71  93.07%   87  16  81.61%   677  47  93.06%
TOTAL                      15288 659  95.69% 1226  97  92.09%  9360 397  95.76%

cargo llvm-cov report --fail-under-file-lines 90 exits 0. Three points of headroom is enough that the platform delta no longer decides it.

Full suite, fmt and clippy all green (cargo test --locked --all-features, cargo clippy --locked --all-targets --all-features -- -D warnings).

@M3gA-Mind
M3gA-Mind merged commit d5ce18d into tinyhumansai:main Sep 3, 2026
14 checks passed
YellowSnnowmann added a commit to tinyhumansai/openhuman that referenced this pull request Sep 3, 2026
Every launch downloaded every native module again. `installed_artifact`
looked for an extraction under `install_dir` that nothing ever wrote, because
tinybus extracted each release into a temporary directory and kept it alive
for the process, so the branch was dead and the download path ran on every
boot — five archives on the desktop, one at a time behind a single
process-wide lock. On a network where one of the CDN's anycast addresses
dropped packets, each download waited the OS SYN timeout before the client
tried the next address: the memory module landed four minutes after boot, and
every memory RPC ran into the UI's 30 s deadline while the chat turn's
context assembly waited behind it (the 2026-09-03 "memory not working, chat
takes seven minutes" incident).

Load releases through tinybus's new `load_github_release_cached` into
`install_dir/<id>/<version>/<host_key>/`. The first launch downloads, verifies
against the registry pin and commits with one rename; every later launch
re-hashes the archive on disk and maps the library without the network.
Versions no longer pinned are pruned after a successful load.

Replace the global resolve gate with a slot per module (`modules::resolution`):
the first caller runs the load as a process-lifetime task on the module
runtime, everyone else waits on a watch channel, and a caller that gives up
cancels nothing. `ensure_loaded_within` bounds the wait, `modules.list`
reports `Loading`, and `ModuleMemoryProvider` answers reads with the
retryable `MemoryError::Unavailable` after an 8 s grace instead of hanging —
writes still wait it out, since a dropped write is lost work — and reports
`Degraded`, never `Down`, while loading, so a cold launch cannot trip the
fallback rebind.

Wire the eager load policy at boot: `load_declared_modules` had no product
caller since it landed, so TinyMemory's download started on the first memory
call, on the request path. `start_bootstrap_jobs` now spawns it behind
`ServiceSet::memory_queue`, installing the memory host callbacks first. Bound
the one memory await left on the chat turn's critical path — the situational
preference recall — to three seconds.

Pin vendor/tinybus to tinyhumansai/tinybus#17 (8b6793a), which carries the
cache, the client budgets and the direct asset URLs.
senamakel pushed a commit to HDZTony/openhuman that referenced this pull request Sep 11, 2026
…very launch downloaded every native module again. `installed_artifact`\nlooked for an extraction under `install_dir` that nothing ever wrote, because\ntinybus extracted each release into a temporary directory and kept it alive\nfor the process, so the branch was dead and the download path ran on every\nboot — five archives on the desktop, one at a time behind a single\nprocess-wide lock. On a network where one of the CDN's anycast addresses\ndropped packets, each download waited the OS SYN timeout before the client\ntried the next address: the memory module landed four minutes after boot, and\nevery memory RPC ran into the UI's 30 s deadline while the chat turn's\ncontext assembly waited behind it (the 2026-09-03 "memory not working, chat\ntakes seven minutes" incident).\n\nLoad releases through tinybus's new `load_github_release_cached` into\n`install_dir/<id>/<version>/<host_key>/`. The first launch downloads, verifies\nagainst the registry pin and commits with one rename; every later launch\nre-hashes the archive on disk and maps the library without the network.\nVersions no longer pinned are pruned after a successful load.\n\nReplace the global resolve gate with a slot per module (`modules::resolution`):\nthe first caller runs the load as a process-lifetime task on the module\nruntime, everyone else waits on a watch channel, and a caller that gives up\ncancels nothing. `ensure_loaded_within` bounds the wait, `modules.list`\nreports `Loading`, and `ModuleMemoryProvider` answers reads with the\nretryable `MemoryError::Unavailable` after an 8 s grace instead of hanging —\nwrites still wait it out, since a dropped write is lost work — and reports\n`Degraded`, never `Down`, while loading, so a cold launch cannot trip the\nfallback rebind.\n\nWire the eager load policy at boot: `load_declared_modules` had no product\ncaller since it landed, so TinyMemory's download started on the first memory\ncall, on the request path. `start_bootstrap_jobs` now spawns it behind\n`ServiceSet::memory_queue`, installing the memory host callbacks first. Bound\nthe one memory await left on the chat turn's critical path — the situational\npreference recall — to three seconds.\n\nPin vendor/tinybus to tinyhumansai/tinybus#17 (8b6793a), which carries the\ncache, the client budgets and the direct asset URLs.\n
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants