module: cache verified release artifacts and bound the loader's network waits - #17
Conversation
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.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: 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 |
|
Every file's line coverage is above 90% except I don't have rerun rights on this repo ( Full per-file line coverage (this run, sorted ascending)TOTAL: 95.52% lines (9329 total, 418 missed). |
There was a problem hiding this comment.
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
How this change flows2 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
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. |
`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.
|
Pushed The file was at exactly 576/640 = 90.00%, the value The uncovered region turned out to be worth a real test rather than a nudge: the argument validation in
90.00% → 93.06% (630/677), verified locally with the workflow's own command:
Full suite, fmt and clippy all green ( |
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.
…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
Summary
ModuleHost::load_github_release_cachedloads 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.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.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.Problem
Observed live in OpenHuman on 2026-09-03: every launch re-downloaded all five modules (no persistent cache —
acquireextracts into aTempDirthe host keeps alive), serialized, and one of the four Fastly anycast addresses behindrelease-assets.githubusercontent.comwas 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 ownmodules.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), streamingdownload,acquire_cached.acquirekeeps 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_pinnedstays 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 -- --checkcargo clippy --locked --all-targets --all-features -- -D warningscargo test --locked --all-features— 314 passed, 10 ignoredcargo check --locked --no-default-featuresand--no-default-features --features modulescache.rscarries 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.rsis 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::CachedReleaseandModuleHost::load_github_release_cached. No existing signature changes.Related
install_dir, per-module resolution, boot preload, bounded memory waits) — PR to follow, pinned to this branch.