diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fccad78c..8771756c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,32 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + host-core-msrv: + name: Host core and client Rust 1.85 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.85.0 + + - uses: Swatinem/rust-cache@v2 + + - name: Check default host core features at the declared MSRV + run: cargo check --locked --manifest-path crates/tinybus/Cargo.toml --lib + + - name: Check minimal host core features at the declared MSRV + run: cargo check --locked --manifest-path crates/tinybus/Cargo.toml --lib --no-default-features + + - name: Check all host core features at the declared MSRV + run: cargo check --locked --manifest-path crates/tinybus/Cargo.toml --lib --all-features + + - name: Exercise the public client from an isolated consumer without SDK dev fixtures + run: cargo test --locked --all-targets --all-features --manifest-path proof-workspaces/msrv-185-client/Cargo.toml + module-ci: uses: tinyhumansai/.github/.github/workflows/module-ci.yml@4adc1080fb495098e7af1aade82ed7e6e3c69bb7 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7a3076c..ffe334fb 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 existing main-only path or the approved reviewed-epic path + type: choice + options: [main, reviewed-epic] + default: main + required: true + reviewed_pr: + description: Positive PR number whose merge commit the tag names (reviewed-epic mode only) + type: number + required: false push: tags: ['v*'] permissions: {} @@ -18,21 +28,47 @@ 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.-]+)?$ ]] || { echo "tag must be v-prefixed semver" >&2 exit 2 } - 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 "tag=$TAG" + echo "version=${TAG#v}" + echo "release_mode=$RELEASE_MODE" + # Supply a JSON number when the main path has no reviewed PR input. + echo "reviewed_pr=${REVIEWED_PR:-0}" + } >> "$GITHUB_OUTPUT" sdk-release: needs: resolve + if: needs.resolve.outputs.release_mode == 'main' permissions: contents: write uses: tinyhumansai/.github/.github/workflows/sdk-release.yml@4adc1080fb495098e7af1aade82ed7e6e3c69bb7 @@ -40,3 +76,17 @@ jobs: tag: ${{ needs.resolve.outputs.tag }} version: ${{ needs.resolve.outputs.version }} package: tinybus + sdk-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/sdk-reviewed-epic-release.yml@0cafb7aada1e706fa237948c52decd9b753b111c + with: + tag: ${{ needs.resolve.outputs.tag }} + version: ${{ needs.resolve.outputs.version }} + package: tinybus + reviewed-pr: ${{ fromJSON(needs.resolve.outputs.reviewed_pr) }} diff --git a/.gitignore b/.gitignore index 8f247040..d6fcfab6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ /target +/examples/target +/proof-workspaces/msrv-185-client/target/ +/crates/tinybus/.tmp*/ /worktrees +__pycache__/ **/*.rs.bk .env diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..176a7f1d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,184 @@ +# Changelog + +## 0.3.12 + +### Added + +- Allow a bounded, host-configured set of exact confidential controller calls + to reach a module during initialization, without changing the wire or ABI. +- Resolve a live module's unique bus peer from its canonical module ID without + loading cold modules or consulting the broker. + +## 0.3.11 + +### Fixed + +- Give the process-lifetime module runtime its own selected controller so + context-bearing modules receive trusted setup capabilities. + +## 0.3.10 + +### Fixed + +- Update the locked WebPKI verifier and root store to patched versions that + reject certificate paths through the distrusted emSign intermediates. + +## 0.3.9 + +### Added + +- Add configured `setup_factory_with_context` exports that retain per-instance + state and receive a fresh loader-issued setup context for each initialization. + +## 0.3.8 + +### Fixed + +- Keep the TinyBus host crate compatible with its declared Rust 1.85 MSRV by + using stable control-flow and remainder operations. + +## 0.3.7 + +### Added + +- Support configured module setup callbacks that receive an SDK-issued + `ModuleSetupContext` on initial setup and reinitialization. + +## 0.3.6 + +### Added + +- Give module setup callbacks an SDK-issued trusted host binding to the + restricted module controller selected by the host. + +## 0.3.5 + +### Added + +- Expose `module::Availability` and its stable serialized status names without + enabling the optional module loader. + +### Fixed + +- Make the rejected native-worker spawn test deterministic when another + request callback is already in flight, while retaining the uncertain + instance's resources until callback settlement. + +## 0.3.4 + +### Added + +- `ModuleServiceSlot::try_close_with` runs a borrowed host close callback under + the slot lock. Errors and cancellation retain the entry; acknowledged success + retires it without calling the stored closer a second time. + +## 0.3.3 + +### Fixed + +- Publish a stream's completion or truncation verdict before closing its reader + channel, so a reader that has drained the payload observes the correct end. + +## 0.3.2 + +### Added + +- `ModuleClient::bind` returns a private ordinary client pinned to the serving + peer for root calls, streamed uploads, and held object paths. It supports + linked modules and fixtures without making an artifact-attestation claim. + +### Fixed + +- Ordinary bindings preserve the selected proxy's deadline for owner lookup + and later calls unless the client supplies an explicit override. + +## 0.3.1 + +### Added + +- `ModuleRuntime::lease` owns reservation activation on the dedicated runtime and + returns a cloneable `RuntimeLease` with a shared acknowledged-stop worker. +- `RuntimeLeaseDriver` and `StopOutcome` expose a protocol-neutral lifecycle + contract on the slim build; only proven-settled failures are retried. +- `RuntimeLeaseDriver::start` returns `StartOutcome` to distinguish acknowledged + start, a proven-settled failure, and an uncertain remote operation. +- `ModuleClient::bind_attested` returns a client pinned to the proven unique + peer, including for later calls and held object paths. +- Unacknowledged and faulted cleanup retains the exact callback registration + until STOP acknowledgement. + +### Fixed + +- An uncertain START keeps its reservation, object, and callback registration + owned until an authoritative settlement; it never triggers an overlapping STOP. +- Closing a linked module connection wakes an idle receive without requiring + another inbound frame, drains queued frames, and refuses sends after close. + +## 0.3.0 + +### Breaking changes + +- `RuntimeSetup` now receives the `ModuleHost` alongside its connection, so + runtime setup can install callbacks without a second process-global slot. +- `LinkedModule` carries an optional Rust-only setup initializer generated by + `module_export_static!`; struct literals must provide `init_with_setup`. + +### Added + +- `ModuleHost::attach_linked_with_setup` replaces one linked export's default + setup with state captured for that admitted instance and waits for readiness. +- `ModuleHost::module_for_sender` resolves broker-assigned unique peers to exact + admitted `ModuleId`s without a broker lookup. +- `ModuleRuntime::serve_host_object` returns a drop guard for host object and + name ownership, with cleanup on the module runtime. + +### Fixed + +- Host object guards remove only their own interface registration and preserve + names that were already owned before the first guard. +- Canceling or timing out linked setup revokes its pending module admission. +- Module sender identity now requires an exact canonical manifest/descriptor + match rather than a sanitized display-name match. + +## 0.2.2 + +### Added + +- `ModuleClient::call_with_stream` and `call_with_stream_detailed` upload + payloads through the module's validated proxy; the detailed form preserves + untrusted remote wire failures while keeping renderings and reports redacted. +- `ModuleObjectClient::call_detailed` retains exact remote method failure + details for held objects while keeping its sanitized call wrapper. + +### Fixed + +- A streamed method follows the unique peer that accepted `Open`, so a + well-known-name handover cannot split its method from its payload. +- A refused protocol frame reports as a transport failure rather than as a + module execution fault. + +## 0.2.1 + +### Added + +- `ModuleClient::call_attested_detailed` retains remote method failure fields + for product adapters while keeping renderings and failure reports redacted. + +## 0.2.0 + +### Breaking changes + +- Module search paths are now opt-in. Hosts that need discovery must configure + `SearchPathConfig` with their own environment variable and app directory; + TinyBus no longer assumes OpenHuman's `OPENHUMAN_MODULE_PATH` or data paths. +- `ModuleHost::search_paths` is now an instance method and + `ResolverInputs` carries optional search-path configuration and a linked + registry. Update resolver construction to pass a `LinkedRegistry` (usually + `LinkedRegistry::global()`) and the host's `SearchPathConfig`, if any. + +### Added + +- `UnloadedAccess` provides registry-backed client access for builds without + the dynamic module loader. +- Hosts can set a bundled release directory and resolve it using an explicitly + named environment variable. diff --git a/Cargo.lock b/Cargo.lock index 0775269e..8c10cbff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -211,6 +211,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -224,7 +235,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -319,6 +330,93 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "icu_casemap" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca9983e8bf51223c2f89014fa4eaa9e9b336c47f3af0d000538f86f841fba1" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98d4663d0f99b301033a19e0acf94e9d2fa4b107638580165e5a6ccc49ad1450" + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "serde", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -359,6 +457,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 = "log" version = "0.4.33" @@ -437,6 +541,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[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 = "proc-macro2" version = "1.0.107" @@ -461,6 +576,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rawzip" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9999039f74cd73c03158cddfe4362991703c74efe5ce5c8073026f80a5e48c" + [[package]] name = "regex-automata" version = "0.4.18" @@ -502,7 +623,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -644,6 +765,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -678,6 +805,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "tar" version = "0.4.46" @@ -699,7 +837,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -733,12 +871,14 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.3.6" +version = "0.3.12" dependencies = [ "async-trait", "clap", "flate2", + "icu_casemap", "libc", + "rawzip", "serde", "serde_json", "sha2", @@ -751,13 +891,14 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "unicode-normalization", "ureq", "zip", ] [[package]] name = "tinybus-macros" -version = "0.3.6" +version = "0.3.12" dependencies = [ "proc-macro2", "quote", @@ -766,7 +907,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.3.6" +version = "0.3.12" dependencies = [ "async-trait", "serde", @@ -776,6 +917,23 @@ dependencies = [ "tracing", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.3" @@ -906,6 +1064,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -1068,6 +1235,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[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" @@ -1078,12 +1251,91 @@ dependencies = [ "rustix", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", + "synstructure", +] + [[package]] name = "zeroize" 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.3", +] + [[package]] name = "zip" version = "9.0.5" diff --git a/Cargo.toml b/Cargo.toml index 0c4c50f0..d101a2a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,20 +7,22 @@ # lockstep with the host ABI so the two definitions cannot drift. Integrations # themselves remain separate repositories and are never workspace members. members = ["crates/tinybus", "crates/tinybus-macros", "crates/tinybus-module"] -exclude = ["examples"] +exclude = ["examples", "proof-workspaces/msrv-185-client"] resolver = "3" [workspace.package] -version = "0.3.6" +version = "0.3.12" edition = "2024" +# The host/core crate and macros support Rust 1.85; the separate module-side +# runtime deliberately remains on Rust 1.88 for its fixture and ABI surface. rust-version = "1.85" license = "GPL-3.0-only" repository = "https://github.com/tinyhumansai/tinybus" [workspace.dependencies] -tinybus = { path = "crates/tinybus", version = "=0.3.6", default-features = false } -tinybus-macros = { path = "crates/tinybus-macros", version = "=0.3.6" } -tinybus-module = { path = "crates/tinybus-module", version = "=0.3.6" } +tinybus = { path = "crates/tinybus", version = "=0.3.12", default-features = false } +tinybus-macros = { path = "crates/tinybus-macros", version = "=0.3.12" } +tinybus-module = { path = "crates/tinybus-module", version = "=0.3.12" } tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } async-trait = "0.1" diff --git a/README.md b/README.md index 4880fd72..d79231d0 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,8 @@ A zbus-style message bus that keeps external integrations out of the OpenHuman kernel's dependency graph. -The `tinybus` and `tinybus-module` crates require Rust 1.88. The optional -`modules` feature uses `zip` 9, which has that minimum, and `tinybus-module` -depends on `tinybus`. `tinybus-macros` retains the workspace's Rust 1.85 -minimum. +The `tinybus` host/core crate and `tinybus-macros` support Rust 1.85. +`tinybus-module` requires Rust 1.88 for its module-side runtime and fixtures. ## The problem diff --git a/crates/tinybus-macros/Cargo.toml b/crates/tinybus-macros/Cargo.toml index ccb74265..c4845d6b 100644 --- a/crates/tinybus-macros/Cargo.toml +++ b/crates/tinybus-macros/Cargo.toml @@ -6,7 +6,7 @@ rust-version.workspace = true license.workspace = true repository.workspace = true description = "The #[interface] attribute for tinybus. Not useful on its own; use the `tinybus` crate." -readme = "README.md" +readme = "../../README.md" [lib] proc-macro = true diff --git a/crates/tinybus-macros/src/lib_tests.rs b/crates/tinybus-macros/src/lib_tests.rs index a03cf840..3d50a3ee 100644 --- a/crates/tinybus-macros/src/lib_tests.rs +++ b/crates/tinybus-macros/src/lib_tests.rs @@ -66,6 +66,30 @@ fn expansion_generates_dispatch_members_and_strips_helper_attributes() { })); } +#[test] +fn expansion_skips_nonmethods_and_tracks_confidential_wildcard_arguments() { + let expanded = expand( + args("name = PATH, name = 7, name = \"ai.tinyhumans.Example\""), + implementation( + r#" + impl Example { + const VERSION: u32 = 1; + #[allow(dead_code)] + fn constructor() -> Self { Self } + #[tinybus(confidential)] + async fn inspect(&self, _: u32) -> tinybus::Result<()> { Ok(()) } + } + "#, + ), + ) + .unwrap() + .to_string(); + + assert!(expanded.contains("__arg0")); + assert!(expanded.contains("matches")); + assert!(expanded.contains("Inspect")); +} + #[test] fn expansion_rejects_invalid_interface_method_signatures() { let name = args("name = \"ai.tinyhumans.Example\""); @@ -88,6 +112,13 @@ fn attributes_and_interface_names_are_validated() { "ai.tinyhumans.Example" ); assert!(interface_name(&args("other = \"value\"")).is_err()); + assert_eq!( + interface_name(&args( + "name = PATH, name = 7, name = \"ai.tinyhumans.Valid\"" + )) + .unwrap(), + "ai.tinyhumans.Valid" + ); let declared = implementation( "impl Example { #[tinybus(skip, confidential, name = \"WireName\")] async fn call(&self) -> tinybus::Result<()> { Ok(()) } }", diff --git a/crates/tinybus-module/src/execution_context_tests.rs b/crates/tinybus-module/src/execution_context_tests.rs index 0acdda77..aa39c3a1 100644 --- a/crates/tinybus-module/src/execution_context_tests.rs +++ b/crates/tinybus-module/src/execution_context_tests.rs @@ -28,6 +28,7 @@ fn owner() -> (ExecutionOwner, &'static AtomicUsize) { ready, broker_routing: None, native_terminal_state: None, + trusted_host_binding: None, }); (ExecutionOwner::new(host), count) } @@ -234,6 +235,7 @@ fn terminal_deliver_enters_exact_instance_and_restores_nested_caller() { let (instance, instance_count) = owner(); let host = HostCalls(TbHostVtable { native_terminal_state: Some(tracing_native_query), + trusted_host_binding: None, ..instance.host.0 }); let state = super::super::terminal::TerminalState::admit(host, Some(instance)).unwrap(); diff --git a/crates/tinybus-module/src/lib.rs b/crates/tinybus-module/src/lib.rs index 6f1fafb7..7c5e1c67 100644 --- a/crates/tinybus-module/src/lib.rs +++ b/crates/tinybus-module/src/lib.rs @@ -23,8 +23,8 @@ use tinybus::module::abi::{ TB_BACKPRESSURE, TB_BAD_ARGUMENT, TB_CLOSED, TB_MODULE_VTABLE_BASE_SIZE, TB_OK, TB_PANICKED, TB_TIMEOUT, TbHostVtable, TbModuleVtable, TbModuleVtableV1Prefix, }; -use tinybus::{Connection, Error, Result, Transport}; -use tokio::sync::{Mutex, mpsc}; +use tinybus::{BusName, CallContext, Connection, Error, Result, Transport, WeakConnection}; +use tokio::sync::{Mutex, mpsc, watch}; mod terminal; pub use terminal::{NativeInstanceLiveness, RuntimeHooks, TerminalCompletion, TerminalReason}; @@ -200,6 +200,87 @@ impl HostCalls { fn ready(&self) { unsafe { (self.0.ready)(self.0.host_ctx) } } + + fn trusted_host_peer(&self) -> Option { + let query = self.0.trusted_host_binding?; + let mut bytes = [0_u8; tinybus::name::MAX_NAME_LEN]; + let mut len = 0; + let code = unsafe { query(self.0.host_ctx, bytes.as_mut_ptr(), bytes.len(), &mut len) }; + if code != TB_OK || len > bytes.len() { + return None; + } + let sender = std::str::from_utf8(&bytes[..len]) + .ok()? + .parse::() + .ok()?; + sender.is_unique().then_some(sender) + } +} + +/// Setup-time connection and the optional SDK-issued migration-host binding. +/// +/// Constructed only by the module runtime after its broker handshake. A +/// migration module must reject `None` before registering storage leases or +/// signaling ready. +pub struct ModuleSetupContext { + connection: Connection, + trusted_host: Option, +} + +impl ModuleSetupContext { + fn new(connection: Connection, grant: Option) -> Self { + let trusted_host = grant.map(|grant| TrustedHost { + host: grant.host, + peer: grant.peer, + origin: connection.downgrade(), + }); + Self { + connection, + trusted_host, + } + } + + /// The module's broker-authenticated connection used for setup. + pub fn connection(&self) -> &Connection { + &self.connection + } + + /// The optional immutable migration-host capability for this attachment. + pub fn trusted_host(&self) -> Option<&TrustedHost> { + self.trusted_host.as_ref() + } +} + +#[derive(Clone)] +struct TrustedHostGrant { + host: HostCalls, + peer: BusName, +} + +/// An SDK-issued, attachment-scoped authorization check for migration calls. +/// +/// The capability has no public constructor or serialized representation. It +/// stores only the initial unique peer, an opaque weak receiver identity, and +/// the host callback used to confirm that the same peer remains live. +#[derive(Clone)] +pub struct TrustedHost { + host: HostCalls, + peer: BusName, + origin: WeakConnection, +} + +impl TrustedHost { + /// Check a confidential call from the pinned controller on this exact + /// module connection, refreshing the loader binding on every decision. + pub fn authorizes(&self, context: &CallContext) -> bool { + if !context.is_confidential() + || !context.is_for_connection(&self.origin) + || context.authenticated_sender() != Some(&self.peer) + { + return false; + } + self.host.trusted_host_peer().as_ref() == Some(&self.peer) + } } struct HostSubscriber { @@ -267,13 +348,21 @@ type IncomingFrame = (Vec, bool); struct ModuleTransport { host: HostCalls, inbound: Mutex>, + closed: watch::Sender, provenance: Arc, detach_on_panic: bool, } +fn close_signal() -> watch::Sender { + watch::channel(false).0 +} + #[async_trait] impl Transport for ModuleTransport { async fn send(&self, message: Message) -> Result<()> { + if *self.closed.borrow() { + return Err(Error::ConnectionClosed); + } let panicked = message.header.error_name.as_deref() == Some(MODULE_PANIC_ERROR); let bytes = serde_json::to_vec(&message)?; if bytes.len() > MAX_FRAME_LEN { @@ -293,20 +382,40 @@ impl Transport for ModuleTransport { } async fn recv(&self) -> Result> { - let bytes = self.inbound.lock().await.recv().await; - let Some((bytes, brokered)) = bytes else { - return Ok(None); - }; - self.host.wake(); - let message = serde_json::from_slice(&bytes)?; - // The connection is the only reader and samples this slot immediately - // after recv. Keep header identity intact for stream ownership/routing; - // only contextual dispatch consumes the captured delivery authority. - self.provenance.store(brokered, Ordering::Release); - Ok(Some(message)) + // Subscribe before taking the receiver lock, then recheck the current + // close intent under that lock. This closes the lost-wakeup window + // between a close request and waiting on the channel. + let mut closed = self.closed.subscribe(); + let mut inbound = self.inbound.lock().await; + loop { + if *closed.borrow_and_update() { + inbound.close(); + } + tokio::select! { + biased; + _ = closed.changed() => inbound.close(), + frame = inbound.recv() => { + let Some((bytes, brokered)) = frame else { + return Ok(None); + }; + self.host.wake(); + let message = serde_json::from_slice(&bytes)?; + // The connection is the only reader and samples this slot + // immediately after recv. Keep header identity intact for + // stream ownership/routing; only contextual dispatch + // consumes the captured delivery authority. + self.provenance.store(brokered, Ordering::Release); + return Ok(Some(message)); + } + } + } } async fn close(&self) -> Result<()> { + // Signal first: an idle recv owns the receiver mutex while waiting for + // a frame. It observes this intent, closes the channel, and releases + // the mutex, so close never needs another inbound delivery to proceed. + self.closed.send_replace(true); self.inbound.lock().await.close(); Ok(()) } @@ -512,6 +621,49 @@ where } } +/// Initialize the module runtime with its setup connection and loader binding. +/// +/// This additive entry point leaves [`start_module`] and its +/// `FnOnce(Connection)` contract unchanged. If the host does not provide a +/// trusted binding, `ModuleSetupContext::trusted_host()` is `None`. +/// +/// # Safety +/// The host vtable must meet [`TbHostVtable::read_compatible`] requirements; +/// the output buffer must satisfy [`start_module`] requirements. +#[doc(hidden)] +pub unsafe fn start_module_with_context( + host: *const TbHostVtable, + out: *mut TbModuleVtable, + worker_threads: usize, + detach_on_panic: bool, + setup: F, +) -> i32 +where + F: FnOnce(ModuleSetupContext) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + let Some((_, compatible_host)) = + (unsafe { validate_start_arguments(host, out, worker_threads) }) + else { + return TB_BAD_ARGUMENT; + }; + let grant = HostCalls(compatible_host) + .trusted_host_peer() + .map(|peer| TrustedHostGrant { + host: HostCalls(compatible_host), + peer, + }); + unsafe { + start_module( + host, + out, + worker_threads, + detach_on_panic, + move |connection| setup(ModuleSetupContext::new(connection, grant)), + ) + } +} + /// Start a linked module without replacing the host's process-global hooks. /// /// # Safety @@ -542,6 +694,44 @@ where } } +/// Linked counterpart to [`start_module_with_context`]. +/// +/// # Safety +/// The host vtable and output storage must satisfy [`start_module`] requirements. +#[doc(hidden)] +pub unsafe fn start_linked_module_with_context( + host: *const TbHostVtable, + out: *mut TbModuleVtable, + worker_threads: usize, + detach_on_panic: bool, + setup: F, +) -> i32 +where + F: FnOnce(ModuleSetupContext) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + let Some((_, compatible_host)) = + (unsafe { validate_start_arguments(host, out, worker_threads) }) + else { + return TB_BAD_ARGUMENT; + }; + let grant = HostCalls(compatible_host) + .trusted_host_peer() + .map(|peer| TrustedHostGrant { + host: HostCalls(compatible_host), + peer, + }); + unsafe { + start_linked_module( + host, + out, + worker_threads, + detach_on_panic, + move |connection| setup(ModuleSetupContext::new(connection, grant)), + ) + } +} + // Validate the frozen ABI prefix before configuration parsing or a factory can // allocate state. Never read or write beyond a caller's declared allocation. unsafe fn validate_start_arguments( @@ -604,6 +794,7 @@ where let transport = Arc::new(ModuleTransport { host, inbound: Mutex::new(inbound_rx), + closed: close_signal(), provenance: Arc::new(AtomicBool::new(false)), detach_on_panic, }); @@ -807,6 +998,77 @@ where } } +/// Initialize a configured module whose setup receives the host-bound context. +/// +/// Reinitialization reuses the original host grant while constructing a fresh +/// context for the active connection and each new configuration. +/// +/// # Safety +/// The caller must uphold the host, output, configuration, and routing contracts +/// of [`start_module`]. +#[doc(hidden)] +pub unsafe fn start_reconfigurable_module_with_context( + host: *const TbHostVtable, + out: *mut TbModuleVtable, + worker_threads: usize, + detach_on_panic: bool, + setup: F, +) -> i32 +where + C: serde::de::DeserializeOwned + Send + 'static, + F: Fn(ModuleSetupContext, C) -> Fut + Clone + Send + Sync + 'static, + Fut: Future> + Send + 'static, +{ + let Some((_, compatible_host)) = + (unsafe { validate_start_arguments(host, out, worker_threads) }) + else { + return TB_BAD_ARGUMENT; + }; + let grant = HostCalls(compatible_host) + .trusted_host_peer() + .map(|peer| TrustedHostGrant { + host: HostCalls(compatible_host), + peer, + }); + let setup = + move |connection, config| setup(ModuleSetupContext::new(connection, grant.clone()), config); + unsafe { start_reconfigurable_module(host, out, worker_threads, detach_on_panic, setup) } +} + +/// Linked counterpart to [`start_reconfigurable_module_with_context`]. +/// +/// # Safety +/// The caller must uphold the host, output, configuration, and routing contracts +/// of [`start_module`]. +#[doc(hidden)] +pub unsafe fn start_linked_reconfigurable_module_with_context( + host: *const TbHostVtable, + out: *mut TbModuleVtable, + worker_threads: usize, + detach_on_panic: bool, + setup: F, +) -> i32 +where + C: serde::de::DeserializeOwned + Send + 'static, + F: Fn(ModuleSetupContext, C) -> Fut + Clone + Send + Sync + 'static, + Fut: Future> + Send + 'static, +{ + let Some((_, compatible_host)) = + (unsafe { validate_start_arguments(host, out, worker_threads) }) + else { + return TB_BAD_ARGUMENT; + }; + let grant = HostCalls(compatible_host) + .trusted_host_peer() + .map(|peer| TrustedHostGrant { + host: HostCalls(compatible_host), + peer, + }); + let setup = + move |connection, config| setup(ModuleSetupContext::new(connection, grant.clone()), config); + unsafe { start_linked_reconfigurable_module(host, out, worker_threads, detach_on_panic, setup) } +} + unsafe fn start_reconfigurable_module_mode( host: *const TbHostVtable, out: *mut TbModuleVtable, @@ -826,7 +1088,7 @@ where out, worker_threads, detach_on_panic, - || setup, + |_| setup, linked, ) } @@ -858,7 +1120,7 @@ where out, worker_threads, detach_on_panic, - || shared_factory_setup(factory), + |_| shared_factory_setup(factory), false, ) } @@ -890,7 +1152,76 @@ where out, worker_threads, detach_on_panic, - || shared_factory_setup(factory), + |_| shared_factory_setup(factory), + true, + ) + } +} + +/// Initialize one configured instance with per-instance state and a fresh +/// loader-bound setup context for initial setup and every reinitialization. +/// +/// The host ABI is validated and the initial configuration is parsed before +/// `factory` runs. The returned callback is shared across reinitialization; +/// its own `Clone` behavior cannot reset captured state. Each invocation gets +/// a new [`ModuleSetupContext`] for the active connection and the immutable +/// trusted-host grant selected for this module instance. +/// +/// # Safety +/// Non-null pointers must address readable host ABI prefixes and writable +/// output storage matching their declared sizes. Host callbacks and context +/// must remain valid until runtime shutdown. Null pointers and invalid sizes +/// or worker counts are rejected before configuration or factory evaluation. +#[doc(hidden)] +pub unsafe fn start_reconfigurable_module_with_context_factory( + host: *const TbHostVtable, + out: *mut TbModuleVtable, + worker_threads: usize, + detach_on_panic: bool, + factory: impl FnOnce() -> F, +) -> i32 +where + C: serde::de::DeserializeOwned + Send + 'static, + F: Fn(ModuleSetupContext, C) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, +{ + unsafe { + start_reconfigurable_module_factory_mode( + host, + out, + worker_threads, + detach_on_panic, + |host_calls| shared_context_factory_setup(factory, host_calls), + false, + ) + } +} + +/// Linked counterpart to [`start_reconfigurable_module_with_context_factory`]. +/// +/// # Safety +/// The caller must uphold the host, output, configuration, and routing +/// contracts of [`start_module`]. +#[doc(hidden)] +pub unsafe fn start_linked_reconfigurable_module_with_context_factory( + host: *const TbHostVtable, + out: *mut TbModuleVtable, + worker_threads: usize, + detach_on_panic: bool, + factory: impl FnOnce() -> F, +) -> i32 +where + C: serde::de::DeserializeOwned + Send + 'static, + F: Fn(ModuleSetupContext, C) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, +{ + unsafe { + start_reconfigurable_module_factory_mode( + host, + out, + worker_threads, + detach_on_panic, + |host_calls| shared_context_factory_setup(factory, host_calls), true, ) } @@ -908,12 +1239,31 @@ where move |connection, config| setup(connection, config) } +fn shared_context_factory_setup( + factory: impl FnOnce() -> F, + host: HostCalls, +) -> impl Fn(Connection, C) -> Fut + Clone + Send + Sync + 'static +where + F: Fn(ModuleSetupContext, C) -> Fut + Send + Sync + 'static, +{ + let trusted_host = host + .trusted_host_peer() + .map(|peer| TrustedHostGrant { host, peer }); + let setup = Arc::new(factory()); + move |connection, config| { + setup( + ModuleSetupContext::new(connection, trusted_host.clone()), + config, + ) + } +} + unsafe fn start_reconfigurable_module_factory_mode( host: *const TbHostVtable, out: *mut TbModuleVtable, worker_threads: usize, detach_on_panic: bool, - factory: impl FnOnce() -> F, + factory: impl FnOnce(HostCalls) -> F, linked: bool, ) -> i32 where @@ -926,7 +1276,8 @@ where else { return TB_BAD_ARGUMENT; }; - let caller_owner = (!linked).then(|| ExecutionOwner::new(HostCalls(compatible_host))); + let host_calls = HostCalls(compatible_host); + let caller_owner = (!linked).then(|| ExecutionOwner::new(host_calls)); if caller_owner.is_some() { ExecutionOwner::install_panic_hook(); } @@ -937,7 +1288,7 @@ where Ok(Err(code)) => return code, Err(_) => return TB_PANICKED, }; - let setup = match catch_unwind(AssertUnwindSafe(factory)) { + let setup = match catch_unwind(AssertUnwindSafe(|| factory(host_calls))) { Ok(setup) => setup, Err(_) => return TB_PANICKED, }; @@ -989,7 +1340,11 @@ where /// Export a tinybus module's descriptor, manifest and initialization entrypoint. /// -/// ```ignore +/// ``` +/// async fn setup(_: tinybus::Connection) -> tinybus::Result<()> { +/// Ok(()) +/// } +/// /// tinybus_module::module_export! { setup = setup, worker_threads = 1 } /// ``` /// @@ -1002,6 +1357,11 @@ where /// state is reused for that instance's reinitializations, allowing admission to /// remain bound without process-global state. Separate instances receive fresh /// state. A factory panic returns `TB_PANICKED` without unwinding into the host. +/// `setup_factory_with_context = factory, config = Config` combines that +/// per-instance state with a fresh SDK-issued [`ModuleSetupContext`] for every +/// setup call. Its factory runs only after host validation and initial config +/// parsing; the selected-host grant is retained for that instance while the +/// context's connection is refreshed on reinitialization. /// Configured modules may instead use `terminal_setup_factory = factory`. Its /// factory receives [`NativeInstanceLiveness`] and returns `Result<(F, /// RuntimeHooks)>`, with the same setup callback bounds. A supported, exactly @@ -1010,23 +1370,54 @@ where /// partial-failure cleanup until it returns and must launch independent workers /// only from setup after hook registration. See [`RuntimeHooks`] for controller /// obligations. Hooks are registered once and never replaced by reinitialization. -/// Existing `setup = function` and `setup_factory` callbacks retain their behavior. +/// `setup_with_context = function, config = Config` opts into configured setup +/// with an SDK-issued [`ModuleSetupContext`] on initial setup and reinitialization. +/// Existing plain `setup_with_context = function`, `setup = function`, and +/// `setup_factory` callbacks retain their behavior. #[macro_export] macro_rules! module_export { - (@linked) => { + (@linked $($declaration:tt)*) => { + $crate::module_export!(@linked_find_threads [] $($declaration)*); + }; + (@linked_find_threads [$($prefix:tt)*] worker_threads = $threads:expr, $($rest:tt)*) => { + $crate::module_export!(@linked_impl $threads); + }; + (@linked_find_threads [$($prefix:tt)*] worker_threads = $threads:expr $(,)?) => { + $crate::module_export!(@linked_impl $threads); + }; + (@linked_find_threads [$($prefix:tt)*] $head:tt $($rest:tt)*) => { + $crate::module_export!(@linked_find_threads [$($prefix)* $head] $($rest)*); + }; + (@linked_impl $threads:expr) => { /// Entry points and parsed manifest for a host that links this module. /// /// # Errors /// Returns an error if the generated manifest is invalid. pub fn linked_module() -> ::tinybus::Result<::tinybus::module::LinkedModule> { unsafe { - ::tinybus::module::LinkedModule::from_exports( + ::tinybus::module::LinkedModule::from_exports_with_setup( &TINYBUS_MODULE_ABI_V1, tinybus_module_manifest_v1, tinybus_module_init_v1, + tinybus_module_init_with_setup_v1, ) } } + + /// Rust-only initializer used when a host supplies per-attachment + /// setup state. It replaces this export's default setup callback. + /// + /// # Safety + /// `host` and `out` must point to valid TinyBus ABI structures that + /// remain readable and writable for the duration of initialization. + #[doc(hidden)] + pub unsafe fn tinybus_module_init_with_setup_v1( + host: *const ::tinybus::module::abi::TbHostVtable, + out: *mut ::tinybus::module::abi::TbModuleVtable, + setup: ::tinybus::module::LinkedSetup, + ) -> i32 { + unsafe { $crate::start_linked_module(host, out, $threads, true, setup) } + } }; (@common export = {$($export:tt)*}, @@ -1039,6 +1430,8 @@ macro_rules! module_export { lazy = $lazy:expr $(,)? ) => { $($export)* + #[doc = "The ABI descriptor exported for this TinyBus module."] + #[doc = "It identifies the package name, version and compatible host ABI."] pub static TINYBUS_MODULE_ABI_V1: ::tinybus::module::abi::TbAbiDescriptor = ::tinybus::module::abi::TbAbiDescriptor::current( env!("CARGO_PKG_NAME"), @@ -1046,6 +1439,8 @@ macro_rules! module_export { ); $($export)* + #[doc = "Return the retained manifest for this TinyBus module."] + #[doc = "The returned byte slice remains valid while this module library remains loaded."] pub extern "C" fn tinybus_module_manifest_v1() -> ::tinybus::module::abi::TbSlice { static MANIFEST_BYTES: ::std::sync::OnceLock<::std::vec::Vec> = ::std::sync::OnceLock::new(); @@ -1088,6 +1483,17 @@ macro_rules! module_export { } $($export)* + /// Initialize this module through the TinyBus loader. + /// + /// The loader supplies the host ABI table and output storage used to + /// start the module runtime. Applications should initialize modules + /// through `ModuleHost`, which validates and retains the artifact. + /// + /// # Safety + /// `host` and `out` must point to readable and writable ABI structures + /// that remain valid for the duration of initialization. The host + /// context and callbacks referenced by `host` must remain valid until + /// the module runtime has shut down. pub unsafe extern "C" fn tinybus_module_init_v1( host: *const ::tinybus::module::abi::TbHostVtable, out: *mut ::tinybus::module::abi::TbModuleVtable, @@ -1141,6 +1547,17 @@ macro_rules! module_export { } $($export)* + /// Initialize this module through the TinyBus loader. + /// + /// The loader supplies the host ABI table and output storage used to + /// start the module runtime. Applications should initialize modules + /// through `ModuleHost`, which validates and retains the artifact. + /// + /// # Safety + /// `host` and `out` must point to readable and writable ABI structures + /// that remain valid for the duration of initialization. The host + /// context and callbacks referenced by `host` must remain valid until + /// the module runtime has shut down. pub unsafe extern "C" fn tinybus_module_init_v1( host: *const ::tinybus::module::abi::TbHostVtable, out: *mut ::tinybus::module::abi::TbModuleVtable, @@ -1172,6 +1589,19 @@ macro_rules! module_export { $($rest)* } }; + ( + setup_factory_with_context = $factory:path, + config = $config:ty, + $($rest:tt)* + ) => { + $crate::module_export! { + @configured export = {#[unsafe(no_mangle)]}, + start = $crate::start_reconfigurable_module_with_context_factory, + setup = $factory, + config = $config, + $($rest)* + } + }; ( setup = $setup:path, config = $config:ty, @@ -1185,6 +1615,27 @@ macro_rules! module_export { $($rest)* } }; + ( + setup_with_context = $setup:path, + config = $config:ty, + $($rest:tt)* + ) => { + $crate::module_export! { + @configured export = {#[unsafe(no_mangle)]}, + start = $crate::start_reconfigurable_module_with_context, + setup = $setup, + config = $config, + $($rest)* + } + }; + (setup_with_context = $setup:path, $($rest:tt)*) => { + $crate::module_export! { + @plain export = {#[unsafe(no_mangle)]}, + start = $crate::start_module_with_context, + setup = $setup, + $($rest)* + } + }; (setup = $setup:path, $($rest:tt)*) => { $crate::module_export! { @plain export = {#[unsafe(no_mangle)]}, @@ -1197,8 +1648,9 @@ macro_rules! module_export { /// Generate Rust-addressable entry points for linking several modules into a /// single executable. The entry points have no shared C linker symbol names. -/// Configured `setup`, per-instance `setup_factory`, and opted-in -/// `terminal_setup_factory` forms are supported; see [`module_export`]. +/// Configured `setup`, per-instance `setup_factory`, context-bearing +/// `setup_factory_with_context`, and opted-in `terminal_setup_factory` forms +/// are supported; see [`module_export`]. /// Terminal factories require the supplied host's native query even when linked. #[macro_export] macro_rules! module_export_static { @@ -1208,7 +1660,7 @@ macro_rules! module_export_static { start = $crate::start_linked_terminal_reconfigurable_module_with_factory, setup = $factory, config = $config, $($rest)* } - $crate::module_export! { @linked } + $crate::module_export! { @linked $($rest)* } }; (setup_factory = $factory:path, config = $config:ty, $($rest:tt)*) => { $crate::module_export! { @@ -1218,7 +1670,17 @@ macro_rules! module_export_static { config = $config, $($rest)* } - $crate::module_export! { @linked } + $crate::module_export! { @linked $($rest)* } + }; + (setup_factory_with_context = $factory:path, config = $config:ty, $($rest:tt)*) => { + $crate::module_export! { + @configured export = {}, + start = $crate::start_linked_reconfigurable_module_with_context_factory, + setup = $factory, + config = $config, + $($rest)* + } + $crate::module_export! { @linked $($rest)* } }; (setup = $setup:path, config = $config:ty, $($rest:tt)*) => { $crate::module_export! { @@ -1228,7 +1690,26 @@ macro_rules! module_export_static { config = $config, $($rest)* } - $crate::module_export! { @linked } + $crate::module_export! { @linked $($rest)* } + }; + (setup_with_context = $setup:path, config = $config:ty, $($rest:tt)*) => { + $crate::module_export! { + @configured export = {}, + start = $crate::start_linked_reconfigurable_module_with_context, + setup = $setup, + config = $config, + $($rest)* + } + $crate::module_export! { @linked $($rest)* } + }; + (setup_with_context = $setup:path, $($rest:tt)*) => { + $crate::module_export! { + @plain export = {}, + start = $crate::start_linked_module_with_context, + setup = $setup, + $($rest)* + } + $crate::module_export! { @linked $($rest)* } }; (setup = $setup:path, $($rest:tt)*) => { $crate::module_export! { @@ -1237,7 +1718,7 @@ macro_rules! module_export_static { setup = $setup, $($rest)* } - $crate::module_export! { @linked } + $crate::module_export! { @linked $($rest)* } }; } @@ -1264,6 +1745,10 @@ mod tests; #[path = "static_link_tests.rs"] mod static_link_tests; +#[cfg(test)] +#[path = "lib_linked_setup_tests.rs"] +mod linked_setup_tests; + #[cfg(test)] #[path = "lib_setup_factory_tests.rs"] mod setup_factory_test; @@ -1460,10 +1945,16 @@ impl Future for TerminalSetupPoll { |future| future.as_mut().poll(cx), ) })); - *self - .slot - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = future; + { + *self + .slot + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = future; + } + #[cfg(test)] + if matches!(self.kind, terminal::PayloadKind::Initial) { + self.state.initial_future_restored_for_test(); + } match outcome { Ok(std::task::Poll::Ready(Err(error))) => { self.state.signal(TerminalReason::SetupFailed); @@ -1720,6 +2211,7 @@ where inner: ModuleTransport { host: state.host, inbound: Mutex::new(inbound_rx), + closed: close_signal(), provenance: Arc::new(AtomicBool::new(false)), detach_on_panic: false, }, diff --git a/crates/tinybus-module/src/lib_lifecycle_tests.rs b/crates/tinybus-module/src/lib_lifecycle_tests.rs index d8d76078..158f0dd7 100644 --- a/crates/tinybus-module/src/lib_lifecycle_tests.rs +++ b/crates/tinybus-module/src/lib_lifecycle_tests.rs @@ -24,6 +24,7 @@ fn host() -> TbHostVtable { ready, broker_routing: None, native_terminal_state: None, + trusted_host_binding: None, } } struct DropCounter(Arc); diff --git a/crates/tinybus-module/src/lib_linked_setup_tests.rs b/crates/tinybus-module/src/lib_linked_setup_tests.rs new file mode 100644 index 00000000..8d068c7f --- /dev/null +++ b/crates/tinybus-module/src/lib_linked_setup_tests.rs @@ -0,0 +1,493 @@ +use super::*; +// Per-attachment stateful setup for generated linked module exports. +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tinybus::Interface; +use tinybus::broker::Broker; +use tinybus::module::{ + LinkedModule, ModuleHost, ModuleId, ModuleRuntime, ModuleSenderLookup, ModuleState, + RuntimeNames, RuntimeSetup, +}; +use tinybus::transport::memory::MemoryBus; + +static DEFAULT_SETUP_CALLS: AtomicUsize = AtomicUsize::new(0); + +mod export { + use super::*; + + async fn default_setup(_: Connection) -> tinybus::Result<()> { + DEFAULT_SETUP_CALLS.fetch_add(1, Ordering::SeqCst); + Err(tinybus::Error::failed("default setup must be replaced")) + } + + crate::module_export_static! { + setup = default_setup, + worker_threads = 1, + provides = ["ai.tinyhumans.test.LinkedSetup"], + methods = [], + signals = [], + requires = [], + optional = [], + lazy = false, + } +} + +struct SetupValue(&'static str); + +#[async_trait::async_trait] +impl Interface for SetupValue { + fn name(&self) -> tinybus::InterfaceName { + "org.example.LinkedSetupValue".parse().unwrap() + } + + fn members(&self) -> Vec { + vec!["Value".parse().unwrap()] + } + + async fn call( + &self, + _: &tinybus::MemberName, + _: serde_json::Value, + ) -> tinybus::Result { + Ok(serde_json::json!(self.0)) + } +} + +fn linked_export(id: &str) -> LinkedModule { + let mut module = export::linked_module().unwrap(); + module.descriptor.module_name = [0; 64]; + module.descriptor.module_name[..id.len()].copy_from_slice(id.as_bytes()); + module.manifest.module.name = id.to_string(); + module.manifest.bus_name = format!("ai.tinyhumans.test.{id}").parse().unwrap(); + module.manifest.object_path = format!("/ai/tinyhumans/test/{}", id.replace('-', "_")) + .parse() + .unwrap(); + module +} + +async fn wait_for_owner(connection: &Connection, name: &str, expected: Option) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if connection.name_owner(name).await.unwrap() == expected { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("module name ownership did not reach the expected state"); +} + +#[tokio::test] +async fn concurrent_linked_setups_replace_the_default_with_instance_bound_state() { + DEFAULT_SETUP_CALLS.store(0, Ordering::SeqCst); + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.clone().spawn(bus.clone()); + let host = Arc::new(ModuleHost::new(broker)); + let observer = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let setups = Arc::new(Mutex::new( + Vec::<(String, &'static str, String, Connection)>::new(), + )); + + let setup_a = setups.clone(); + let host_a = host.clone(); + let first = host.attach_linked_with_setup( + linked_export("linked-a"), + serde_json::json!({}), + move |connection| async move { + let peer = connection.unique_name().expect("connected module peer"); + assert_eq!( + host_a + .module_for_sender(&peer) + .as_ref() + .map(tinybus::module::ModuleId::as_str), + Some("linked-a") + ); + connection + .serve_at("/linked/setup".parse().unwrap(), SetupValue("a")) + .await?; + setup_a.lock().unwrap().push(( + "linked-a".to_string(), + "a", + peer.to_string(), + connection, + )); + Ok(()) + }, + ); + + let setup_b = setups.clone(); + let host_b = host.clone(); + let second = host.attach_linked_with_setup( + linked_export("linked-abcdefghijklmnopqrstuvwxyz0123456789"), + serde_json::json!({}), + move |connection| async move { + let peer = connection.unique_name().expect("connected module peer"); + assert_eq!( + host_b + .module_for_sender(&peer) + .as_ref() + .map(tinybus::module::ModuleId::as_str), + Some("linked-abcdefghijklmnopqrstuvwxyz0123456789") + ); + connection + .serve_at("/linked/setup".parse().unwrap(), SetupValue("b")) + .await?; + setup_b.lock().unwrap().push(( + "linked-abcdefghijklmnopqrstuvwxyz0123456789".to_string(), + "b", + peer.to_string(), + connection, + )); + Ok(()) + }, + ); + + let (first, second) = tokio::join!(first, second); + assert_eq!(first.unwrap().state, ModuleState::Ready); + assert_eq!(second.unwrap().state, ModuleState::Ready); + assert_eq!(DEFAULT_SETUP_CALLS.load(Ordering::SeqCst), 0); + + let setups = std::mem::take(&mut *setups.lock().unwrap()); + assert_eq!(setups.len(), 2); + assert_ne!(setups[0].2, setups[1].2); + for (id, expected, peer, connection) in &setups { + let proxy = observer + .proxy(peer, "/linked/setup", "org.example.LinkedSetupValue") + .unwrap(); + let value: serde_json::Value = proxy.call("Value", ()).await.unwrap(); + assert_eq!(value, serde_json::json!(expected)); + assert_eq!( + host.module_for_sender(&peer.parse().unwrap()) + .as_ref() + .map(tinybus::module::ModuleId::as_str), + Some(id.as_str()) + ); + + let attestation = observer + .attestation(format!("ai.tinyhumans.test.{id}").parse().unwrap()) + .await + .unwrap() + .expect("stateful linked setup keeps executable attestation"); + assert_eq!( + attestation.sha256, + tinybus::module::sha256_file(std::env::current_exe().unwrap()).unwrap() + ); + assert!(connection.unique_name().is_some()); + } + broker_task.abort(); +} + +#[tokio::test] +async fn runtime_setup_can_export_a_weak_sender_lookup_for_linked_setup() { + fn assert_owned_thread_safe() {} + assert_owned_thread_safe::(); + + let stored_lookup = Arc::new(Mutex::new(None)); + let capture_lookup = stored_lookup.clone(); + let setup: RuntimeSetup = Box::new(move |_connection, host| { + Box::pin(async move { + *capture_lookup.lock().unwrap() = Some(host.sender_lookup()); + Ok(()) + }) + }); + let runtime = ModuleRuntime::start(&RuntimeNames::default(), Some(setup)).unwrap(); + let lookup = stored_lookup + .lock() + .unwrap() + .take() + .expect("runtime setup should export an owned sender lookup"); + + let id = "linked_runtime_sender_lookup"; + let bus_name = format!("ai.tinyhumans.test.{id}").parse().unwrap(); + let lookup_during_setup = lookup.clone(); + let (peer_tx, peer_rx) = tokio::sync::oneshot::channel(); + let info = runtime + .host() + .attach_linked_with_setup( + linked_export(id), + serde_json::json!({}), + move |connection| async move { + let peer = connection.unique_name().expect("authenticated module peer"); + assert_eq!( + lookup_during_setup + .module_for_sender(&peer) + .as_ref() + .map(ModuleId::as_str), + Some(id) + ); + assert!(lookup_during_setup.module_for_sender(&bus_name).is_none()); + let unrelated: tinybus::BusName = ":1.999999".parse().unwrap(); + assert!(lookup_during_setup.module_for_sender(&unrelated).is_none()); + peer_tx.send(peer).expect("test receiver is alive"); + connection + .serve_at( + "/linked/runtime_lookup".parse().unwrap(), + SetupValue("ready"), + ) + .await?; + Ok(()) + }, + ) + .await + .unwrap(); + assert_eq!(info.state, ModuleState::Ready); + + let peer = peer_rx.await.unwrap(); + assert_eq!( + lookup + .module_for_sender(&peer) + .as_ref() + .map(ModuleId::as_str), + Some(id), + "the setup-owned handle must survive RuntimeSetup returning" + ); +} + +#[tokio::test] +async fn a_failing_stateful_setup_faults_and_releases_its_module_peer() { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.clone().spawn(bus.clone()); + let host = ModuleHost::new(broker); + let observer = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let module = linked_export("linked_failure"); + let bus_name = module.manifest.bus_name.clone(); + let result = host + .attach_linked_with_setup(module, serde_json::json!({}), |connection| async move { + connection + .serve_at("/linked/partial".parse().unwrap(), SetupValue("partial")) + .await?; + Err(tinybus::Error::failed("stateful setup refused")) + }) + .await; + assert!(result.is_err()); + assert!(host.list().iter().any(|module| { + module.manifest.bus_name == bus_name + && matches!( + module.state, + ModuleState::Failed { .. } | ModuleState::Faulted { .. } + ) + })); + wait_for_owner(&observer, bus_name.as_str(), None).await; + broker_task.abort(); +} + +#[tokio::test] +async fn canceling_pending_linked_setup_denies_late_ready_and_releases_the_name() { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.clone().spawn(bus.clone()); + let host = Arc::new(ModuleHost::new(broker)); + let observer = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let module = linked_export("linked_cancel_pending"); + let bus_name = module.manifest.bus_name.clone(); + let release = Arc::new(tokio::sync::Semaphore::new(0)); + let release_setup = release.clone(); + let (peer_tx, peer_rx) = tokio::sync::oneshot::channel(); + let (settled_tx, settled_rx) = tokio::sync::oneshot::channel(); + let attaching_host = host.clone(); + let mut attaching = tokio::spawn(async move { + attaching_host + .attach_linked_with_setup( + module, + serde_json::json!({}), + move |connection| async move { + let peer = connection.unique_name().expect("module peer"); + let _ = peer_tx.send(peer); + let _permit = release_setup + .acquire() + .await + .expect("release pending setup"); + let served = connection + .serve_at("/linked/pending".parse().unwrap(), SetupValue("late")) + .await; + let _ = settled_tx.send(served.is_ok()); + served + }, + ) + .await + }); + + let peer = tokio::select! { + result = &mut attaching => panic!("linked attach ended before setup entered: {result:?}"), + peer = peer_rx => peer.expect("linked setup callback did not provide its authenticated peer"), + }; + assert_eq!( + host.module_for_sender(&peer) + .as_ref() + .map(tinybus::module::ModuleId::as_str), + Some("linked_cancel_pending") + ); + attaching.abort(); + assert!(matches!(attaching.await, Err(error) if error.is_cancelled())); + assert!(host.module_for_sender(&peer).is_none()); + release.add_permits(1); + let settled = tokio::time::timeout(Duration::from_secs(5), settled_rx) + .await + .expect("late setup callback did not settle"); + assert!(matches!(settled, Ok(true) | Err(_))); + assert!(host.module_for_sender(&peer).is_none()); + wait_for_owner(&observer, bus_name.as_str(), None).await; + assert!( + observer + .proxy(&bus_name, "/linked/pending", "org.example.LinkedSetupValue") + .unwrap() + .call::("Value", serde_json::json!([])) + .await + .is_err() + ); + assert!(host.list().iter().all(|module| { + module.manifest.bus_name != bus_name + || !matches!(module.state, ModuleState::Ready | ModuleState::Serving) + })); + broker_task.abort(); +} + +#[tokio::test] +async fn timed_out_linked_setup_denies_late_ready_and_releases_the_name() { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.clone().spawn(bus.clone()); + let host = Arc::new(ModuleHost::new(broker)); + let observer = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let module = linked_export("linked_timeout_pending"); + let bus_name = module.manifest.bus_name.clone(); + let release = Arc::new(tokio::sync::Semaphore::new(0)); + let release_setup = release.clone(); + let (peer_tx, peer_rx) = tokio::sync::oneshot::channel(); + let (settled_tx, settled_rx) = tokio::sync::oneshot::channel(); + let attaching_host = host.clone(); + let mut attaching = tokio::spawn(async move { + attaching_host + .attach_linked_with_setup( + module, + serde_json::json!({}), + move |connection| async move { + let peer = connection.unique_name().expect("module peer"); + let _ = peer_tx.send(peer); + let _permit = release_setup + .acquire() + .await + .expect("release pending setup"); + let served = connection + .serve_at("/linked/pending".parse().unwrap(), SetupValue("late")) + .await; + let _ = settled_tx.send(served.is_ok()); + served + }, + ) + .await + }); + + let peer = tokio::select! { + result = &mut attaching => panic!("linked attach ended before setup entered: {result:?}"), + peer = peer_rx => peer.expect("linked setup callback did not provide its authenticated peer"), + }; + assert_eq!( + host.module_for_sender(&peer) + .as_ref() + .map(tinybus::module::ModuleId::as_str), + Some("linked_timeout_pending") + ); + assert!(attaching.await.unwrap().is_err()); + assert!(host.module_for_sender(&peer).is_none()); + release.add_permits(1); + let settled = tokio::time::timeout(Duration::from_secs(5), settled_rx) + .await + .expect("late setup callback did not settle"); + assert!(matches!(settled, Ok(true) | Err(_))); + assert!(host.module_for_sender(&peer).is_none()); + wait_for_owner(&observer, bus_name.as_str(), None).await; + assert!( + observer + .proxy(&bus_name, "/linked/pending", "org.example.LinkedSetupValue") + .unwrap() + .call::("Value", serde_json::json!([])) + .await + .is_err() + ); + assert!(host.list().iter().all(|module| { + module.manifest.bus_name != bus_name + || !matches!(module.state, ModuleState::Ready | ModuleState::Serving) + })); + broker_task.abort(); +} + +#[tokio::test] +async fn linked_setup_admission_rejects_unmet_dependencies_before_running_the_callback() { + let broker = Broker::new(); + let host = ModuleHost::new(broker); + let mut module = linked_export("linked_requires_missing"); + module + .manifest + .requires + .push(tinybus::module::manifest::Dependency { + interface: tinybus::InterfaceVersion::consumed( + "ai.tinyhumans.test.Missing".parse().unwrap(), + tinybus::version::Version::new(1, 0, 0), + ), + optional: false, + reason: String::new(), + }); + let ran = Arc::new(AtomicUsize::new(0)); + let called = ran.clone(); + let result = host + .attach_linked_with_setup(module, serde_json::json!({}), move |_| async move { + called.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + .await; + assert!(result.is_err()); + assert_eq!(ran.load(Ordering::SeqCst), 0); + assert!(host.list().is_empty()); + + let mut invalid_descriptor = linked_export("linked_bad_descriptor"); + invalid_descriptor.descriptor.module_version = [0; 32]; + invalid_descriptor.descriptor.module_version[..3].copy_from_slice(b"9.9"); + let callback_ran = ran.clone(); + let result = host + .attach_linked_with_setup( + invalid_descriptor, + serde_json::json!({}), + move |_| async move { + callback_ran.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .await; + assert!(result.is_err()); + assert_eq!(ran.load(Ordering::SeqCst), 0); + assert!(host.list().iter().any(|module| { + module.name == "linked_bad_descriptor" + && matches!(module.state, ModuleState::Rejected { .. }) + })); + + let mut invalid_identity = linked_export("linked_bad_identity"); + invalid_identity.manifest.module.name.push('\n'); + let callback_ran = Arc::new(AtomicUsize::new(0)); + let callback_count = callback_ran.clone(); + let result = host + .attach_linked_with_setup( + invalid_identity, + serde_json::json!({}), + move |_| async move { + callback_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .await; + assert!(result.is_err()); + assert_eq!(callback_ran.load(Ordering::SeqCst), 0); +} diff --git a/crates/tinybus-module/src/lib_setup_factory_tests.rs b/crates/tinybus-module/src/lib_setup_factory_tests.rs index 1fe9cb9d..6a208d3f 100644 --- a/crates/tinybus-module/src/lib_setup_factory_tests.rs +++ b/crates/tinybus-module/src/lib_setup_factory_tests.rs @@ -275,6 +275,287 @@ async fn factory_callback_with_copying_clone_keeps_initial_admission_on_reinitia assert!(proxy.call::("Mode", ()).await.unwrap()); } +mod context_factory_export { + #![deny(missing_docs)] + + use super::*; + use std::sync::atomic::AtomicUsize; + + /// Counts constructions so the test can distinguish instances from setups. + pub(super) static FACTORIES: AtomicUsize = AtomicUsize::new(0); + + #[derive(serde::Deserialize)] + struct Config { + restricted: bool, + } + + struct Service { + mode: bool, + generation: usize, + trusted_host: TrustedHost, + } + + #[async_trait::async_trait] + impl tinybus::service::Interface for Service { + fn name(&self) -> tinybus::InterfaceName { + "org.example.ContextFactory".parse().unwrap() + } + + fn members(&self) -> Vec { + vec!["Inspect".parse().unwrap()] + } + + fn requires_confidential(&self, _: &tinybus::MemberName) -> bool { + true + } + + async fn call( + &self, + _: &tinybus::MemberName, + _: serde_json::Value, + ) -> tinybus::Result { + Err(tinybus::Error::failed("context-aware dispatch required")) + } + + async fn call_with_context( + &self, + member: &tinybus::MemberName, + args: serde_json::Value, + context: &tinybus::CallContext, + ) -> tinybus::Result { + if !self.trusted_host.authorizes(context) { + return Err(tinybus::Error::failed( + "factory context authority unavailable", + )); + } + if member.as_str() != "Inspect" { + return Err(tinybus::Error::failed("unknown method")); + } + if args != serde_json::json!([]) { + return Err(tinybus::Error::failed("expected no arguments")); + } + Ok(serde_json::json!({ + "restricted": self.mode, + "generation": self.generation, + })) + } + } + + fn factory() -> impl Fn(ModuleSetupContext, Config) -> SetupFuture + Send + Sync + 'static { + FACTORIES.fetch_add(1, Ordering::SeqCst); + let mode = Arc::new(tokio::sync::Mutex::new(None)); + let generation = Arc::new(AtomicUsize::new(0)); + move |context, config: Config| { + let mode = Arc::clone(&mode); + let generation = Arc::clone(&generation); + Box::pin(async move { + let mut admitted = mode.lock().await; + if admitted.is_some_and(|initial| initial != config.restricted) { + return Err(tinybus::Error::failed("immutable mode")); + } + *admitted = Some(config.restricted); + drop(admitted); + let trusted_host = context + .trusted_host() + .cloned() + .ok_or_else(|| tinybus::Error::failed("trusted host unavailable"))?; + let generation = generation.fetch_add(1, Ordering::SeqCst) + 1; + context + .connection() + .serve_at( + "/context_factory".parse()?, + Service { + mode: config.restricted, + generation, + trusted_host, + }, + ) + .await?; + context + .connection() + .request_name("org.example.ContextFactory") + .await + }) + } + } + + crate::module_export_static! { + setup_factory_with_context = factory, + config = Config, + worker_threads = 1, + provides = ["org.example.ContextFactory"], + methods = ["Inspect"], + signals = [], + requires = [], + optional = [], + lazy = false, + } + + #[test] + fn invalid_initial_configuration_does_not_construct_context_factory_state() { + let _guard = TEST_LOCK.blocking_lock(); + let before = FACTORIES.load(Ordering::SeqCst); + let host = ModuleHost::new(Broker::new()); + assert!( + host.attach_linked_with_config( + linked_module().unwrap(), + serde_json::json!({"restricted":"invalid"}), + ) + .is_err() + ); + assert_eq!(FACTORIES.load(Ordering::SeqCst), before); + } + + #[test] + fn invalid_host_is_rejected_before_context_factory_construction() { + let _guard = TEST_LOCK.blocking_lock(); + let before = FACTORIES.load(Ordering::SeqCst); + let result = unsafe { + start_linked_reconfigurable_module_with_context_factory::( + std::ptr::null(), + std::ptr::null_mut(), + 1, + true, + factory, + ) + }; + assert_eq!(result, TB_BAD_ARGUMENT); + assert_eq!(FACTORIES.load(Ordering::SeqCst), before); + } +} + +async fn contextual_factory_instance( + restricted: bool, +) -> (ModuleHost, Broker, Connection, tinybus::Proxy) { + use tinybus::transport::memory::MemoryTransport; + + let broker = Broker::new_restricted_module_control(); + let (broker_transport, controller_transport) = MemoryTransport::pair(); + let selected_controller = broker + .attach_module_controller(Arc::new(broker_transport)) + .unwrap(); + let controller = Connection::connect(Box::new(controller_transport)) + .await + .unwrap(); + assert_eq!( + controller.unique_name().as_ref(), + Some(&selected_controller) + ); + let host = ModuleHost::new(broker.clone()); + host.attach_linked_with_config( + context_factory_export::linked_module().unwrap(), + serde_json::json!({"restricted":restricted}), + ) + .unwrap(); + let proxy = controller + .proxy( + "org.example.ContextFactory", + "/context_factory", + "org.example.ContextFactory", + ) + .unwrap(); + (host, broker, controller, proxy) +} + +#[tokio::test(flavor = "current_thread")] +async fn contextual_factory_is_per_instance_authorized_confidential_and_reinit_stable() { + let _guard = TEST_LOCK.lock().await; + use tinybus::transport::memory::MemoryTransport; + + let before = context_factory_export::FACTORIES.load(Ordering::SeqCst); + let ((first, broker, controller, first_proxy), (_second, _, _, second_proxy)) = tokio::join!( + contextual_factory_instance(true), + contextual_factory_instance(false) + ); + assert_eq!( + context_factory_export::FACTORIES.load(Ordering::SeqCst) - before, + 2, + "the context factory must run once for each ABI instance" + ); + assert!(first_proxy.attestation().await.unwrap().is_some()); + assert!( + first_proxy + .call::("Inspect", ()) + .await + .is_err() + ); + assert_eq!( + first_proxy + .call_confidential::("Inspect", ()) + .await + .unwrap(), + serde_json::json!({"restricted":true,"generation":1}) + ); + assert_eq!( + second_proxy + .call_confidential::("Inspect", ()) + .await + .unwrap(), + serde_json::json!({"restricted":false,"generation":1}) + ); + + let (outsider_server, outsider_client) = MemoryTransport::pair(); + broker.attach(Arc::new(outsider_server)); + let outsider = Connection::connect(Box::new(outsider_client)) + .await + .unwrap(); + let outsider_proxy = outsider + .proxy( + "org.example.ContextFactory", + "/context_factory", + "org.example.ContextFactory", + ) + .unwrap(); + let denied = outsider_proxy + .call_confidential::("Inspect", serde_json::json!("malformed")) + .await + .unwrap_err(); + assert!( + denied + .to_string() + .contains("factory context authority unavailable") + ); + let malformed = first_proxy + .call_confidential::("Inspect", serde_json::json!("malformed")) + .await + .unwrap_err(); + assert!(malformed.to_string().contains("expected no arguments")); + + first + .reinitialize("tinybus-module", serde_json::json!({"restricted":true})) + .await + .unwrap(); + assert_eq!( + first_proxy + .call_confidential::("Inspect", ()) + .await + .unwrap(), + serde_json::json!({"restricted":true,"generation":2}) + ); + assert!( + first + .reinitialize("tinybus-module", serde_json::json!({"restricted":false})) + .await + .is_err() + ); + assert_eq!( + first_proxy + .call_confidential::("Inspect", ()) + .await + .unwrap(), + serde_json::json!({"restricted":true,"generation":2}), + "a refused reinitialization must leave the live service and its trusted context intact" + ); + assert_eq!( + second_proxy + .call_confidential::("Inspect", ()) + .await + .unwrap(), + serde_json::json!({"restricted":false,"generation":1}) + ); + controller.close().await.unwrap(); +} + mod failed_setup { use super::*; pub(super) static DROPPED: AtomicUsize = AtomicUsize::new(0); diff --git a/crates/tinybus-module/src/lib_terminal_tests.rs b/crates/tinybus-module/src/lib_terminal_tests.rs index 946f0130..d0a50736 100644 --- a/crates/tinybus-module/src/lib_terminal_tests.rs +++ b/crates/tinybus-module/src/lib_terminal_tests.rs @@ -23,6 +23,7 @@ fn host() -> TbHostVtable { }, broker_routing: None, native_terminal_state: None, + trusted_host_binding: None, } } fn output() -> TbModuleVtable { @@ -316,6 +317,9 @@ mod controlled { pub payload_drops: AtomicUsize, pub config_drops: AtomicUsize, pub config_parse_control: StdMutex>>, + pub initial_poll_gate: StdMutex>>, + pub request_gate: + StdMutex, std::sync::mpsc::Receiver<()>)>>, pub drop_off_lock: AtomicBool, pub request_off_lock: AtomicBool, pub future_drop_control: StdMutex, bool)>>, @@ -339,6 +343,8 @@ mod controlled { payload_drops: AtomicUsize::new(0), config_drops: AtomicUsize::new(0), config_parse_control: StdMutex::new(None), + initial_poll_gate: StdMutex::new(None), + request_gate: StdMutex::new(None), drop_off_lock: AtomicBool::new(true), request_off_lock: AtomicBool::new(true), future_drop_control: StdMutex::new(None), @@ -386,6 +392,9 @@ mod controlled { if self.first { self.first = false; self.fixture.events.send(Event::Poll(self.mode)).unwrap(); + if let Some(gate) = self.fixture.initial_poll_gate.lock().unwrap().take() { + gate.wait(); + } } match self.mode { 0 => Poll::Ready(Ok(())), @@ -509,6 +518,10 @@ mod controlled { *request.wake.0.lock().unwrap() = true; request.wake.1.notify_all(); request.events.send(Event::Request).unwrap(); + if let Some((entered, release)) = request.request_gate.lock().unwrap().take() { + let _ = entered.send(()); + let _ = release.recv(); + } if request.hook_failure.load(Ordering::Acquire) == 1 { std::panic::panic_any(HookPayload(request.clone())); } @@ -750,6 +763,11 @@ async fn actual_linked_hook_panics_and_uncertain_finish_preserve_once_join_and_a async fn actual_linked_rejected_physical_spawn_retains_received_resources_without_retry() { let _serial = controlled::TEST_LOCK.lock().await; let (fixture, events) = controlled::fixture(); + let poll_gate = Arc::new(std::sync::Barrier::new(2)); + *fixture.initial_poll_gate.lock().unwrap() = Some(poll_gate.clone()); + let (request_entered, request_entered_rx) = std::sync::mpsc::channel(); + let (request_release_tx, request_release_rx) = std::sync::mpsc::channel(); + *fixture.request_gate.lock().unwrap() = Some((request_entered, request_release_rx)); let broker = tinybus::broker::Broker::new(); broker .clone() @@ -762,9 +780,42 @@ async fn actual_linked_rejected_physical_spawn_retains_received_resources_withou .unwrap(); let entered = events.recv_timeout(Duration::from_secs(3)).unwrap(); let view = fixture.view.lock().unwrap().take().unwrap(); + assert!(matches!(entered, controlled::Event::Poll(1))); + let future_was_stored_before_its_poll_returned = view + .inner + .resources + .lock() + .unwrap() + .initial_future + .lock() + .unwrap() + .is_some(); + // Poll is sent from inside SetupFuture::poll, while TerminalSetupPoll has + // temporarily taken the future out of its storage slot. + poll_gate.wait(); + assert!(!future_was_stored_before_its_poll_returned); + view.inner.wait_initial_future_restored_for_test().await; + assert!( + view.inner + .resources + .lock() + .unwrap() + .initial_future + .lock() + .unwrap() + .is_some() + ); let context = view.inner.context(); view.inner.reject_worker_spawn_for_test(); - view.request_fault(); + + // Hold the first request callback after its bounded wake signal. A second + // terminal cause then hits the already-running callback and rejects the + // physical worker submission. + let fault_view = view.clone(); + let fault = std::thread::spawn(move || fault_view.request_fault()); + request_entered_rx + .recv_timeout(Duration::from_secs(3)) + .unwrap(); host.shutdown(Duration::from_secs(3)).await; // Submission failure cannot execute original finish. The fixture alone // releases/joins its independent worker; this is not SDK finish evidence. @@ -773,9 +824,8 @@ async fn actual_linked_rejected_physical_spawn_retains_received_resources_withou let observations: Vec<_> = events.try_iter().collect(); let native = view.inner.host.0; let flags = unsafe { native.native_terminal_state.unwrap()(native.host_ctx) }; - assert!(matches!(entered, controlled::Event::Poll(1))); assert_eq!(completed, TerminalCompletion::Uncertain); - assert!(view.inner.request_done_for_test()); + assert!(!view.inner.request_done_for_test()); assert_ne!(flags & tinybus::module::abi::TB_NATIVE_TERMINAL_FAULT, 0); assert_eq!(fixture.requests.load(Ordering::Acquire), 1); assert_eq!(fixture.finishes.load(Ordering::Acquire), 0); @@ -812,6 +862,19 @@ async fn actual_linked_rejected_physical_spawn_retains_received_resources_withou assert!(resources.runtime.is_some()); assert!(resources.initial.is_some()); assert!(resources.initial_future.lock().unwrap().is_some()); + drop(resources); + + // The sender disconnects on assertion failure, so this test-only gate + // cannot strand the request thread during unwinding. + let _ = request_release_tx.send(()); + fault.join().unwrap(); + // A re-entrant host shutdown may own and finish this one-shot callback on + // its physical worker; joining the signal trigger need not join that worker. + assert!( + view.inner + .wait_request_done_for_test(Duration::from_secs(3)) + ); + assert!(view.inner.request_done_for_test()); // One deliberately retained, bounded original State/job/Runtime remains. } diff --git a/crates/tinybus-module/src/lib_tests.rs b/crates/tinybus-module/src/lib_tests.rs index 07bf9f4f..bdf9aa6e 100644 --- a/crates/tinybus-module/src/lib_tests.rs +++ b/crates/tinybus-module/src/lib_tests.rs @@ -1,4 +1,8 @@ use super::*; +#[cfg(feature = "static-link")] +use std::future::Future; +#[cfg(feature = "static-link")] +use std::pin::Pin; use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize}; use std::sync::{OnceLock, mpsc::SyncSender}; @@ -8,6 +12,14 @@ static HOST_LOGS: AtomicUsize = AtomicUsize::new(0); static HOST_READY: AtomicBool = AtomicBool::new(false); static HOST_FAULTED: AtomicBool = AtomicBool::new(false); static START_OUTGOING: OnceLock>>>> = OnceLock::new(); +static TRUSTED_HOST_REPLY: AtomicUsize = AtomicUsize::new(0); +static CAPTURED_TRUSTED_HOST: OnceLock>> = OnceLock::new(); +static CAPTURED_MODULE_NAME: OnceLock>> = OnceLock::new(); +static CAPTURED_MODULE_CONTEXT: OnceLock>> = OnceLock::new(); +#[cfg(feature = "static-link")] +static FOREIGN_CONTEXT_SENDER: OnceLock< + StdMutex>>, +> = OnceLock::new(); // These tests share host callbacks and the module's process-global runtime // capture, so overlapping tests would make their assertions order-dependent. static HOST_STATE_LOCK: OnceLock> = OnceLock::new(); @@ -75,9 +87,551 @@ fn host(config: &[u8]) -> TbHostVtable { ready: host_ready, broker_routing: None, native_terminal_state: None, + trusted_host_binding: None, } } +unsafe extern "C" fn test_trusted_host_binding( + _: *mut c_void, + out: *mut u8, + capacity: usize, + out_len: *mut usize, +) -> i32 { + if out_len.is_null() { + return TB_BAD_ARGUMENT; + } + let (code, bytes): (i32, &[u8]) = match TRUSTED_HOST_REPLY.load(Ordering::Acquire) { + 0 => (TB_OK, b":1.2"), + 1 => (TB_OK, b":1.3"), + 2 => (TB_CLOSED, b""), + 3 => { + unsafe { out_len.write(capacity + 1) }; + return TB_OK; + } + 4 => (TB_OK, b"\xff"), + 5 => (TB_OK, b"org.example.Controller"), + _ => (91, b""), + }; + unsafe { out_len.write(bytes.len()) }; + if code != TB_OK { + return code; + } + if bytes.len() > capacity || (out.is_null() && !bytes.is_empty()) { + return TB_BAD_ARGUMENT; + } + if !bytes.is_empty() { + unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), out, bytes.len()) }; + } + TB_OK +} + +unsafe extern "C" fn assert_test_broker_routed(_: *mut c_void) -> u32 { + 1 +} + +async fn setup_with_context(context: ModuleSetupContext) -> Result<()> { + let trusted_host = context.trusted_host().cloned(); + *CAPTURED_TRUSTED_HOST + .get_or_init(|| StdMutex::new(None)) + .lock() + .expect("captured trusted host lock") = trusted_host.clone(); + *CAPTURED_MODULE_NAME + .get_or_init(|| StdMutex::new(None)) + .lock() + .expect("captured module name lock") = context.connection().unique_name(); + context + .connection() + .serve_at( + "/authorization".parse().unwrap(), + ContextAuthorizationProbe { trusted_host }, + ) + .await?; + Ok(()) +} + +struct ContextAuthorizationProbe { + trusted_host: Option, +} + +#[async_trait::async_trait] +impl tinybus::service::Interface for ContextAuthorizationProbe { + fn name(&self) -> tinybus::InterfaceName { + "org.example.TrustedHostProbe".parse().unwrap() + } + + fn members(&self) -> Vec { + vec!["Authorize".parse().unwrap()] + } + + async fn call( + &self, + _: &tinybus::MemberName, + _: serde_json::Value, + ) -> tinybus::Result { + Err(Error::failed("context-aware dispatch required")) + } + + async fn call_with_context( + &self, + _: &tinybus::MemberName, + _: serde_json::Value, + context: &CallContext, + ) -> tinybus::Result { + *CAPTURED_MODULE_CONTEXT + .get_or_init(|| StdMutex::new(None)) + .lock() + .expect("captured module context lock") = Some(context.clone()); + Ok(serde_json::json!(self.trusted_host.as_ref().is_some_and( + |trusted_host| trusted_host.authorizes(context) + ))) + } +} + +#[cfg(feature = "static-link")] +async fn setup_foreign_context_capture(connection: Connection) -> Result<()> { + connection + .serve_at("/capture".parse().unwrap(), ForeignContextCapture) + .await?; + Ok(()) +} + +#[cfg(feature = "static-link")] +struct ForeignContextCapture; + +#[cfg(feature = "static-link")] +#[async_trait::async_trait] +impl tinybus::service::Interface for ForeignContextCapture { + fn name(&self) -> tinybus::InterfaceName { + "org.example.ForeignContext".parse().unwrap() + } + + fn members(&self) -> Vec { + vec!["Capture".parse().unwrap()] + } + + async fn call( + &self, + _: &tinybus::MemberName, + _: serde_json::Value, + ) -> tinybus::Result { + Err(Error::failed("context-aware dispatch required")) + } + + async fn call_with_context( + &self, + _: &tinybus::MemberName, + _: serde_json::Value, + context: &CallContext, + ) -> tinybus::Result { + FOREIGN_CONTEXT_SENDER + .get_or_init(|| StdMutex::new(None)) + .lock() + .expect("foreign context sender lock") + .take() + .expect("one foreign context capture") + .send(context.clone()) + .map_err(|_| Error::failed("foreign context receiver closed"))?; + Ok(serde_json::Value::Null) + } +} + +#[cfg(feature = "static-link")] +mod dynamic_context_export { + crate::module_export! { + setup_factory_with_context = super::setup_context_factory, + config = super::ContextFactoryConfig, + worker_threads = 1, + provides = ["org.example.DynamicContext"], + methods = ["Authorize"], + signals = [], + requires = [], + optional = [], + lazy = false, + } +} + +#[cfg(feature = "static-link")] +unsafe extern "C" fn plain_context_setup_entry( + host: *const TbHostVtable, + out: *mut TbModuleVtable, +) -> i32 { + unsafe { start_module_with_context(host, out, 1, true, setup_with_context) } +} + +#[cfg(feature = "static-link")] +static CONTEXT_FACTORY_CREATIONS: AtomicUsize = AtomicUsize::new(0); +#[cfg(feature = "static-link")] +static CONTEXT_FACTORY_SETUPS: AtomicUsize = AtomicUsize::new(0); + +#[cfg(feature = "static-link")] +#[derive(serde::Deserialize)] +struct ContextFactoryConfig {} + +#[cfg(feature = "static-link")] +type ContextFactorySetupFuture = Pin> + Send>>; + +#[cfg(feature = "static-link")] +fn setup_context_factory() +-> impl Fn(ModuleSetupContext, ContextFactoryConfig) -> ContextFactorySetupFuture + Send + Sync + 'static +{ + CONTEXT_FACTORY_CREATIONS.fetch_add(1, Ordering::SeqCst); + let setups = Arc::new(AtomicUsize::new(0)); + move |context, _config| { + let setups = Arc::clone(&setups); + Box::pin(async move { + setups.fetch_add(1, Ordering::SeqCst); + CONTEXT_FACTORY_SETUPS.fetch_add(1, Ordering::SeqCst); + setup_with_context(context).await + }) + } +} + +#[allow(dead_code)] +mod static_context_export { + crate::module_export_static! { + setup_with_context = super::setup_with_context, + worker_threads = 1, + provides = ["org.example.StaticContext"], + methods = ["Authorize"], + signals = [], + requires = [], + optional = [], + lazy = false, + } +} + +#[cfg(feature = "static-link")] +mod foreign_context_export { + crate::module_export_static! { + setup = super::setup_foreign_context_capture, + worker_threads = 1, + provides = ["org.example.ForeignContext"], + methods = ["Capture"], + signals = [], + requires = [], + optional = [], + lazy = false, + } +} + +fn start_context_export( + init: unsafe extern "C" fn(*const TbHostVtable, *mut TbModuleVtable) -> i32, + include_binding: bool, + include_routing: bool, +) -> (TbModuleVtable, std::sync::mpsc::Receiver>) { + HOST_READY.store(false, Ordering::Release); + HOST_FAULTED.store(false, Ordering::Release); + let (outgoing_tx, outgoing_rx) = std::sync::mpsc::sync_channel(8); + *START_OUTGOING + .get_or_init(|| StdMutex::new(None)) + .lock() + .expect("startup capture lock") = Some(outgoing_tx); + let mut host = host(b"{}"); + host.send = capture_host_send; + if include_binding { + host.trusted_host_binding = Some(test_trusted_host_binding); + } + if include_routing { + host.broker_routing = Some(assert_test_broker_routed); + } + if !include_binding && !include_routing { + host.size = tinybus::module::abi::TB_HOST_VTABLE_BASE_SIZE; + } + let mut out = TbModuleVtable::default(); + assert_eq!(unsafe { init(&host, &mut out) }, TB_OK); + let hello_bytes = outgoing_rx + .recv_timeout(Duration::from_secs(1)) + .expect("contextual module sends Hello"); + let hello: Message = serde_json::from_slice(&hello_bytes).unwrap(); + let reply = Message::method_return(&hello.header, serde_json::json!(":1.1")); + let reply_bytes = serde_json::to_vec(&reply).unwrap(); + assert_eq!( + unsafe { (out.deliver)(out.module_ctx, reply_bytes.as_ptr(), reply_bytes.len()) }, + TB_OK + ); + let deadline = std::time::Instant::now() + Duration::from_secs(1); + while !HOST_READY.load(Ordering::Acquire) { + assert!( + std::time::Instant::now() < deadline, + "contextual setup did not become ready" + ); + std::thread::yield_now(); + } + (out, outgoing_rx) +} + +fn send_context_probe_call( + out: &TbModuleVtable, + receiver: &BusName, + sender: &str, + confidential: bool, + outgoing: &std::sync::mpsc::Receiver>, +) -> serde_json::Value { + let destination = receiver.clone(); + let path = "/authorization".parse().unwrap(); + let interface = "org.example.TrustedHostProbe".parse().unwrap(); + let member = "Authorize".parse().unwrap(); + let mut call = if confidential { + Message::confidential_call( + destination, + path, + interface, + member, + serde_json::Value::Null, + ) + } else { + Message::method_call( + destination, + path, + interface, + member, + serde_json::Value::Null, + ) + }; + call.header.sender = Some(sender.parse().unwrap()); + call.header.serial = 42; + let bytes = serde_json::to_vec(&call).unwrap(); + assert_eq!( + unsafe { (out.deliver)(out.module_ctx, bytes.as_ptr(), bytes.len()) }, + TB_OK + ); + let reply = outgoing + .recv_timeout(Duration::from_secs(1)) + .expect("contextual call reply"); + let reply: Message = serde_json::from_slice(&reply).unwrap(); + assert_eq!(reply.header.reply_serial, Some(42)); + reply.body +} + +#[tokio::test] +async fn trusted_host_binding_parser_fails_closed_for_missing_or_malformed_callbacks() { + let _host_state = host_state_guard().await; + let missing = HostCalls(host(b"{}")); + assert!(missing.trusted_host_peer().is_none()); + + let mut table = host(b"{}"); + table.trusted_host_binding = Some(test_trusted_host_binding); + let calls = HostCalls(table); + + TRUSTED_HOST_REPLY.store(0, Ordering::Release); + assert_eq!(calls.trusted_host_peer().unwrap().as_str(), ":1.2"); + TRUSTED_HOST_REPLY.store(1, Ordering::Release); + assert_eq!(calls.trusted_host_peer().unwrap().as_str(), ":1.3"); + for invalid in [2, 3, 4, 5, 6] { + TRUSTED_HOST_REPLY.store(invalid, Ordering::Release); + assert!(calls.trusted_host_peer().is_none(), "reply mode {invalid}"); + } +} + +#[cfg(feature = "static-link")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn contextual_dynamic_export_pins_and_revalidates_the_broker_controller() { + let _host_state = host_state_guard().await; + let before_factory = CONTEXT_FACTORY_CREATIONS.load(Ordering::SeqCst); + let before_setups = CONTEXT_FACTORY_SETUPS.load(Ordering::SeqCst); + TRUSTED_HOST_REPLY.store(0, Ordering::Release); + *CAPTURED_TRUSTED_HOST + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() = None; + *CAPTURED_MODULE_CONTEXT + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() = None; + + let init: unsafe extern "C" fn(*const TbHostVtable, *mut TbModuleVtable) -> i32 = + dynamic_context_export::tinybus_module_init_v1; + let (out, outgoing) = start_context_export(init, true, true); + assert_eq!( + CONTEXT_FACTORY_CREATIONS.load(Ordering::SeqCst) - before_factory, + 1, + "the context factory runs once for this ABI instance" + ); + assert_eq!( + CONTEXT_FACTORY_SETUPS.load(Ordering::SeqCst) - before_setups, + 1 + ); + let receiver = CAPTURED_MODULE_NAME + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() + .clone() + .expect("setup connection has its handshake name"); + let trusted_host = CAPTURED_TRUSTED_HOST + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() + .clone() + .expect("valid host callback issues a trusted capability"); + + assert_eq!( + send_context_probe_call(&out, &receiver, ":1.2", true, &outgoing), + serde_json::json!(true), + "the confidential call from the selected peer on the setup connection is authorized" + ); + assert_eq!( + send_context_probe_call(&out, &receiver, ":1.2", false, &outgoing), + serde_json::json!(false), + "a non-confidential call cannot use the migration capability" + ); + assert_eq!( + send_context_probe_call(&out, &receiver, ":1.3", true, &outgoing), + serde_json::json!(false), + "a different authenticated sender cannot use the cached controller identity" + ); + + TRUSTED_HOST_REPLY.store(1, Ordering::Release); + assert_eq!( + send_context_probe_call(&out, &receiver, ":1.2", true, &outgoing), + serde_json::json!(false), + "a fresh callback that names a replacement controller revokes the cached binding" + ); + TRUSTED_HOST_REPLY.store(2, Ordering::Release); + assert_eq!( + send_context_probe_call(&out, &receiver, ":1.2", true, &outgoing), + serde_json::json!(false), + "a disconnected controller revokes the cached binding" + ); + + TRUSTED_HOST_REPLY.store(0, Ordering::Release); + let (foreign_context, _foreign_caller, _foreign_host, _foreign_broker) = + capture_foreign_confidential_context().await; + assert_eq!( + foreign_context.authenticated_sender().unwrap().as_str(), + ":1.2" + ); + assert!(foreign_context.is_confidential()); + assert!( + !trusted_host.authorizes(&foreign_context), + "a confidential context from another live receiver cannot match even with the same sender name" + ); + + let captured = CAPTURED_MODULE_CONTEXT + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() + .clone() + .expect("authorization probe retained an actual routed context"); + let shutdown = out.shutdown; + let context = out.module_ctx as usize; + let shutdown_code = + tokio::task::spawn_blocking(move || unsafe { shutdown(context as *mut c_void, 1000) }) + .await + .unwrap(); + assert_eq!(shutdown_code, TB_OK, "contextual module shutdown code"); + assert!( + !trusted_host.authorizes(&captured), + "the cloned capability cannot outlive its module connection" + ); +} + +#[cfg(feature = "static-link")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn plain_context_setup_entry_receives_the_selected_controller_binding() { + let _host_state = host_state_guard().await; + TRUSTED_HOST_REPLY.store(0, Ordering::Release); + let (out, outgoing) = start_context_export(plain_context_setup_entry, true, true); + let receiver = CAPTURED_MODULE_NAME + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() + .clone() + .expect("plain context setup receives its attachment connection"); + assert_eq!( + send_context_probe_call(&out, &receiver, ":1.2", true, &outgoing), + serde_json::json!(true) + ); + let shutdown = out.shutdown; + let context = out.module_ctx as usize; + assert_eq!( + tokio::task::spawn_blocking(move || unsafe { shutdown(context as *mut c_void, 1000) }) + .await + .unwrap(), + TB_OK + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn contextual_static_export_accepts_an_old_host_without_the_optional_binding_tail() { + let _host_state = host_state_guard().await; + *CAPTURED_TRUSTED_HOST + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() = None; + + let init: unsafe extern "C" fn(*const TbHostVtable, *mut TbModuleVtable) -> i32 = + static_context_export::tinybus_module_init_v1; + let (out, outgoing) = start_context_export(init, false, false); + let receiver = CAPTURED_MODULE_NAME + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() + .clone() + .expect("old host still completes ordinary setup"); + assert!( + CAPTURED_TRUSTED_HOST + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() + .is_none(), + "an old host with no trusted-binding tail gives setup no capability" + ); + assert_eq!( + send_context_probe_call(&out, &receiver, ":1.2", true, &outgoing), + serde_json::json!(false) + ); + let shutdown = out.shutdown; + let context = out.module_ctx as usize; + assert_eq!( + tokio::task::spawn_blocking(move || unsafe { shutdown(context as *mut c_void, 1000) }) + .await + .unwrap(), + TB_OK + ); +} + +#[cfg(feature = "static-link")] +async fn capture_foreign_confidential_context() -> ( + CallContext, + Connection, + tinybus::module::ModuleHost, + tokio::task::JoinHandle>, +) { + let bus = tinybus::transport::memory::MemoryBus::new(); + let broker = tinybus::broker::Broker::new(); + let broker_task = broker.clone().spawn(bus.clone()); + let module_host = tinybus::module::ModuleHost::new(broker); + let linked = foreign_context_export::linked_module().unwrap(); + let info = module_host + .attach_linked_with_config(linked, serde_json::json!({})) + .unwrap(); + module_host + .ensure_ready(info.manifest.bus_name.as_str()) + .await + .unwrap(); + let (captured_tx, captured_rx) = tokio::sync::oneshot::channel(); + *FOREIGN_CONTEXT_SENDER + .get_or_init(|| StdMutex::new(None)) + .lock() + .expect("foreign context sender lock") = Some(captured_tx); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let expected_sender = BusName::new(":1.2").unwrap(); + assert_eq!(caller.unique_name(), Some(expected_sender)); + caller + .proxy( + info.manifest.bus_name.as_str(), + "/capture", + "org.example.ForeignContext", + ) + .unwrap() + .call_confidential::("Capture", ()) + .await + .unwrap(); + (captured_rx.await.unwrap(), caller, module_host, broker_task) +} + fn message() -> Message { Message::signal( "/org/example/Module".parse().unwrap(), @@ -260,6 +814,7 @@ async fn module_transport_maps_host_results_and_wakes_after_receiving() { let transport = ModuleTransport { host: HostCalls(host(&[])), inbound: Mutex::new(receiver), + closed: close_signal(), provenance: Arc::new(AtomicBool::new(false)), detach_on_panic: true, }; @@ -296,6 +851,145 @@ async fn module_transport_maps_host_results_and_wakes_after_receiving() { assert_eq!(transport.describe(), "module"); } +#[tokio::test] +async fn module_transport_close_wakes_an_idle_pending_receive() { + let _host_state = host_state_guard().await; + let (sender, receiver) = mpsc::channel(1); + let transport = Arc::new(ModuleTransport { + host: HostCalls(host(&[])), + inbound: Mutex::new(receiver), + closed: close_signal(), + provenance: Arc::new(AtomicBool::new(false)), + detach_on_panic: false, + }); + let receiving_transport = transport.clone(); + let (receive_started, receive_started_rx) = tokio::sync::oneshot::channel(); + let receiving = tokio::spawn(async move { + let _ = receive_started.send(()); + receiving_transport.recv().await + }); + receive_started_rx.await.unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if transport.inbound.try_lock().is_err() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("the module receive is pending while it owns the receiver"); + + let closing_transport = transport.clone(); + let (close_started, close_started_rx) = tokio::sync::oneshot::channel(); + let mut closing = tokio::spawn(async move { + let _ = close_started.send(()); + closing_transport.close().await + }); + close_started_rx.await.unwrap(); + match tokio::time::timeout(Duration::from_secs(2), &mut closing).await { + Ok(result) => assert!(result.unwrap().is_ok()), + Err(_) => { + // Negative control for the old implementation: one inbound frame + // releases the receiver mutex that close is incorrectly waiting on. + let frame = serde_json::to_vec(&message()).unwrap(); + sender.send((frame, false)).await.unwrap(); + assert!(receiving.await.unwrap().unwrap().is_some()); + assert!(closing.await.unwrap().is_ok()); + panic!("close must wake an idle module receive without another frame"); + } + } + assert!(receiving.await.unwrap().unwrap().is_none()); + transport.close().await.unwrap(); + assert!( + sender + .try_send((serde_json::to_vec(&message()).unwrap(), false)) + .is_err() + ); +} + +#[tokio::test] +async fn module_transport_close_before_recv_drains_queued_frames_and_rejects_new_ones() { + let _host_state = host_state_guard().await; + let (sender, receiver) = mpsc::channel(2); + let transport = ModuleTransport { + host: HostCalls(host(&[])), + inbound: Mutex::new(receiver), + closed: close_signal(), + provenance: Arc::new(AtomicBool::new(false)), + detach_on_panic: false, + }; + let first = message(); + let mut second = message(); + second.header.serial = 2; + for frame in [&first, &second] { + sender + .send((serde_json::to_vec(frame).unwrap(), false)) + .await + .unwrap(); + } + + transport.close().await.unwrap(); + transport.close().await.unwrap(); + assert_eq!(transport.recv().await.unwrap(), Some(first)); + assert_eq!(transport.recv().await.unwrap(), Some(second)); + assert!(transport.recv().await.unwrap().is_none()); + assert!( + sender + .try_send((serde_json::to_vec(&message()).unwrap(), false)) + .is_err() + ); +} + +#[tokio::test] +async fn module_transport_send_after_close_is_refused_before_host_delivery() { + let _host_state = host_state_guard().await; + let (_sender, receiver) = mpsc::channel(1); + let transport = ModuleTransport { + host: HostCalls(host(&[])), + inbound: Mutex::new(receiver), + closed: close_signal(), + provenance: Arc::new(AtomicBool::new(false)), + detach_on_panic: false, + }; + HOST_SEND_CODE.store(TB_OK, Ordering::Release); + transport.close().await.unwrap(); + + assert!(matches!( + transport.send(message()).await, + Err(Error::ConnectionClosed) + )); +} + +#[tokio::test] +async fn a_cancelled_module_transport_close_leaves_a_persistent_wakeup() { + let _host_state = host_state_guard().await; + let (sender, receiver) = mpsc::channel(1); + let transport = Arc::new(ModuleTransport { + host: HostCalls(host(&[])), + inbound: Mutex::new(receiver), + closed: close_signal(), + provenance: Arc::new(AtomicBool::new(false)), + detach_on_panic: false, + }); + let receiver_lock = transport.inbound.lock().await; + let closing_transport = transport.clone(); + let closing = tokio::spawn(async move { closing_transport.close().await }); + tokio::time::timeout(Duration::from_secs(2), async { + while !*transport.closed.borrow() { + tokio::task::yield_now().await; + } + }) + .await + .expect("close publishes its wakeup before waiting for the receiver lock"); + closing.abort(); + let _ = closing.await; + drop(receiver_lock); + + assert!(transport.recv().await.unwrap().is_none()); + assert!(sender.is_closed()); +} + #[tokio::test] async fn host_calls_and_subscriber_forward_logs_at_their_original_level() { let _host_state = host_state_guard().await; @@ -814,6 +1508,7 @@ async fn admission_never_promotes_a_frame_already_queued_without_provenance() { let transport = ModuleTransport { host: HostCalls(table), inbound: Mutex::new(receiver), + closed: close_signal(), provenance: Arc::new(AtomicBool::new(false)), detach_on_panic: false, }; @@ -843,6 +1538,7 @@ async fn old_hosts_retain_sender_headers_without_authenticating_them() { let transport = ModuleTransport { host: HostCalls(host(b"{}")), inbound: Mutex::new(receiver), + closed: close_signal(), provenance: Arc::new(AtomicBool::new(false)), detach_on_panic: false, }; @@ -911,6 +1607,7 @@ async fn unverified_native_frames_preserve_distinct_stream_owners_and_unverified let transport = Arc::new(ModuleTransport { host: HostCalls(table), inbound: Mutex::new(receiver), + closed: close_signal(), provenance: Arc::new(AtomicBool::new(false)), detach_on_panic: false, }); diff --git a/crates/tinybus-module/src/static_link_tests.rs b/crates/tinybus-module/src/static_link_tests.rs index 36590150..1ae941c8 100644 --- a/crates/tinybus-module/src/static_link_tests.rs +++ b/crates/tinybus-module/src/static_link_tests.rs @@ -56,26 +56,29 @@ mod configured { } mod optional { + #![deny(missing_docs)] + #[derive(serde::Deserialize)] struct Config {} - async fn setup(_: tinybus::Connection, _: Config) -> tinybus::Result<()> { - Ok(()) - } - fn setup_factory() -> impl Fn( - tinybus::Connection, + crate::ModuleSetupContext, Config, ) -> std::pin::Pin< Box> + Send>, - > + Clone - + Send + > + Send + Sync { - |connection, config| Box::pin(setup(connection, config)) + |context, _config| { + Box::pin(async move { + let _connection = context.connection(); + let _trusted_host = context.trusted_host(); + Ok(()) + }) + } } crate::module_export_optional_static! { - setup_factory = setup_factory, + setup_factory_with_context = setup_factory, config = Config, worker_threads = 1, provides = ["ai.tinyhumans.tinybus.OptionalStatic"], @@ -87,6 +90,48 @@ mod optional { } } +mod configured_context { + use crate::ModuleSetupContext; + + #[derive(serde::Deserialize)] + struct Config {} + + async fn setup(_: ModuleSetupContext, _: Config) -> tinybus::Result<()> { + Ok(()) + } + + crate::module_export_static! { + setup_with_context = setup, + config = Config, + worker_threads = 1, + provides = ["ai.tinyhumans.tinybus.StaticConfiguredContext"], + methods = [], + signals = [], + requires = [], + optional = [], + lazy = false, + } +} + +mod trusted_context { + async fn setup(context: crate::ModuleSetupContext) -> tinybus::Result<()> { + let _trusted_host = context.trusted_host(); + let _connection = context.connection(); + Ok(()) + } + + crate::module_export_static! { + setup_with_context = setup, + worker_threads = 1, + provides = ["ai.tinyhumans.tinybus.StaticTrustedContext"], + methods = ["Inspect"], + signals = [], + requires = [], + optional = [], + lazy = false, + } +} + #[test] fn linked_modules_retain_distinct_manifests() { fn manifest(slice: tinybus::module::abi::TbSlice) -> tinybus::module::manifest::ModuleManifest { @@ -97,6 +142,8 @@ fn linked_modules_retain_distinct_manifests() { let first_manifest = manifest(first::tinybus_module_manifest_v1()); let second_manifest = manifest(second::tinybus_module_manifest_v1()); let configured_manifest = manifest(configured::tinybus_module_manifest_v1()); + let contextual_manifest = manifest(trusted_context::tinybus_module_manifest_v1()); + let configured_context_manifest = manifest(configured_context::tinybus_module_manifest_v1()); assert_eq!( first_manifest.bus_name.as_str(), "ai.tinyhumans.tinybus.StaticFirst" @@ -109,12 +156,28 @@ fn linked_modules_retain_distinct_manifests() { configured_manifest.bus_name.as_str(), "ai.tinyhumans.tinybus.StaticConfigured" ); + assert_eq!( + contextual_manifest.bus_name.as_str(), + "ai.tinyhumans.tinybus.StaticTrustedContext" + ); + assert_eq!( + configured_context_manifest.bus_name.as_str(), + "ai.tinyhumans.tinybus.StaticConfiguredContext" + ); assert_eq!(first::linked_module().unwrap().manifest, first_manifest); assert_eq!(second::linked_module().unwrap().manifest, second_manifest); assert_eq!( configured::linked_module().unwrap().manifest, configured_manifest ); + assert_eq!( + trusted_context::linked_module().unwrap().manifest, + contextual_manifest + ); + assert_eq!( + configured_context::linked_module().unwrap().manifest, + configured_context_manifest + ); let _entries = ( &first::TINYBUS_MODULE_ABI_V1, first::tinybus_module_init_v1 as tinybus::module::abi::TbModuleInit, @@ -122,6 +185,10 @@ fn linked_modules_retain_distinct_manifests() { second::tinybus_module_init_v1 as tinybus::module::abi::TbModuleInit, &configured::TINYBUS_MODULE_ABI_V1, configured::tinybus_module_init_v1 as tinybus::module::abi::TbModuleInit, + &trusted_context::TINYBUS_MODULE_ABI_V1, + trusted_context::tinybus_module_init_v1 as tinybus::module::abi::TbModuleInit, + &configured_context::TINYBUS_MODULE_ABI_V1, + configured_context::tinybus_module_init_v1 as tinybus::module::abi::TbModuleInit, ); assert!(!optional::tinybus_module_manifest_v1().ptr.is_null()); #[cfg(feature = "static-link")] diff --git a/crates/tinybus-module/src/terminal.rs b/crates/tinybus-module/src/terminal.rs index 849df1ec..9d23777c 100644 --- a/crates/tinybus-module/src/terminal.rs +++ b/crates/tinybus-module/src/terminal.rs @@ -156,6 +156,11 @@ struct SlotLease { admission: Weak, index: usize, } +#[cfg(test)] +pub(super) struct AnchorRemovalGate { + pub(super) entered: std::sync::Barrier, + pub(super) release: std::sync::Barrier, +} impl Drop for SlotLease { fn drop(&mut self) { if let Some(admission) = self.admission.upgrade() { @@ -302,6 +307,10 @@ pub(super) struct TerminalState { startup_failure_stage: AtomicU8, #[cfg(any(test, feature = "__native-fixture-support"))] transport_drop_thread: StdMutex>, + #[cfg(test)] + before_anchor_removal: StdMutex>>, + #[cfg(test)] + initial_future_restored: tokio::sync::Notify, lease: SlotLease, token: usize, } @@ -402,6 +411,10 @@ impl TerminalState { startup_failure_stage: AtomicU8::new(0), #[cfg(any(test, feature = "__native-fixture-support"))] transport_drop_thread: StdMutex::new(None), + #[cfg(test)] + before_anchor_removal: StdMutex::new(None), + #[cfg(test)] + initial_future_restored: tokio::sync::Notify::new(), lease: SlotLease { admission: Arc::downgrade(admission), index, @@ -441,19 +454,10 @@ impl TerminalState { self.vtable_published.store(true, Ordering::Release); } pub fn retain_uncertain_anchor(self: &Arc) { - self.uncertain.store(true, Ordering::Release); - if let Some(admission) = self.lease.admission.upgrade() { - let mut slots = admission - .slots - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let slot = &mut slots[self.lease.index]; - // This State's still-live lease forbids reuse while a C callback or - // retained view performs the final genuine-fault recheck. - if slot.generation == self.token >> 8 && slot.anchor.is_none() { - slot.anchor = Some(self.clone()); - } - } + let mut control = self.lock(); + self.record_uncertain_locked(&mut control); + drop(control); + self.changed.notify_all(); } pub fn view(self: &Arc) -> NativeInstanceLiveness { NativeInstanceLiveness { @@ -469,6 +473,14 @@ impl TerminalState { pub fn is_terminal(&self) -> bool { self.reason.load(Ordering::Acquire) != 0 } + #[cfg(test)] + pub async fn wait_initial_future_restored_for_test(&self) { + self.initial_future_restored.notified().await; + } + #[cfg(test)] + pub(super) fn initial_future_restored_for_test(&self) { + self.initial_future_restored.notify_one(); + } pub fn is_live(self: &Arc) -> bool { if self.is_terminal() { return false; @@ -534,20 +546,48 @@ impl TerminalState { // Atomic authority fence must precede payload-cell locks and foreign Drop. self.host.fault(); self.commit(TerminalReason::Fault); - self.uncertain.store(true, Ordering::Release); + self.retain_uncertain_anchor(); self.keep_payload(kind, payload); self.run_request(); self.schedule(); } - fn lock(&self) -> std::sync::MutexGuard<'_, Control> { - self.control.lock().unwrap_or_else(|poison| { - self.host.fault(); - self.commit(TerminalReason::Fault); - self.uncertain.store(true, Ordering::Release); - poison.into_inner() - }) + fn lock(self: &Arc) -> std::sync::MutexGuard<'_, Control> { + match self.control.lock() { + Ok(control) => control, + Err(poison) => { + let mut control = poison.into_inner(); + self.record_uncertain_locked(&mut control); + drop(control); + self.changed.notify_all(); + // Host callbacks are foreign code and must run after releasing + // Control. Uncertainty and its anchor were recorded first. + self.host.fault(); + self.commit(TerminalReason::Fault); + self.control + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + } + } + fn record_uncertain_locked(self: &Arc, control: &mut Control) { + self.uncertain.store(true, Ordering::Release); + if control.completion == Some(TerminalCompletion::PhysicallyStopped) { + control.completion = Some(TerminalCompletion::Uncertain); + } + if let Some(admission) = self.lease.admission.upgrade() { + let mut slots = admission + .slots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let slot = &mut slots[self.lease.index]; + // This State's still-live lease forbids reuse while a C callback or + // retained view performs the final genuine-fault recheck. + if slot.generation == self.token >> 8 && slot.anchor.is_none() { + slot.anchor = Some(self.clone()); + } + } } - fn keep_payload(&self, kind: PayloadKind, payload: Payload) { + fn keep_payload(self: &Arc, kind: PayloadKind, payload: Payload) { let mut control = self.lock(); let cell = control.payloads.cell(kind); // Once/terminal admission prevents a second primary in this category. @@ -591,8 +631,10 @@ impl TerminalState { Err(payload) => { self.host.fault(); self.commit(TerminalReason::Fault); - self.uncertain.store(true, Ordering::Release); - self.lock().request_state = RequestState::Panicked; + let mut control = self.lock(); + self.record_uncertain_locked(&mut control); + control.request_state = RequestState::Panicked; + drop(control); self.changed.notify_all(); self.keep_payload(PayloadKind::Request, payload); } @@ -606,7 +648,7 @@ impl TerminalState { self.schedule(); } } - pub fn begin_reinit(&self) -> bool { + pub fn begin_reinit(self: &Arc) -> bool { let mut control = self.lock(); if self.is_terminal() || control.active_reinit { return false; @@ -614,7 +656,7 @@ impl TerminalState { control.active_reinit = true; true } - pub fn end_reinit(&self) { + pub fn end_reinit(self: &Arc) { self.lock().active_reinit = false; self.changed.notify_all(); } @@ -714,12 +756,29 @@ impl TerminalState { self.sdk_owners.load(Ordering::Acquire) } #[cfg(any(test, feature = "__native-fixture-support"))] - pub(super) fn request_done_for_test(&self) -> bool { + pub(super) fn request_done_for_test(self: &Arc) -> bool { matches!( self.lock().request_state, RequestState::Done | RequestState::Panicked ) } + #[cfg(test)] + pub(super) fn wait_request_done_for_test(self: &Arc, timeout: Duration) -> bool { + let control = self.lock(); + let (control, _) = self + .changed + .wait_timeout_while(control, timeout, |control| { + !matches!( + control.request_state, + RequestState::Done | RequestState::Panicked + ) + }) + .unwrap_or_else(std::sync::PoisonError::into_inner); + matches!( + control.request_state, + RequestState::Done | RequestState::Panicked + ) + } fn schedule(self: &Arc) { let job = { let mut control = self.lock(); @@ -750,8 +809,8 @@ impl TerminalState { .spawn(move || job.run(state)) }; if submitted.is_err() { - self.uncertain.store(true, Ordering::Release); let mut control = self.lock(); + self.record_uncertain_locked(&mut control); control.finish_state = FinishState::SpawnFailed; control.completion = Some(TerminalCompletion::Uncertain); drop(control); @@ -761,22 +820,33 @@ impl TerminalState { self.run_request(); } } - fn remove_anchor(&self) { + fn take_anchor(&self) -> Option> { if let Some(admission) = self.lease.admission.upgrade() { - let anchor = { - let mut slots = admission - .slots - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - slots[self.lease.index].anchor.take() - }; - drop(anchor); + let mut slots = admission + .slots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + slots[self.lease.index].anchor.take() + } else { + None + } + } + #[cfg(test)] + fn pause_before_anchor_removal_for_test(&self) { + let gate = self + .before_anchor_removal + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(gate) = gate { + gate.entered.wait(); + gate.release.wait(); } } pub fn empty_factory_failure(self: &Arc) { // No callable/controller was transferred. Retained views still hold the permit. self.commit(TerminalReason::SetupFailed); - self.remove_anchor(); + drop(self.take_anchor()); } fn run_finish(self: &Arc) { let _scope = self.owner.as_ref().map(ExecutionOwner::enter); @@ -877,23 +947,36 @@ impl TerminalState { false }; self.observe_host(); - if !released || (self.has_fault() && self.vtable_published.load(Ordering::Acquire)) { - self.uncertain.store(true, Ordering::Release); + let failed_final_checks = + !released || (self.has_fault() && self.vtable_published.load(Ordering::Acquire)); + // Let a concurrent deadline/fault retain its uncertainty before taking + // the finalization lock. The lock below linearizes anchor retirement and + // completion publication against that re-anchor operation. + #[cfg(test)] + if completed == TerminalCompletion::PhysicallyStopped && !failed_final_checks { + self.pause_before_anchor_removal_for_test(); + } + let mut control = self.lock(); + if failed_final_checks { + self.record_uncertain_locked(&mut control); } let completion = if self.uncertain.load(Ordering::Acquire) { TerminalCompletion::Uncertain } else { completed }; - { - let mut control = self.lock(); - control.finish_state = FinishState::Complete; - control.completion = Some(completion); - } + let removed_anchor = if completion == TerminalCompletion::PhysicallyStopped { + // Control -> registry is the common order with timeout retention. + // Keep the removed strong reference until after both locks are free. + self.take_anchor() + } else { + None + }; + control.finish_state = FinishState::Complete; + control.completion = Some(completion); + drop(control); + drop(removed_anchor); self.changed.notify_all(); - if completion == TerminalCompletion::PhysicallyStopped { - self.remove_anchor(); - } } fn settle_execution(self: &Arc) -> bool { let (initial, handle) = { @@ -933,7 +1016,7 @@ impl TerminalState { } true } - fn dispose_setup(&self) { + fn dispose_setup(self: &Arc) { // The worker already observed active_reinit=false. Every last capture // is taken mechanically and destroyed after all bookkeeping unlocks. let (reinitialize, future, reinit_future) = { @@ -1040,10 +1123,10 @@ impl TerminalState { pub fn has_fault(&self) -> bool { self.reason.load(Ordering::Acquire) >= 3 } - pub fn mark_uncertain(&self) { - self.uncertain.store(true, Ordering::Release); + pub fn mark_uncertain(self: &Arc) { + self.retain_uncertain_anchor(); } - pub fn wait(&self, deadline: Duration) -> TerminalCompletion { + pub fn wait(self: &Arc, deadline: Duration) -> TerminalCompletion { let control = self.lock(); let (control, _) = self .changed diff --git a/crates/tinybus-module/src/terminal_tests.rs b/crates/tinybus-module/src/terminal_tests.rs index cd829cbf..ef4a361e 100644 --- a/crates/tinybus-module/src/terminal_tests.rs +++ b/crates/tinybus-module/src/terminal_tests.rs @@ -30,6 +30,7 @@ fn host() -> HostCalls { }, broker_routing: None, native_terminal_state: Some(query), + trusted_host_binding: None, }) } #[test] @@ -86,6 +87,147 @@ fn retired_anchor_cannot_refund_permit_while_old_view_survives() { ); } +struct AnchorRemovalRelease { + gate: Arc, + released: bool, +} +impl AnchorRemovalRelease { + fn release(&mut self) { + if !self.released { + self.gate.release.wait(); + self.released = true; + } + } +} +impl Drop for AnchorRemovalRelease { + fn drop(&mut self) { + self.release(); + } +} + +#[test] +fn physical_completion_is_published_only_after_anchor_retirement() { + let _serial = TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + FLAGS.store(TB_NATIVE_TERMINAL_LIVE, Ordering::Release); + let admission = Admission::new(); + let state = TerminalState::admit_in(&admission, host(), None).unwrap(); + let view = state.view(); + let gate = Arc::new(AnchorRemovalGate { + entered: std::sync::Barrier::new(2), + release: std::sync::Barrier::new(2), + }); + *state.before_anchor_removal.lock().unwrap() = Some(gate.clone()); + state.register(RuntimeHooks::new( + |_| {}, + || TerminalCompletion::PhysicallyStopped, + )); + state.publish(); + state.signal(TerminalReason::Stop); + + gate.entered.wait(); + let mut release = AnchorRemovalRelease { + gate, + released: false, + }; + assert!(TerminalState::lookup_in(&admission, state.context().addr()).is_some()); + assert_eq!( + view.inner.wait(Duration::ZERO), + TerminalCompletion::Uncertain + ); + + release.release(); + assert_eq!( + view.inner.wait(Duration::from_secs(3)), + TerminalCompletion::PhysicallyStopped + ); + assert!(TerminalState::lookup_in(&admission, state.context().addr()).is_none()); + assert!(admission.slots.lock().unwrap()[state.context().addr() & 0xff].reserved); + drop(state); + assert!( + admission.slots.lock().unwrap()[0].reserved, + "the retained liveness view keeps the instance permit occupied" + ); + drop(view); +} + +#[test] +fn timeout_uncertainty_wins_over_a_prepared_physical_completion() { + let _serial = TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + FLAGS.store(TB_NATIVE_TERMINAL_LIVE, Ordering::Release); + let admission = Admission::new(); + let state = TerminalState::admit_in(&admission, host(), None).unwrap(); + let gate = Arc::new(AnchorRemovalGate { + entered: std::sync::Barrier::new(2), + release: std::sync::Barrier::new(2), + }); + *state.before_anchor_removal.lock().unwrap() = Some(gate.clone()); + state.register(RuntimeHooks::new( + |_| {}, + || TerminalCompletion::PhysicallyStopped, + )); + state.publish(); + state.signal(TerminalReason::Stop); + + gate.entered.wait(); + let mut release = AnchorRemovalRelease { + gate, + released: false, + }; + assert_eq!( + state.wait(Duration::ZERO), + TerminalCompletion::Uncertain, + "the deadline expires before completion publication" + ); + state.retain_uncertain_anchor(); + assert!(TerminalState::lookup_in(&admission, state.context().addr()).is_some()); + + release.release(); + assert_eq!( + state.wait(Duration::from_secs(3)), + TerminalCompletion::Uncertain, + "a prepared physical result cannot erase timeout uncertainty" + ); + assert!( + TerminalState::lookup_in(&admission, state.context().addr()).is_some(), + "uncertain completion keeps its exact callback anchor" + ); +} + +#[test] +fn uncertainty_after_physical_publication_downgrades_and_reanchors() { + let _serial = TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + FLAGS.store(TB_NATIVE_TERMINAL_LIVE, Ordering::Release); + let admission = Admission::new(); + let state = TerminalState::admit_in(&admission, host(), None).unwrap(); + state.register(RuntimeHooks::new( + |_| {}, + || TerminalCompletion::PhysicallyStopped, + )); + state.publish(); + state.signal(TerminalReason::Stop); + + assert_eq!( + state.wait(Duration::from_secs(3)), + TerminalCompletion::PhysicallyStopped + ); + assert!(TerminalState::lookup_in(&admission, state.context().addr()).is_none()); + + state.retain_uncertain_anchor(); + + assert_eq!( + state.wait(Duration::ZERO), + TerminalCompletion::Uncertain, + "a later fault observation cannot leave stale physical success published" + ); + assert!(TerminalState::lookup_in(&admission, state.context().addr()).is_some()); +} + #[test] fn request_primary_payload_is_retained_and_finish_still_attempted_once() { let _serial = TEST_LOCK diff --git a/crates/tinybus/Cargo.toml b/crates/tinybus/Cargo.toml index e9f45378..b1240b82 100644 --- a/crates/tinybus/Cargo.toml +++ b/crates/tinybus/Cargo.toml @@ -2,12 +2,12 @@ name = "tinybus" version.workspace = true edition.workspace = true -rust-version = "1.88" +rust-version = "1.85" license.workspace = true repository.workspace = true build = "build.rs" description = "A zbus-style message bus that keeps external integrations out of the OpenHuman kernel's dependency graph." -readme = "README.md" +readme = "../../README.md" keywords = ["bus", "ipc", "rpc", "integrations", "openhuman"] categories = ["network-programming", "asynchronous"] # Examples are declared explicitly, with the features they need, so an example @@ -38,7 +38,9 @@ tracing-subscriber = { version = "0.3", default-features = false, features = ["e ureq = { version = "3.5", optional = true } flate2 = { version = "1", optional = true } tar = { version = "0.4", optional = true } -zip = { version = "9", optional = true, default-features = false, features = ["deflate-flate2"] } +rawzip = { version = "0.5.2", optional = true } +icu_casemap = { version = "2.1", default-features = false, features = ["compiled_data"], optional = true } +unicode-normalization = { version = "0.1", default-features = false, optional = true } tempfile = { version = "3", optional = true } toml = { version = "1", optional = true } @@ -73,11 +75,17 @@ cli = [ "tokio/rt-multi-thread", "tokio/signal", "uds", + "dep:flate2", + "dep:tar", + "dep:rawzip", + "dep:icu_casemap", + "dep:unicode-normalization", + "dep:tempfile", ] # Load trusted, in-process cdylib modules. Off by default because a loaded # module shares the host's address space and full privileges. -modules = ["tokio/rt-multi-thread", "dep:ureq", "dep:flate2", "dep:tar", "dep:zip", "dep:tempfile", "dep:toml", "dep:libc"] +modules = ["tokio/rt-multi-thread", "dep:ureq", "dep:flate2", "dep:tar", "dep:rawzip", "dep:icu_casemap", "dep:unicode-normalization", "dep:tempfile", "dep:toml", "dep:libc"] # Shared helpers for integration suites that load a built module cdylib. test-support = ["modules", "macros"] @@ -86,6 +94,13 @@ test-support = ["modules", "macros"] [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "io-util", "test-util"] } tempfile = "3" +# `zip`'s `deflate-flate2` feature enables flate2 without selecting a backend. +# Keep the fixture writer self-contained in standalone test builds while the +# production loader continues to use its bounded rawzip implementation. +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } +# Archive writer used only to construct hostile ZIP regression fixtures. The +# production loader remains the bounded rawzip parser/extractor. +zip = { version = "9", default-features = false, features = ["deflate-flate2"] } [[bin]] name = "tinybus" @@ -128,11 +143,26 @@ name = "module_context" crate-type = ["cdylib"] required-features = ["modules"] +[[example]] +name = "module_context_configured" +crate-type = ["cdylib"] +required-features = ["modules"] + +[[example]] +name = "module_context_factory" +crate-type = ["cdylib"] +required-features = ["modules"] + [[example]] name = "module_context_eager" crate-type = ["cdylib"] required-features = ["modules"] +[[example]] +name = "module_trusted_migration" +crate-type = ["cdylib"] +required-features = ["modules"] + [[example]] name = "module_host_callback" crate-type = ["cdylib"] diff --git a/crates/tinybus/examples/module_context_configured.rs b/crates/tinybus/examples/module_context_configured.rs new file mode 100644 index 00000000..ce693950 --- /dev/null +++ b/crates/tinybus/examples/module_context_configured.rs @@ -0,0 +1,137 @@ +//! Native fixture exercising contextual dispatch and a callback on its own runtime. + +use async_trait::async_trait; +use serde_json::Value; +use tinybus::{CallContext, Connection, Error, Interface, InterfaceName, MemberName, Result}; +use tinybus_module::{ModuleSetupContext, TrustedHost}; + +#[derive(serde::Deserialize)] +#[serde(default)] +struct Config { + marker: String, +} + +impl Default for Config { + fn default() -> Self { + Self { + marker: "initial".to_string(), + } + } +} + +struct ContextService( + Connection, + std::sync::Arc>>, + Option, + String, + std::sync::Arc>>, +); + +#[async_trait] +impl Interface for ContextService { + fn name(&self) -> InterfaceName { + "org.example.Context".parse().unwrap() + } + fn members(&self) -> Vec { + vec![ + "Inspect".parse().unwrap(), + "Callback".parse().unwrap(), + "ConfidentialCallback".parse().unwrap(), + "WaitForWatchChanged".parse().unwrap(), + "Configuration".parse().unwrap(), + "TrustedContext".parse().unwrap(), + ] + } + async fn call(&self, _: &MemberName, _: Value) -> Result { + Err(Error::failed("authenticated incoming context required")) + } + async fn call_with_context( + &self, + member: &MemberName, + args: Value, + context: &CallContext, + ) -> Result { + let sender = context + .authenticated_sender() + .ok_or_else(|| Error::failed("unverified caller"))?; + if args != serde_json::json!([]) { + return Err(Error::failed("expected no arguments")); + } + match member.as_str() { + "Inspect" => Ok(serde_json::json!(sender)), + "Configuration" => Ok(serde_json::json!(&self.3)), + "TrustedContext" => { + let previous = self.4.lock().await.clone(); + let previous_authorized = self + .2 + .as_ref() + .zip(previous.as_ref()) + .is_some_and(|(trusted_host, context)| trusted_host.authorizes(context)); + let authorized = self + .2 + .as_ref() + .is_some_and(|trusted_host| trusted_host.authorizes(context)); + if authorized { + *self.4.lock().await = Some(context.clone()); + } + Ok(serde_json::json!({ + "authorized": authorized, + "previousAuthorized": previous_authorized, + })) + } + "Callback" | "ConfidentialCallback" => { + let proxy = self + .0 + .proxy(sender.as_str(), "/callback", "org.example.Callback")?; + let reply: String = if member.as_str() == "ConfidentialCallback" { + proxy.call_confidential("Observe", ()).await? + } else { + proxy.call("Observe", ()).await? + }; + Ok(serde_json::json!([sender, reply])) + } + "WaitForWatchChanged" => { + let message = self + .1 + .lock() + .await + .recv() + .await + .map_err(|_| Error::failed("directed signal receiver closed"))?; + Ok(message.body) + } + _ => Err(Error::failed("unknown method")), + } + } +} + +async fn setup(context: ModuleSetupContext, config: Config) -> Result<()> { + let connection = context.connection().clone(); + let trusted_host = context.trusted_host().cloned(); + let signals = connection + .proxy("org.example.Context", "/context", "org.example.Context")? + .receive_directed_signal("WatchChanged") + .await?; + connection + .serve_at( + "/context".parse()?, + ContextService( + connection.clone(), + std::sync::Arc::new(tokio::sync::Mutex::new(signals)), + trusted_host, + config.marker, + std::sync::Arc::new(tokio::sync::Mutex::new(None)), + ), + ) + .await?; + connection.request_name("org.example.Context").await +} + +tinybus_module::module_export! { + setup_with_context = setup, + config = Config, + worker_threads = 1, + provides = ["org.example.Context"], + methods = ["Inspect", "Callback", "ConfidentialCallback", "WaitForWatchChanged"], + signals = ["WatchChanged"], requires = [], optional = [], lazy = true, +} diff --git a/crates/tinybus/examples/module_context_factory.rs b/crates/tinybus/examples/module_context_factory.rs new file mode 100644 index 00000000..87ba4e2c --- /dev/null +++ b/crates/tinybus/examples/module_context_factory.rs @@ -0,0 +1,122 @@ +//! Compiled fixture for configured setup factories that need loader context. +#![deny(missing_docs)] + +use std::{ + future::Future, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::Value; +use tinybus::{CallContext, Error, Interface, InterfaceName, MemberName, Result}; +use tinybus_module::{ModuleSetupContext, TrustedHost}; + +static FACTORY_IDS: AtomicUsize = AtomicUsize::new(0); + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Config { + restricted: bool, +} + +struct Service { + restricted: bool, + generation: usize, + factory_id: usize, + trusted_host: TrustedHost, +} + +#[async_trait] +impl Interface for Service { + fn name(&self) -> InterfaceName { + "org.example.ContextFactory".parse().unwrap() + } + + fn members(&self) -> Vec { + vec!["Inspect".parse().unwrap()] + } + + fn requires_confidential(&self, _: &MemberName) -> bool { + true + } + + async fn call(&self, _: &MemberName, _: Value) -> Result { + Err(Error::failed("context-aware dispatch required")) + } + + async fn call_with_context( + &self, + member: &MemberName, + args: Value, + context: &CallContext, + ) -> Result { + if !self.trusted_host.authorizes(context) { + return Err(Error::failed("factory context authority unavailable")); + } + if member.as_str() != "Inspect" { + return Err(Error::failed("unknown method")); + } + if args != serde_json::json!([]) { + return Err(Error::failed("expected no arguments")); + } + Ok(serde_json::json!({ + "restricted": self.restricted, + "generation": self.generation, + "factory_id": self.factory_id, + })) + } +} + +type SetupFuture = Pin> + Send>>; + +fn setup_factory() -> impl Fn(ModuleSetupContext, Config) -> SetupFuture + Send + Sync + 'static { + let factory_id = FACTORY_IDS.fetch_add(1, Ordering::SeqCst) + 1; + let restricted = Arc::new(tokio::sync::Mutex::new(None)); + let generation = Arc::new(AtomicUsize::new(0)); + move |context, config: Config| { + let restricted = Arc::clone(&restricted); + let generation = Arc::clone(&generation); + Box::pin(async move { + let mut admitted = restricted.lock().await; + if admitted.is_some_and(|initial| initial != config.restricted) { + return Err(Error::failed("immutable mode")); + } + *admitted = Some(config.restricted); + drop(admitted); + let trusted_host = context + .trusted_host() + .cloned() + .ok_or_else(|| Error::failed("trusted host unavailable"))?; + let connection = context.connection(); + connection + .serve_at( + "/context_factory".parse()?, + Service { + restricted: config.restricted, + generation: generation.fetch_add(1, Ordering::SeqCst) + 1, + factory_id, + trusted_host, + }, + ) + .await?; + connection.request_name("org.example.ContextFactory").await + }) + } +} + +tinybus_module::module_export! { + setup_factory_with_context = setup_factory, + config = Config, + worker_threads = 1, + provides = ["org.example.ContextFactory"], + methods = ["Inspect"], + signals = [], + requires = [], + optional = [], + lazy = false, +} diff --git a/crates/tinybus/examples/module_host_callback.rs b/crates/tinybus/examples/module_host_callback.rs index 744bca8c..a270fe16 100644 --- a/crates/tinybus/examples/module_host_callback.rs +++ b/crates/tinybus/examples/module_host_callback.rs @@ -9,6 +9,12 @@ struct Config { host: tinybus::BusName, #[serde(default)] fail: bool, + #[serde(default = "confidential_by_default")] + confidential: bool, +} + +fn confidential_by_default() -> bool { + true } struct CallbackModule(Connection, BusName); @@ -37,10 +43,12 @@ async fn setup(connection: Connection, config: Config) -> Result<()> { if config.fail { return Err(Error::failed("fixture initialization rejected")); } - let _: tinybus::BusName = connection - .proxy(config.host.as_str(), "/callback", "org.example.Callback")? - .call_confidential("Observe", ()) - .await?; + let proxy = connection.proxy(config.host.as_str(), "/callback", "org.example.Callback")?; + let _: tinybus::BusName = if config.confidential { + proxy.call_confidential("Observe", ()).await? + } else { + proxy.call("Observe", ()).await? + }; connection .serve_at( "/org/example/EagerCallback".parse()?, diff --git a/crates/tinybus/examples/module_trusted_migration.rs b/crates/tinybus/examples/module_trusted_migration.rs new file mode 100644 index 00000000..8ae0c26c --- /dev/null +++ b/crates/tinybus/examples/module_trusted_migration.rs @@ -0,0 +1,118 @@ +//! Compiled migration fixture requiring TinyBus's loader-bound host capability. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use async_trait::async_trait; +use serde_json::Value; +use tinybus::{CallContext, Error, Interface, InterfaceName, MemberName, Result}; +use tinybus_module::{ModuleSetupContext, TrustedHost}; + +struct MigrationService { + trusted_host: TrustedHost, + schema_read: Arc, + transformed: Arc, + lease_registered: Arc, +} + +#[async_trait] +impl Interface for MigrationService { + fn name(&self) -> InterfaceName { + "org.example.TrustedMigration".parse().unwrap() + } + + fn members(&self) -> Vec { + vec![ + "Schema".parse().unwrap(), + "Transform".parse().unwrap(), + "RegisterOrdinaryLease".parse().unwrap(), + ] + } + + async fn call(&self, _: &MemberName, _: Value) -> Result { + Err(Error::failed("authenticated migration host required")) + } + + async fn call_with_context( + &self, + member: &MemberName, + args: Value, + context: &CallContext, + ) -> Result { + if !self.trusted_host.authorizes(context) { + return Err(Error::failed("trusted migration host required")); + } + match member.as_str() { + "Schema" if args == serde_json::json!([]) => { + self.schema_read.store(true, Ordering::Release); + Ok(serde_json::json!({ + "type": "object", + "required": ["id"], + "properties": {"id": {"type": "string"}} + })) + } + "Transform" => { + let Some(id) = args + .as_array() + .and_then(|values| values.first()) + .and_then(Value::as_str) + else { + return Err(Error::failed("migration transform input is invalid")); + }; + if !self.schema_read.load(Ordering::Acquire) + || self.lease_registered.load(Ordering::Acquire) + { + return Err(Error::failed("migration ordering is invalid")); + } + self.transformed.store(true, Ordering::Release); + Ok(serde_json::json!({ + "id": format!("record-{id}"), + "ordinary_lease_registered": false + })) + } + "RegisterOrdinaryLease" if args == serde_json::json!([]) => { + if !self.transformed.load(Ordering::Acquire) { + return Err(Error::failed( + "migration must finish before lease registration", + )); + } + self.lease_registered.store(true, Ordering::Release); + Ok(serde_json::json!(true)) + } + _ => Err(Error::failed("unknown migration method")), + } + } +} + +async fn setup(context: ModuleSetupContext) -> Result<()> { + let trusted_host = context + .trusted_host() + .cloned() + .ok_or_else(|| Error::failed("trusted migration host binding unavailable"))?; + let connection = context.connection().clone(); + connection + .serve_at( + "/migration".parse()?, + MigrationService { + trusted_host, + schema_read: Arc::new(AtomicBool::new(false)), + transformed: Arc::new(AtomicBool::new(false)), + lease_registered: Arc::new(AtomicBool::new(false)), + }, + ) + .await?; + connection + .request_name("org.example.TrustedMigration") + .await +} + +tinybus_module::module_export! { + setup_with_context = setup, + worker_threads = 1, + provides = ["org.example.TrustedMigration"], + methods = ["Schema", "Transform", "RegisterOrdinaryLease"], + signals = [], + requires = [], + optional = [], + lazy = false, +} diff --git a/crates/tinybus/src/attest.rs b/crates/tinybus/src/attest.rs index d3d45cf3..390a3860 100644 --- a/crates/tinybus/src/attest.rs +++ b/crates/tinybus/src/attest.rs @@ -21,9 +21,11 @@ //! # What the check actually establishes //! //! An [`Attestation`] records that *this* well-known name is owned by a module -//! whose bytes hashed to *this* SHA-256, and that the operator listed that hash -//! as acceptable. The hash is computed by the host over bytes the host read -//! itself; nothing a module says about itself participates. +//! whose admitted artifact hashed to *this* SHA-256. Release modules keep the +//! archive digest there and may carry a separately verified library digest; +//! local allowlisted libraries keep their file digest there. The host computes +//! these values from protected files or the verified release cache; nothing a +//! module says about itself participates. //! //! Admission also binds that record to the module's fixed unique peer. A unique //! lookup retains the original manifest name and digest even after alias release; @@ -89,14 +91,22 @@ pub struct Attestation { /// against a `modules.toml` beside it. For one loaded from a pinned GitHub /// release it is the release *archive* the library was extracted from — /// the artifact named by the digest the host compiled in, and the only - /// value in that path any operator ever asserted. Hashing the extracted - /// library instead would report a number nobody had vouched for, computed - /// by the same code that would then be trusting it. + /// value in that path any operator ever asserted for the artifact. The + /// optional [`Self::library_sha256`] carries a separately published and + /// checked library digest without changing this archive-digest meaning. /// /// A linked module reports the SHA-256 of the host executable containing /// its code, not a release archive pin. A sender of a secret must recognize /// linked registration explicitly before accepting that digest. pub sha256: String, + /// Lowercase hex SHA-256 of the extracted library, when the host verified + /// one separately from the admitted artifact digest. + /// + /// GitHub release attestations keep [`Self::sha256`] bound to the pinned + /// archive and carry this value for the extracted library. Legacy peers + /// omit it, which leaves strict library-pinned calls ineligible. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub library_sha256: Option, } /// Whether `value` is exactly 64 hex digits. diff --git a/crates/tinybus/src/attest_tests.rs b/crates/tinybus/src/attest_tests.rs index c4e920f4..8ba05da0 100644 --- a/crates/tinybus/src/attest_tests.rs +++ b/crates/tinybus/src/attest_tests.rs @@ -23,3 +23,13 @@ fn only_a_full_length_hex_digest_counts_as_a_hash() { assert!(!is_hex_sha256("e3b0c442")); assert!(!is_hex_sha256(&"z".repeat(64))); } + +#[test] +fn an_attestation_without_a_library_digest_remains_compatible() { + let value = serde_json::json!({ + "name": "ai.tinyhumans.test.Legacy", + "sha256": "a".repeat(64), + }); + let attestation: Attestation = serde_json::from_value(value).unwrap(); + assert_eq!(attestation.library_sha256, None); +} diff --git a/crates/tinybus/src/bin/tinybus/tinybus_tests.rs b/crates/tinybus/src/bin/tinybus/tinybus_tests.rs index a9e662ac..5be9a311 100644 --- a/crates/tinybus/src/bin/tinybus/tinybus_tests.rs +++ b/crates/tinybus/src/bin/tinybus/tinybus_tests.rs @@ -56,6 +56,126 @@ async fn broker_with_module_host() -> (tempfile::TempDir, PathBuf, ModuleHost) { (dir, address, host) } +#[test] +fn checksum_manifest_keeps_archive_hash_and_adds_its_library_hash() { + let directory = tempfile::tempdir().unwrap(); + let archive_path = directory.path().join("clock-linux.tar.gz"); + let library = b"verified library bytes"; + let library_path = directory.path().join("library.so"); + std::fs::write(&library_path, library).unwrap(); + let file = std::fs::File::create(&archive_path).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut archive = tar::Builder::new(encoder); + let mut header = tar::Header::new_gnu(); + header.set_size(library.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + archive + .append_data(&mut header, "libclock.so", &library[..]) + .unwrap(); + archive.into_inner().unwrap().finish().unwrap(); + + let manifest_path = directory.path().join("checksum.toml"); + write_checksum_manifest(std::slice::from_ref(&archive_path), Some(&manifest_path)).unwrap(); + let manifest = std::fs::read_to_string(manifest_path).unwrap(); + let archive_sha256 = tinybus::module::sha256_file(&archive_path).unwrap(); + assert!(manifest.contains("[sha256]")); + assert!(manifest.contains(&format!("\"clock-linux.tar.gz\" = \"{archive_sha256}\""))); + assert!(manifest.contains("[library_sha256]")); + assert!(manifest.contains(&format!( + "\"clock-linux.tar.gz\" = \"{}\"", + tinybus::module::sha256_file(library_path).unwrap() + ))); +} + +#[test] +fn checksum_manifest_reads_a_windows_library_from_zip_on_unix() { + use std::io::Write as _; + + let directory = tempfile::tempdir().unwrap(); + let archive_path = directory.path().join("clock-windows.zip"); + let library = b"windows module bytes"; + let mut archive = rawzip::ZipArchiveWriter::new(Vec::new()); + let (mut entry, config) = archive.new_file("clock.dll").start().unwrap(); + let mut writer = config.wrap(&mut entry); + writer.write_all(library).unwrap(); + let (_, descriptor) = writer.finish().unwrap(); + entry.finish(descriptor).unwrap(); + std::fs::write(&archive_path, archive.finish().unwrap()).unwrap(); + + let library_path = directory.path().join("clock.dll"); + std::fs::write(&library_path, library).unwrap(); + let manifest_path = directory.path().join("checksum.toml"); + write_checksum_manifest(std::slice::from_ref(&archive_path), Some(&manifest_path)).unwrap(); + let manifest = std::fs::read_to_string(manifest_path).unwrap(); + assert!(manifest.contains("[sha256]")); + assert!(manifest.contains("[library_sha256]")); + assert!(manifest.contains(&format!( + "\"clock-windows.zip\" = \"{}\"", + tinybus::module::sha256_file(library_path).unwrap() + ))); +} + +fn write_zip_fixture(path: &Path, files: &[(&str, &[u8])]) { + use std::io::Write as _; + + let mut archive = rawzip::ZipArchiveWriter::new(Vec::new()); + for &(name, bytes) in files { + let (mut entry, config) = archive.new_file(name).start().unwrap(); + let mut writer = config.wrap(&mut entry); + writer.write_all(bytes).unwrap(); + let (_, descriptor) = writer.finish().unwrap(); + entry.finish(descriptor).unwrap(); + } + std::fs::write(path, archive.finish().unwrap()).unwrap(); +} + +#[test] +fn checksum_manifest_rejects_a_zip_without_a_module_library() { + let directory = tempfile::tempdir().unwrap(); + let archive_path = directory.path().join("no-library.zip"); + write_zip_fixture(&archive_path, &[("README.txt", b"documentation")]); + + let error = write_checksum_manifest(std::slice::from_ref(&archive_path), None).unwrap_err(); + assert!( + error + .to_string() + .contains("checksum ZIP contains no module library"), + "{error}" + ); +} + +#[test] +fn checksum_manifest_rejects_multiple_module_libraries_in_a_zip() { + let directory = tempfile::tempdir().unwrap(); + let archive_path = directory.path().join("multiple-libraries.zip"); + write_zip_fixture( + &archive_path, + &[ + ("lib/clock.so", b"first library"), + ("nested/clock.dll", b"second library"), + ], + ); + + let error = write_checksum_manifest(std::slice::from_ref(&archive_path), None).unwrap_err(); + assert!( + error + .to_string() + .contains("checksum ZIP contains more than one module library"), + "{error}" + ); +} + +#[test] +fn checksum_reader_refuses_module_sizes_above_its_expansion_limit() { + let error = sha256_reader(&b""[..], 8, 4).unwrap_err(); + assert!( + error + .to_string() + .contains("checksum module exceeds its size limit") + ); +} + #[test] fn the_cli_parses_its_declared_commands_and_uses_explicit_addresses() { let cli = Cli::try_parse_from([ @@ -332,7 +452,7 @@ async fn a_module_stop_deadline_cannot_outlive_the_call_deadline() { async fn module_cli_controls_a_real_dynamic_module_lifecycle() { let path = PathBuf::from(std::env::var_os("TINYBUS_TEST_MODULE").unwrap()); let (dir, address, _host) = broker_with_module_host().await; - let timeout = Duration::from_secs(2); + let timeout = Duration::from_secs(30); let config = dir.path().join("config.json"); std::fs::write(&config, r#"{"prefix":"cli:"}"#).unwrap(); diff --git a/crates/tinybus/src/bin/tinybus/unix.rs b/crates/tinybus/src/bin/tinybus/unix.rs index 1b1ea82c..32a5f6d1 100644 --- a/crates/tinybus/src/bin/tinybus/unix.rs +++ b/crates/tinybus/src/bin/tinybus/unix.rs @@ -9,6 +9,7 @@ //! bus you debug by adding logging to two processes and restarting both; with //! it, "did the kernel actually call the wallet" is one command. +use std::io::Read; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -20,6 +21,9 @@ use tinybus::router::MatchRule; use tinybus::transport::unix::{UnixListenerAdapter, UnixTransport}; use tinybus::{Error, Result}; +const MAX_CHECKSUM_ARCHIVE_EXPANDED_BYTES: u64 = 2 * 1024 * 1024 * 1024; +const MAX_CHECKSUM_MODULE_BYTES: u64 = 1024 * 1024 * 1024; + #[derive(Parser)] #[command(name = "tinybus", version, about, long_about = None)] struct Cli { @@ -474,6 +478,7 @@ fn read_private_config(path: Option<&Path>) -> Result { fn write_checksum_manifest(paths: &[PathBuf], output: Option<&Path>) -> Result<()> { let mut manifest = String::from("[sha256]\n"); + let mut libraries = Vec::new(); for path in paths { let name = path .file_name() @@ -481,6 +486,15 @@ fn write_checksum_manifest(paths: &[PathBuf], output: Option<&Path>) -> Result<( .ok_or_else(|| Error::failed("checksum path has no safe filename"))?; let digest = tinybus::module::sha256_file(path)?; manifest.push_str(&format!("{name:?} = \"{digest}\"\n")); + if let Some(library_sha256) = library_sha256_in_archive(path)? { + libraries.push((name, library_sha256)); + } + } + if !libraries.is_empty() { + manifest.push_str("\n[library_sha256]\n"); + for (name, digest) in libraries { + manifest.push_str(&format!("{name:?} = \"{digest}\"\n")); + } } if let Some(output) = output { std::fs::write(output, manifest)?; @@ -490,6 +504,98 @@ fn write_checksum_manifest(paths: &[PathBuf], output: Option<&Path>) -> Result<( Ok(()) } +fn library_sha256_in_archive(path: &Path) -> Result> { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return Err(Error::failed("checksum path has no safe filename")); + }; + if name.ends_with(".zip") { + return tinybus::module::sha256_zip_module_library(path).map(Some); + } + let file = std::fs::File::open(path)?; + let reader: Box = if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + Box::new(flate2::read::GzDecoder::new(file)) + } else if name.ends_with(".tar") { + Box::new(file) + } else { + return Ok(None); + }; + let mut bounded_reader = reader.take(MAX_CHECKSUM_ARCHIVE_EXPANDED_BYTES + 1); + let digest = { + let mut archive = tar::Archive::new(&mut bounded_reader); + let mut digest = None; + for entry in archive + .entries() + .map_err(|_| Error::failed("checksum archive could not be read"))? + { + let mut entry = + entry.map_err(|_| Error::failed("checksum archive entry is invalid"))?; + let is_library = { + let path = entry + .path() + .map_err(|_| Error::failed("checksum archive entry path is invalid"))?; + matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("so" | "dylib" | "dll") + ) + }; + if !is_library { + continue; + } + if digest.is_some() { + return Err(Error::failed( + "checksum archive contains more than one module library", + )); + } + let size = entry + .header() + .size() + .map_err(|_| Error::failed("checksum archive module header is invalid"))?; + digest = Some(sha256_reader(&mut entry, size, MAX_CHECKSUM_MODULE_BYTES)?); + } + digest + }; + if bounded_reader.limit() == 0 { + return Err(Error::failed( + "checksum archive exceeds its expanded-size limit", + )); + } + digest + .map(Some) + .ok_or_else(|| Error::failed("checksum archive contains no module library")) +} + +fn sha256_reader(mut reader: impl Read, expected_size: u64, max_size: u64) -> Result { + use sha2::Digest as _; + + if expected_size > max_size { + return Err(Error::failed("checksum module exceeds its size limit")); + } + let mut hasher = sha2::Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + let mut total = 0u64; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + total = total + .checked_add(read as u64) + .ok_or_else(|| Error::failed("checksum module exceeds its size limit"))?; + if total > expected_size || total > max_size { + return Err(Error::failed("checksum module size is inconsistent")); + } + hasher.update(&buffer[..read]); + } + if total != expected_size { + return Err(Error::failed("checksum module size is inconsistent")); + } + Ok(hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + async fn connect(address: &Path) -> Result { Connection::connect(Box::new(UnixTransport::connect(address).await?)).await } diff --git a/crates/tinybus/src/broker.rs b/crates/tinybus/src/broker.rs index 320c77be..29f00bd1 100644 --- a/crates/tinybus/src/broker.rs +++ b/crates/tinybus/src/broker.rs @@ -57,6 +57,63 @@ pub struct Broker { module_controller: Option>>, } +/// A module peer admitted into the router but not yet made dispatchable. +/// Module hosts publish its validated identity before calling `start`. +#[cfg(feature = "modules")] +pub(crate) struct StagedModulePeer { + broker: Broker, + id: Option, + unique: BusName, + transport: Option>, + inbox: Option>, +} + +#[cfg(feature = "modules")] +impl StagedModulePeer { + pub(crate) fn unique_name(&self) -> &BusName { + &self.unique + } + + pub(crate) fn start(mut self) { + let id = self.id.expect("staged peer is live"); + let transport = self.transport.as_ref().expect("staged transport").clone(); + let inbox = self.inbox.take().expect("staged inbox"); + if let Some(module) = crate::transport::downcast::( + transport.as_ref(), + ) { + module.admit_broker(); + } + tracing::debug!(peer = %self.unique, transport = %transport.describe(), "module peer attached"); + tokio::spawn(writer_task(transport.clone(), inbox)); + tokio::spawn(reader_task( + self.broker.clone(), + transport, + id, + self.unique.clone(), + )); + self.id.take(); + self.transport.take(); + } +} + +#[cfg(feature = "modules")] +impl Drop for StagedModulePeer { + fn drop(&mut self) { + if self.id.is_some() { + self.broker.detach_unique(&self.unique); + if let Some(transport) = self.transport.as_deref() { + if let Some(module) = crate::transport::downcast::< + crate::module::transport::ModuleTransport, + >(transport) + { + module.commit_terminal(true); + module.request_terminal(true); + } + } + } + } +} + #[cfg(feature = "modules")] #[derive(Default)] enum ModuleControllerBinding { @@ -66,6 +123,45 @@ enum ModuleControllerBinding { Selected(BusName), } +/// A non-owning snapshot of the controller selected when one module transport +/// is created. It retains no connection or transport endpoint. +#[cfg(feature = "modules")] +#[derive(Clone)] +pub(crate) struct RestrictedModuleControllerBinding { + peer: BusName, + controller: Weak>, + router: Weak>, +} + +#[cfg(feature = "modules")] +impl RestrictedModuleControllerBinding { + pub(crate) fn peer(&self) -> &BusName { + &self.peer + } + + /// Confirm that the selected peer is unchanged and still attached. + pub(crate) fn is_live(&self) -> bool { + let Some(controller) = self.controller.upgrade() else { + return false; + }; + let selected = match controller.lock() { + Ok(binding) => { + matches!(&*binding, ModuleControllerBinding::Selected(peer) if peer == &self.peer) + } + Err(_) => false, + }; + if !selected { + return false; + } + let Some(router) = self.router.upgrade() else { + return false; + }; + router + .lock() + .is_ok_and(|router| router.has_unique_peer(&self.peer)) + } +} + impl Broker { /// Build a broker with an empty routing table. pub fn new() -> Self { @@ -134,6 +230,33 @@ impl Broker { Ok(unique) } + /// Snapshot the selected restricted controller for a new module transport. + /// A missing selection is final for that transport; later selection does + /// not bind an already-created module context. + #[cfg(feature = "modules")] + pub(crate) fn restricted_module_controller_binding( + &self, + ) -> Option { + let controller = self.module_controller.as_ref()?; + let selected_peer = { + let binding = controller.lock().ok()?; + match &*binding { + ModuleControllerBinding::Selected(peer) => peer.clone(), + ModuleControllerBinding::Unselected | ModuleControllerBinding::Attaching => { + return None; + } + } + }; + if !self.router.lock().ok()?.has_unique_peer(&selected_peer) { + return None; + } + Some(RestrictedModuleControllerBinding { + peer: selected_peer, + controller: Arc::downgrade(controller), + router: Arc::downgrade(&self.router), + }) + } + /// This broker's id, as reported by the bus's `GetId`. pub fn id(&self) -> &str { &self.id @@ -198,6 +321,25 @@ impl Broker { unique } + /// Reserve a module peer without starting dispatch. The module host must + /// validate and publish its exact identity before starting this peer. + #[cfg(feature = "modules")] + pub(crate) fn attach_module_staged(&self, transport: Arc) -> StagedModulePeer { + let (outbox, inbox) = mpsc::channel(PEER_QUEUE_CAPACITY); + let (id, unique) = self + .router + .lock() + .expect("router lock is never held across a panic point") + .attach(outbox); + StagedModulePeer { + broker: self.clone(), + id: Some(id), + unique, + transport: Some(transport), + inbox: Some(inbox), + } + } + #[cfg(feature = "modules")] pub(crate) fn reserve_module_name( &self, @@ -210,6 +352,32 @@ impl Broker { .request_name_for_unique(unique, name) } + /// Atomically claim a runtime-owned host name for one attached connection. + /// Returns whether this call created the claim. This stays local to the + /// broker so an idempotent public RequestName cannot erase ownership + /// disposition through a reply race. + #[cfg(feature = "modules")] + pub(crate) fn claim_host_name(&self, unique: &BusName, name: BusName) -> Result { + let change = self.reserve_module_name(unique, name)?; + let newly_claimed = change.old_owner.is_none(); + if newly_claimed { + self.announce_name_change_now(change); + } + Ok(newly_claimed) + } + + /// Release a host name only while the exact unique peer still owns it. + #[cfg(feature = "modules")] + pub(crate) fn release_host_name(&self, unique: &BusName, name: &BusName) -> Result<()> { + let change = self + .router + .lock() + .expect("router lock") + .release_name_for_unique(unique, name)?; + self.announce_name_change_now(change); + Ok(()) + } + /// Record that a loaded module's artifact matched the module allowlist. /// /// This is how a module peer becomes eligible to receive a confidential @@ -236,7 +404,7 @@ impl Broker { .router .lock() .expect("router lock") - .subscribers(&message, from); + .subscribers_from(&message, from, from_name)?; for target in targets { // A full or closed queue is that subscriber's problem; // signals are best-effort and must not block the sender's @@ -257,7 +425,12 @@ impl Broker { .router .lock() .expect("router lock") - .directed_signal_target(from_name, destination, &message.header)?; + .directed_signal_target_from( + from, + from_name, + destination, + &message.header, + )?; // This route is one recipient, not a match-rule broadcast. let _ = target.try_send(message); Ok(()) @@ -311,7 +484,9 @@ impl Broker { // connection, which chose to participate in the exchange. let confidential_call = message.header.confidential && message.header.kind == MessageKind::MethodCall; - let target = self.router.lock().expect("router lock").resolve_call( + let target = self.router.lock().expect("router lock").resolve_call_from( + from, + from_name, &destination, message.header.expected_responder.as_ref(), confidential_call, @@ -423,10 +598,20 @@ impl Broker { body: Value, ) -> (Result, Vec, Vec) { #[cfg(feature = "modules")] - if let Some((result, module_states)) = - self.module_method(from_name, member, body.clone()).await { - return (result, Vec::new(), module_states); + let admission = self + .router + .lock() + .expect("router lock") + .ensure_peer_attached(from, from_name); + if let Err(error) = admission { + return (Err(error), Vec::new(), Vec::new()); + } + if let Some((result, module_states)) = + self.module_method(from_name, member, body.clone()).await + { + return (result, Vec::new(), module_states); + } } // Negotiate conditional routing before building the call body. Keep @@ -435,7 +620,11 @@ impl Broker { if member.as_str() == "ResolveCallTarget" { let result = (|| -> Result { let (name,): (BusName,) = parse_args(member, body)?; - let owner = self.router.lock().expect("router lock").owner_of(&name); + let owner = { + let router = self.router.lock().expect("router lock"); + router.ensure_peer_attached(from, from_name)?; + router.owner_of(&name) + }; match owner { Some(owner) => Ok(serde_json::to_value(owner)?), None => { @@ -461,6 +650,7 @@ impl Broker { let mut router = self.router.lock().expect("router lock"); let result = (|| -> Result { + router.ensure_peer_attached(from, from_name)?; match member.as_str() { "Hello" => Ok(serde_json::to_value(from_name)?), "GetId" => Ok(Value::String(self.id.clone())), @@ -737,19 +927,36 @@ impl Broker { /// user has been staring at a spinner. Subscribers still have to have asked /// for it; the broker does not push it at peers that did not. pub(crate) async fn announce_name_change(&self, change: NameChange) { + self.announce_name_change_now(change); + } + + /// Synchronously detach an internally managed peer and announce each name + /// transition before its reader task observes the closed transport. + #[cfg(feature = "modules")] + pub(crate) fn detach_unique(&self, unique: &BusName) { + let changes = self + .router + .lock() + .expect("router lock") + .detach_unique(unique); + for change in changes { + self.announce_name_change_now(change); + } + } + + fn announce_name_change_now(&self, change: NameChange) { self.broadcast_bus_signal( "NameOwnerChanged", serde_json::json!([change.name, change.old_owner, change.new_owner]), - ) - .await; + ); } #[cfg(feature = "modules")] pub(crate) async fn announce_module_state(&self, body: Value) { - self.broadcast_bus_signal("ModuleStateChanged", body).await; + self.broadcast_bus_signal("ModuleStateChanged", body); } - async fn broadcast_bus_signal(&self, member: &str, body: Value) { + fn broadcast_bus_signal(&self, member: &str, body: Value) { let signal = Message { header: crate::message::Header { kind: MessageKind::Signal, @@ -769,6 +976,7 @@ impl Broker { ), member: Some(MemberName::new(member).expect("literal is a valid member")), error_name: None, + module_lifecycle: None, // The bus's own announcements are broadcasts by construction. confidential: false, sensitive: false, diff --git a/crates/tinybus/src/broker_module_control_tests.rs b/crates/tinybus/src/broker_module_control_tests.rs index d06883f9..e14299d9 100644 --- a/crates/tinybus/src/broker_module_control_tests.rs +++ b/crates/tinybus/src/broker_module_control_tests.rs @@ -371,6 +371,42 @@ async fn replacing_module_host_does_not_relax_broker_controller_admission() { assert_eq!(*replacement.0.lock().unwrap(), vec!["LoadModule"]); } +#[tokio::test] +async fn a_module_binding_snapshot_is_fixed_to_a_live_selected_controller() { + let permissive = Broker::new(); + assert!(permissive.restricted_module_controller_binding().is_none()); + + let broker = Broker::new_restricted_module_control(); + assert!(broker.restricted_module_controller_binding().is_none()); + let (server, client) = MemoryTransport::pair(); + let selected = broker.attach_module_controller(Arc::new(server)).unwrap(); + let controller = Connection::connect(Box::new(client)).await.unwrap(); + assert_eq!(controller.unique_name().as_ref(), Some(&selected)); + + let binding = broker + .restricted_module_controller_binding() + .expect("live selected controller is snapshotted"); + assert_eq!(binding.peer(), &selected); + assert!(binding.is_live()); + + controller.close().await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while binding.is_live() { + tokio::task::yield_now().await; + } + }) + .await + .expect("disconnect removes the selected unique peer"); + assert!(broker.restricted_module_controller_binding().is_none()); + let (replacement_server, _replacement_client) = MemoryTransport::pair(); + assert!( + broker + .attach_module_controller(Arc::new(replacement_server)) + .is_err(), + "a dead selection cannot be rebound on the same broker" + ); +} + /// The default test runtime has one thread: a load that blocks it would hold /// every other connection's call until the load returned. #[tokio::test] diff --git a/crates/tinybus/src/broker_tests.rs b/crates/tinybus/src/broker_tests.rs index 72996d52..9ab9c3a2 100644 --- a/crates/tinybus/src/broker_tests.rs +++ b/crates/tinybus/src/broker_tests.rs @@ -47,6 +47,22 @@ async fn bus() -> (MemoryBus, Connection, Connection) { (bus, service, client) } +async fn receive_name_change( + changes: &mut tokio::sync::broadcast::Receiver, + name: &str, +) -> Message { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let message = changes.recv().await.unwrap(); + if message.body[0] == name { + break message; + } + } + }) + .await + .expect("the requested name change is announced") +} + #[tokio::test] async fn a_client_calls_a_service_by_its_well_known_name() { let (_bus, _service, client) = bus().await; @@ -64,6 +80,246 @@ async fn hello_assigns_distinct_unique_names() { assert_ne!(a, b); } +#[tokio::test] +async fn disconnect_announces_unique_name_loss_but_alias_release_does_not() { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let peer = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let observer = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let unique = peer.unique_name().unwrap(); + let mut changes = observer + .add_match( + MatchRule::new() + .signals() + .member(MemberName::new("NameOwnerChanged").unwrap()), + ) + .await + .unwrap(); + + peer.close().await.unwrap(); + + let detached = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let message = changes.recv().await.unwrap(); + if message.body[0] == serde_json::Value::String(unique.to_string()) { + break message; + } + } + }) + .await + .expect("peer detach must announce its unique name"); + assert_eq!( + detached.body[1], + serde_json::Value::String(unique.to_string()) + ); + assert!(detached.body[2].is_null()); + + let live = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let alias = "org.example.LiveAlias"; + let live_unique = live.unique_name().unwrap(); + assert!(live.release_name(live_unique.as_str()).await.is_err()); + assert_eq!( + live.name_owner(live_unique.as_str()).await.unwrap(), + Some(live_unique.clone()) + ); + live.request_name(alias).await.unwrap(); + let alias_acquired = tokio::time::timeout(Duration::from_secs(2), changes.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(alias_acquired.body[0], alias); + assert!(alias_acquired.body[1].is_null()); + assert_eq!( + alias_acquired.body[2], + serde_json::Value::String(live_unique.to_string()) + ); + + live.release_name(alias).await.unwrap(); + let alias_released = tokio::time::timeout(Duration::from_secs(2), changes.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(alias_released.body[0], alias); + assert_eq!( + alias_released.body[1], + serde_json::Value::String(live_unique.to_string()) + ); + assert!(alias_released.body[2].is_null()); + assert_eq!( + live.name_owner(live_unique.as_str()).await.unwrap(), + Some(live_unique) + ); + assert!( + tokio::time::timeout(Duration::from_millis(100), changes.recv()) + .await + .is_err(), + "releasing an alias from a live peer must not announce peer death" + ); +} + +#[tokio::test] +#[cfg(feature = "modules")] +async fn an_internally_detached_peer_still_announces_its_unique_name_loss() { + let bus = MemoryBus::new(); + let broker = Broker::new(); + broker.clone().spawn(bus.clone()); + let peer = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let observer = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let unique = peer.unique_name().unwrap(); + let mut changes = observer + .add_match( + MatchRule::new() + .signals() + .member(MemberName::new("NameOwnerChanged").unwrap()), + ) + .await + .unwrap(); + + // This is the synchronous revocation path used by module teardown. The + // reader task will later observe EOF, but by then the route is already gone. + broker.detach_unique(&unique); + let detached = tokio::time::timeout(Duration::from_secs(2), changes.recv()) + .await + .expect("internal detach must announce peer death") + .unwrap(); + assert_eq!(detached.body, serde_json::json!([unique, unique, null])); + assert_eq!(observer.name_owner(unique.as_str()).await.unwrap(), None); + peer.close().await.unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(100), changes.recv()) + .await + .is_err(), + "reader cleanup must not announce the same detach twice" + ); +} + +struct CountingVoice(tokio::sync::mpsc::UnboundedSender<()>); + +#[async_trait] +impl Interface for CountingVoice { + fn name(&self) -> InterfaceName { + InterfaceName::new(VOICE_NAME).unwrap() + } + + fn members(&self) -> Vec { + vec![MemberName::new("Transcribe").unwrap()] + } + + async fn call(&self, _member: &MemberName, _args: Value) -> Result { + let _ = self.0.send(()); + Ok(Value::Null) + } +} + +#[tokio::test] +#[cfg(feature = "modules")] +async fn a_detached_peer_cannot_route_frames_after_its_death_is_announced() { + let bus = MemoryBus::new(); + let broker = Broker::new(); + broker.clone().spawn(bus.clone()); + + let (call_tx, mut call_rx) = tokio::sync::mpsc::unbounded_channel(); + let service = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + service + .serve_at(ObjectPath::new(VOICE_PATH).unwrap(), CountingVoice(call_tx)) + .await + .unwrap(); + service.request_name(VOICE_NAME).await.unwrap(); + + let stale_peer = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let unique = stale_peer.unique_name().unwrap(); + let death_observer = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let signal_observer = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let mut deaths = death_observer + .add_match( + MatchRule::new() + .signals() + .member(MemberName::new("NameOwnerChanged").unwrap()), + ) + .await + .unwrap(); + let mut peer_signals = signal_observer + .add_match( + MatchRule::new() + .signals() + .member(MemberName::new("PeerPing").unwrap()), + ) + .await + .unwrap(); + let id = unique + .as_str() + .strip_prefix(":1.") + .expect("broker unique name has the documented prefix") + .parse::() + .expect("broker unique name embeds its peer id"); + + broker.detach_unique(&unique); + let death = tokio::time::timeout(Duration::from_secs(2), deaths.recv()) + .await + .expect("detach announcement arrives before stale frames") + .unwrap(); + assert_eq!(death.body, serde_json::json!([unique, unique, null])); + + let signal = Message::signal( + ObjectPath::new("/ai/tinyhumans/test/Peer").unwrap(), + InterfaceName::new("ai.tinyhumans.test.Peer").unwrap(), + MemberName::new("PeerPing").unwrap(), + Value::Null, + ); + let signal_result = broker.route(id, &unique, signal).await; + + let method = Message::method_call( + BusName::new(VOICE_NAME).unwrap(), + ObjectPath::new(VOICE_PATH).unwrap(), + InterfaceName::new(VOICE_NAME).unwrap(), + MemberName::new("Transcribe").unwrap(), + serde_json::json!(["/tmp/stale.wav"]), + ); + let method_result = broker.route(id, &unique, method).await; + + let alias = BusName::new("org.example.DetachedGhost").unwrap(); + let request_name = Message::method_call( + BusName::new(crate::BUS_NAME).unwrap(), + ObjectPath::new(crate::BUS_PATH).unwrap(), + InterfaceName::new(crate::BUS_INTERFACE).unwrap(), + MemberName::new("RequestName").unwrap(), + serde_json::json!([alias]), + ); + let request_result = broker.route(id, &unique, request_name).await; + + let signal_delivery = + tokio::time::timeout(Duration::from_millis(100), peer_signals.recv()).await; + let service_delivery = tokio::time::timeout(Duration::from_millis(100), call_rx.recv()).await; + let live_names = broker.router.lock().expect("router lock").list_names(); + assert!( + signal_result.is_err() + && method_result.is_err() + && request_result.is_err() + && signal_delivery.is_err() + && service_delivery.is_err() + && !live_names.contains(&alias), + "stale routing: signal={signal_result:?}, method={method_result:?}, request={request_result:?}, signal_delivery={signal_delivery:?}, service_delivery={service_delivery:?}, names={live_names:?}" + ); +} + struct ClosedListener; #[async_trait] @@ -235,10 +491,7 @@ async fn a_service_dying_releases_its_name_and_announces_it() { drop(service); - let announcement = tokio::time::timeout(Duration::from_secs(5), signals.recv()) - .await - .expect("the kernel is told") - .unwrap(); + let announcement = receive_name_change(&mut signals, VOICE_NAME).await; let (name, _old, new): (BusName, Option, Option) = serde_json::from_value(announcement.body).unwrap(); assert_eq!(name.as_str(), VOICE_NAME); @@ -455,6 +708,7 @@ async fn attested_bus() -> (MemoryBus, Broker, Connection, Connection) { crate::attest::Attestation { name: BusName::new(VOICE_NAME).unwrap(), sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(), + library_sha256: None, }, ); @@ -644,10 +898,7 @@ async fn a_name_handed_on_to_another_peer_does_not_hand_on_its_attestation() { .await .unwrap(); service.close().await.unwrap(); - let detached = tokio::time::timeout(Duration::from_secs(5), changes.recv()) - .await - .unwrap() - .unwrap(); + let detached = receive_name_change(&mut changes, "org.example.Extra").await; assert_eq!(detached.body[0], "org.example.Extra"); assert_eq!(fixed.attestation().await.unwrap(), None); assert_eq!( diff --git a/crates/tinybus/src/connection.rs b/crates/tinybus/src/connection.rs index 673f2ddb..20cdb93f 100644 --- a/crates/tinybus/src/connection.rs +++ b/crates/tinybus/src/connection.rs @@ -175,6 +175,8 @@ impl PendingReply { } struct Inner { + /// Per-receiver identity copied weakly into broker-authenticated contexts. + origin: Arc, transport: Arc, brokered_native: bool, native_provenance: Option>, @@ -201,6 +203,22 @@ struct Inner { catch_observer: std::sync::RwLock>>, } +/// Opaque allocation identity for one local receive connection. +pub(crate) struct ConnectionOrigin { + _private: u8, +} + +/// Identity of one exact exported interface registration. +/// +/// Used by owner guards: a guard removes its interface only if no later +/// registration has replaced it at the same path and interface. +#[cfg(feature = "modules")] +pub(crate) struct ObjectRegistration { + path: ObjectPath, + interface: InterfaceName, + id: u64, +} + /// Closes the transport when the last [`Connection`] handle goes away. /// /// Needed because the dispatch task holds its own `Arc`, so the @@ -248,6 +266,7 @@ pub struct Connection { pub struct WeakConnection { inner: std::sync::Weak, close: std::sync::Weak, + origin: std::sync::Weak, } impl std::fmt::Debug for WeakConnection { @@ -274,6 +293,12 @@ impl WeakConnection { _close: close, }) } + + pub(crate) fn matches_origin(&self, origin: &std::sync::Weak) -> bool { + self.origin.strong_count() > 0 + && origin.strong_count() > 0 + && std::sync::Weak::ptr_eq(&self.origin, origin) + } } impl Connection { @@ -286,6 +311,7 @@ impl Connection { WeakConnection { inner: Arc::downgrade(&self.inner), close: Arc::downgrade(&self._close), + origin: Arc::downgrade(&self.inner.origin), } } @@ -378,6 +404,7 @@ impl Connection { let (signal_deliveries, _) = broadcast::channel(SIGNAL_BUFFER); let (outbox, outbound) = mpsc::channel(OUTBOX_CAPACITY); let inner = Arc::new(Inner { + origin: Arc::new(ConnectionOrigin { _private: 0 }), transport, brokered_native, native_provenance, @@ -448,20 +475,25 @@ impl Connection { /// Export `interface` at `path`. pub async fn serve_at(&self, path: ObjectPath, interface: impl Interface) -> Result<()> { - self.inner - .objects - .write() - .await - .insert(path, Arc::new(interface)); - Ok(()) + #[cfg(feature = "modules")] + { + self.serve_at_owned(path, interface).await.map(drop) + } + #[cfg(not(feature = "modules"))] + { + self.inner + .objects + .write() + .await + .insert(path, Arc::new(interface)); + Ok(()) + } } - /// Export `interface` at `path` only if no interface is already served - /// there. + /// Export `interface` at `path` only when the object path is unoccupied. /// - /// Registration is atomic with respect to other calls to this method: a - /// collision leaves every existing interface at that path untouched. Use - /// [`Connection::serve_at`] when replacement is intentional. + /// Unlike [`Connection::serve_at`], this never replaces an existing + /// interface. Concurrent registrations are atomic: exactly one can win. pub async fn try_serve_at(&self, path: ObjectPath, interface: impl Interface) -> Result<()> { if self .inner @@ -478,6 +510,50 @@ impl Connection { } } + /// Export one interface and retain the exact registration identity for an + /// owner guard. Public `serve_at` replacements invalidate older identities. + #[cfg(feature = "modules")] + pub(crate) async fn serve_at_owned( + &self, + path: ObjectPath, + interface: impl Interface, + ) -> Result { + let interface: Arc = Arc::new(interface); + let interface_name = interface.name(); + let (id, replaced) = self.inner.objects.write().await.insert_owned( + path.clone(), + interface_name.clone(), + interface, + ); + let registration = ObjectRegistration { + path, + interface: interface_name, + id, + }; + if !replaced.is_empty() { + // Retired interfaces are user code: their destructors can panic. + // Keep the newly installed registration in this parent task while + // a child owns the retired values, then consume any JoinError. + let _ = tokio::runtime::Handle::current() + .spawn(async move { drop(replaced) }) + .await; + } + Ok(registration) + } + + /// Remove an exported interface only while this registration still owns it. + #[cfg(feature = "modules")] + pub(crate) async fn take_if_owned( + &self, + registration: &ObjectRegistration, + ) -> Option> { + self.inner.objects.write().await.take_if_owned( + ®istration.path, + ®istration.interface, + registration.id, + ) + } + /// Stop exporting everything at `path`. pub async fn unserve(&self, path: &ObjectPath) -> bool { self.inner.objects.write().await.remove(path) @@ -507,9 +583,18 @@ impl Connection { /// Which peer, if any, owns `name`. pub async fn name_owner(&self, name: impl AsRef) -> Result> { + self.name_owner_with_timeout(name, DEFAULT_TIMEOUT).await + } + + /// Which peer owns `name`, with a caller-supplied deadline. + pub(crate) async fn name_owner_with_timeout( + &self, + name: impl AsRef, + timeout: Duration, + ) -> Result> { let name = BusName::new(name.as_ref())?; let value = self - .call_bus("GetNameOwner", serde_json::json!([name])) + .call_bus_with_timeout("GetNameOwner", serde_json::json!([name]), timeout) .await?; Ok(serde_json::from_value(value)?) } @@ -525,8 +610,17 @@ impl Connection { /// `None` means the bus will refuse a confidential message to that name — /// nothing owns it, or the operator never allowlisted an artifact for it. pub async fn attestation(&self, name: BusName) -> Result> { + self.attestation_with_timeout(name, DEFAULT_TIMEOUT).await + } + + /// What the broker verified about `name`, with a caller-supplied deadline. + pub(crate) async fn attestation_with_timeout( + &self, + name: BusName, + timeout: Duration, + ) -> Result> { let value = self - .call_bus("GetAttestation", serde_json::json!([name])) + .call_bus_with_timeout("GetAttestation", serde_json::json!([name]), timeout) .await?; Ok(serde_json::from_value(value)?) } @@ -629,7 +723,10 @@ impl Connection { /// Apply replacement JSON configuration to a running module. /// /// Configuration is always carried as a sensitive control call and is - /// never eligible for signal fan-out or monitor output. + /// never eligible for signal fan-out or monitor output. If the module is + /// still resolved with a deferred lazy initializer, the replacement is + /// applied to that initializer so its first method call uses the new + /// configuration. #[cfg(feature = "modules")] pub async fn reinitialize_module( &self, @@ -1196,6 +1293,24 @@ impl Connection { args: impl FnOnce(&StreamRef) -> Value, bytes: &[u8], timeout: Duration, + ) -> Result { + self.call_with_stream_serialized(destination, path, interface, member, args, bytes, timeout) + .await + } + + #[allow(clippy::too_many_arguments)] + /// Shared transfer path for argument builders that can fail serialization. + /// If encoding fails after `Open`, returning drops the writer and triggers + /// its best-effort `Abort` before any business method is sent. + pub(crate) async fn call_with_stream_serialized( + &self, + destination: BusName, + path: ObjectPath, + interface: InterfaceName, + member: MemberName, + args: impl FnOnce(&StreamRef) -> A, + bytes: &[u8], + timeout: Duration, ) -> Result { let mut writer = self .open_stream_with_timeout( @@ -1204,8 +1319,11 @@ impl Connection { timeout, ) .await?; + // Keep the method with the peer that accepted Open. A well-known name + // may have changed owners while the stream was being opened, and + // splitting its handle from its payload would make the transfer fail. let message = Message::method_call( - destination, + writer.destination().clone(), path, interface, member, @@ -1483,6 +1601,7 @@ async fn refuse_call(inner: &Inner, serial: u64, refusal: &Error) { interface: None, member: None, error_name: None, + module_lifecycle: None, confidential: false, sensitive: false, stream_reply: false, @@ -1533,9 +1652,10 @@ async fn dispatch_loop(inner: Arc) { if pending .get(&serial) .is_some_and(|waiter| waiter.accepts(&message, brokered)) - && let Some(waiter) = pending.remove(&serial) { - let _ = waiter.sender.send(message); + if let Some(waiter) = pending.remove(&serial) { + let _ = waiter.sender.send(message); + } } } MessageKind::Signal | MessageKind::DirectedSignal => { @@ -1720,7 +1840,11 @@ async fn dispatch(inner: &Inner, header: &Header, body: Value, brokered: bool) - return Err(Error::protocol("method call is missing an address")); }; let context = if brokered { - crate::CallContext::brokered(header.sender.as_ref(), header.confidential) + crate::CallContext::brokered_with_origin( + header.sender.as_ref(), + header.confidential, + Some(&inner.origin), + ) } else { crate::CallContext::default() }; diff --git a/crates/tinybus/src/connection_reply_tests.rs b/crates/tinybus/src/connection_reply_tests.rs index 11a9463f..53d0d3d8 100644 --- a/crates/tinybus/src/connection_reply_tests.rs +++ b/crates/tinybus/src/connection_reply_tests.rs @@ -134,6 +134,7 @@ async fn reject_forged_replies(named: bool, confidential: bool, genuine_error: b crate::attest::Attestation { name: alias.clone(), sha256: "a".repeat(64), + library_sha256: None, }, ); } @@ -252,6 +253,7 @@ async fn alias_takeover_is_refused(confidential: bool) { crate::attest::Attestation { name: alias.clone(), sha256: "a".repeat(64), + library_sha256: None, }, ); } diff --git a/crates/tinybus/src/connection_tests.rs b/crates/tinybus/src/connection_tests.rs index a23e2eda..6b4bde3e 100644 --- a/crates/tinybus/src/connection_tests.rs +++ b/crates/tinybus/src/connection_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::CallContext; use crate::broker::Broker; use crate::transport::memory::{MemoryBus, MemoryTransport}; use async_trait::async_trait; @@ -51,8 +52,8 @@ impl Interface for ReplacementEcho { vec![MemberName::new("Echo").unwrap()] } - async fn call(&self, _member: &MemberName, _args: Value) -> Result { - Ok(Value::String("replacement".to_string())) + async fn call(&self, _: &MemberName, _: Value) -> Result { + Ok(Value::String("replacement".to_owned())) } } @@ -181,6 +182,124 @@ async fn brokered_pair() -> (Connection, Connection) { (client, service) } +#[tokio::test] +async fn try_serve_at_keeps_the_original_interface_after_a_collision() { + let (client, service) = pair().await; + assert!(service.try_serve_at(path(), ReplacementEcho).await.is_err()); + + let result: Value = client + .proxy("ai.tinyhumans.Test", path(), "ai.tinyhumans.Test") + .unwrap() + .call("Echo", ("original",)) + .await + .unwrap(); + assert_eq!(result, serde_json::json!(["original"])); +} + +struct CaptureCallContext(std::sync::Mutex>>); + +#[async_trait] +impl Interface for CaptureCallContext { + fn name(&self) -> InterfaceName { + InterfaceName::new("org.example.ContextCapture").unwrap() + } + + fn members(&self) -> Vec { + vec![MemberName::new("Capture").unwrap()] + } + + async fn call(&self, _: &MemberName, _: Value) -> Result { + Err(Error::failed("context-aware dispatch required")) + } + + async fn call_with_context( + &self, + _: &MemberName, + _: Value, + context: &CallContext, + ) -> Result { + self.0 + .lock() + .unwrap() + .take() + .expect("one capture call") + .send(context.clone()) + .map_err(|_| Error::failed("context receiver closed"))?; + Ok(Value::Null) + } +} + +struct CapturedContext { + context: CallContext, + receiver: Connection, + caller: Connection, + _broker_task: tokio::task::JoinHandle>, +} + +async fn capture_context_from_fresh_broker() -> CapturedContext { + let bus = MemoryBus::new(); + let broker_task = Broker::new().spawn(bus.clone()); + let receiver = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let (capture, captured_rx) = tokio::sync::oneshot::channel(); + receiver + .serve_at( + "/capture".parse().unwrap(), + CaptureCallContext(std::sync::Mutex::new(Some(capture))), + ) + .await + .unwrap(); + receiver + .request_name("org.example.ContextCapture") + .await + .unwrap(); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let proxy = caller + .proxy( + "org.example.ContextCapture", + "/capture", + "org.example.ContextCapture", + ) + .unwrap(); + let _: Value = proxy.call("Capture", ()).await.unwrap(); + CapturedContext { + context: captured_rx.await.unwrap(), + receiver, + caller, + _broker_task: broker_task, + } +} + +#[tokio::test] +async fn captured_contexts_from_distinct_brokers_do_not_match_same_named_receivers() { + let first = capture_context_from_fresh_broker().await; + let second = capture_context_from_fresh_broker().await; + assert_eq!( + first.caller.unique_name(), + second.caller.unique_name(), + "fresh brokers deliberately assign the same sender name" + ); + assert!(first.context.is_for_connection(&first.receiver.downgrade())); + assert!( + second + .context + .is_for_connection(&second.receiver.downgrade()) + ); + assert!( + !first + .context + .is_for_connection(&second.receiver.downgrade()) + ); + assert!( + !second + .context + .is_for_connection(&first.receiver.downgrade()) + ); +} + struct MutatesObjectTree { service: Connection, extra_path: ObjectPath, @@ -284,6 +403,167 @@ async fn a_reply_larger_than_one_frame_arrives_through_a_bounded_stream() { assert!(reply.bytes().all(|byte| byte == b'x')); } +struct PausedMethodTransport { + inner: Box, + paused_member: &'static str, + blocked: tokio::sync::mpsc::UnboundedSender<()>, + resume: std::sync::Arc, +} + +#[async_trait] +impl crate::ports::Transport for PausedMethodTransport { + async fn send(&self, message: Message) -> Result<()> { + if message.header.member.as_ref().map(MemberName::as_str) == Some(self.paused_member) { + self.blocked.send(()).unwrap(); + self.resume.notified().await; + } + self.inner.send(message).await + } + + async fn recv(&self) -> Result> { + self.inner.recv().await + } + + async fn close(&self) -> Result<()> { + self.inner.close().await + } +} + +struct ReadsUploadedStream { + connection: Connection, + received: tokio::sync::mpsc::UnboundedSender>, +} + +#[async_trait] +impl Interface for ReadsUploadedStream { + fn name(&self) -> InterfaceName { + InterfaceName::new("ai.tinyhumans.StreamHandover").unwrap() + } + + fn members(&self) -> Vec { + vec![MemberName::new("Upload").unwrap()] + } + + async fn call(&self, _member: &MemberName, args: Value) -> Result { + let (stream,): (crate::stream::StreamRef,) = serde_json::from_value(args)?; + let bytes = self.connection.read_stream(&stream).await?; + self.received.send(bytes.clone()).unwrap(); + Ok(serde_json::to_value(bytes.len())?) + } +} + +struct ReplacementUpload { + calls: std::sync::Arc, +} + +#[async_trait] +impl Interface for ReplacementUpload { + fn name(&self) -> InterfaceName { + InterfaceName::new("ai.tinyhumans.StreamHandover").unwrap() + } + + fn members(&self) -> Vec { + vec![MemberName::new("Upload").unwrap()] + } + + async fn call(&self, _member: &MemberName, _args: Value) -> Result { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(Value::Null) + } +} + +#[tokio::test] +async fn a_stream_method_stays_with_the_owner_that_opened_its_stream_after_name_handover() { + const NAME: &str = "ai.tinyhumans.StreamHandover"; + const PATH: &str = "/ai/tinyhumans/StreamHandover"; + + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + + let owner = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + owner.request_name(NAME).await.unwrap(); + let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel(); + owner + .serve_at( + ObjectPath::new(PATH).unwrap(), + ReadsUploadedStream { + connection: owner.clone(), + received: received_tx, + }, + ) + .await + .unwrap(); + + let replacement = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let replacement_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + replacement + .serve_at( + ObjectPath::new(PATH).unwrap(), + ReplacementUpload { + calls: replacement_calls.clone(), + }, + ) + .await + .unwrap(); + + let resume = std::sync::Arc::new(tokio::sync::Notify::new()); + let (blocked_tx, mut blocked_rx) = tokio::sync::mpsc::unbounded_channel(); + let client_transport = PausedMethodTransport { + inner: bus.connect().await.unwrap(), + paused_member: "Upload", + blocked: blocked_tx, + resume: resume.clone(), + }; + let client = Connection::connect(Box::new(client_transport)) + .await + .unwrap(); + let upload = tokio::spawn(async move { + client + .call_with_stream::( + BusName::new(NAME).unwrap(), + ObjectPath::new(PATH).unwrap(), + InterfaceName::new(NAME).unwrap(), + MemberName::new("Upload").unwrap(), + |stream| serde_json::json!([stream]), + b"stream opened by the original owner", + ) + .await + }); + + // The Open reply has arrived before the transport pauses the following + // method call. Move the public name while leaving the original stream peer + // alive, then allow the method frame to route. + tokio::time::timeout(Duration::from_secs(10), blocked_rx.recv()) + .await + .expect("the post-Open method call reached the transport barrier") + .expect("the transport barrier remained connected"); + owner.release_name(NAME).await.unwrap(); + replacement.request_name(NAME).await.unwrap(); + resume.notify_one(); + + let result = tokio::time::timeout(Duration::from_secs(10), upload) + .await + .expect("the streamed method completed after name handover") + .unwrap(); + assert_eq!( + result.unwrap(), + b"stream opened by the original owner".len() + ); + assert_eq!( + replacement_calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "the replacement owner must not receive the method" + ); + assert_eq!( + received_rx.recv().await.unwrap(), + b"stream opened by the original owner" + ); +} + #[tokio::test] async fn a_streaming_caller_accepts_an_ordinary_legacy_reply() { let (client, _service) = pair().await; diff --git a/crates/tinybus/src/error.rs b/crates/tinybus/src/error.rs index d176b7d8..c5c0f477 100644 --- a/crates/tinybus/src/error.rs +++ b/crates/tinybus/src/error.rs @@ -292,6 +292,8 @@ impl Error { /// The dotted error name for a member that requires confidential delivery. pub const CONFIDENTIALITY_REQUIRED: &'static str = "ai.tinyhumans.tinybus.Error.ConfidentialityRequired"; + /// Stable wire name for a module that cannot currently serve calls. + pub const MODULE_UNAVAILABLE: &'static str = "ai.tinyhumans.tinybus.Error.ModuleUnavailable"; /// Build an [`Error::Protocol`] from anything displayable. pub fn protocol(message: impl std::fmt::Display) -> Self { @@ -409,7 +411,7 @@ impl Error { Self::Timeout { .. } => "ai.tinyhumans.tinybus.Error.Timeout", Self::IncompatibleVersion { .. } => "ai.tinyhumans.tinybus.Error.IncompatibleVersion", Self::ModuleRefused { .. } => "ai.tinyhumans.tinybus.Error.ModuleRefused", - Self::ModuleUnavailable { .. } => "ai.tinyhumans.tinybus.Error.ModuleUnavailable", + Self::ModuleUnavailable { .. } => Self::MODULE_UNAVAILABLE, Self::Path { .. } => "ai.tinyhumans.tinybus.Error.Path", Self::UnknownStream { .. } => "ai.tinyhumans.tinybus.Error.UnknownStream", Self::StreamAborted { .. } => "ai.tinyhumans.tinybus.Error.StreamAborted", diff --git a/crates/tinybus/src/error_tests.rs b/crates/tinybus/src/error_tests.rs index 219ecb3c..3d2d11a4 100644 --- a/crates/tinybus/src/error_tests.rs +++ b/crates/tinybus/src/error_tests.rs @@ -152,3 +152,19 @@ fn every_structured_error_has_a_stable_wire_name() { assert!(error.wire_name().starts_with("ai.tinyhumans.")); } } + +#[test] +fn module_unavailable_wire_name_remains_stable_across_lifecycle_states() { + let unavailable = Error::ModuleUnavailable { + module: "module".into(), + state: "faulted".into(), + detail: "safe".into(), + }; + let initializing = Error::ModuleUnavailable { + module: "module".into(), + state: "initializing".into(), + detail: "safe".into(), + }; + assert_eq!(unavailable.wire_name(), Error::MODULE_UNAVAILABLE); + assert_eq!(initializing.wire_name(), Error::MODULE_UNAVAILABLE); +} diff --git a/crates/tinybus/src/global.rs b/crates/tinybus/src/global.rs index 22205ea5..4b71ef65 100644 --- a/crates/tinybus/src/global.rs +++ b/crates/tinybus/src/global.rs @@ -11,8 +11,21 @@ //! The bus is generic over the host's event type, so tinybus cannot own a //! `static` of it — there is no single type to name. Instead the host writes: //! -//! ```ignore +//! ``` +//! use serde::{Deserialize, Serialize}; +//! use tinybus::events::Event; +//! +//! #[derive(Clone, Serialize, Deserialize)] +//! struct DomainEvent; +//! +//! impl Event for DomainEvent { +//! fn domain(&self) -> &str { +//! "example" +//! } +//! } +//! //! static BUS: tinybus::global::OnceBus = tinybus::global::OnceBus::new(); +//! assert!(!BUS.is_initialised()); //! ``` //! //! …and gets the whole surface off it. `OnceBus::new` is `const`, so this costs diff --git a/crates/tinybus/src/message/mod.rs b/crates/tinybus/src/message/mod.rs index cf546d5b..4cbfe450 100644 --- a/crates/tinybus/src/message/mod.rs +++ b/crates/tinybus/src/message/mod.rs @@ -50,6 +50,29 @@ pub enum MessageKind { DirectedSignal, } +/// Closed lifecycle metadata accompanying a module availability error. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ModuleLifecycle { + /// The module has not finished its current initialization attempt. + Initializing, + /// A future lifecycle state this version cannot classify safely. + Unknown, +} + +impl<'de> Deserialize<'de> for ModuleLifecycle { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Ok(match value.as_str() { + "initializing" => Self::Initializing, + _ => Self::Unknown, + }) + } +} + /// Everything the router and the dispatcher need, and nothing else. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Header { @@ -90,6 +113,10 @@ pub struct Header { /// For [`MessageKind::Error`]: the stable dotted error name. #[serde(default, skip_serializing_if = "Option::is_none")] pub error_name: Option, + /// Closed lifecycle metadata for module availability errors. Older peers + /// ignore this optional field and continue to classify by `error_name`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub module_lifecycle: Option, /// This body is a secret: deliver it to the attested destination, an exact /// active SDK-created local host callback capability, or nobody. /// @@ -158,6 +185,7 @@ impl Message { interface: Some(interface), member: Some(member), error_name: None, + module_lifecycle: None, confidential: false, sensitive: false, stream_reply: false, @@ -231,6 +259,7 @@ impl Message { interface: None, member: None, error_name: None, + module_lifecycle: None, // A reply inherits the call's confidentiality. A secret asked // for confidentially is usually answered with another one — a // key derivation returns a key — and a reply that quietly lost @@ -260,6 +289,14 @@ impl Message { interface: None, member: None, error_name: Some(error.wire_name().to_string()), + module_lifecycle: match error { + Error::ModuleUnavailable { state, .. } + if state.eq_ignore_ascii_case("initializing") => + { + Some(ModuleLifecycle::Initializing) + } + _ => None, + }, // Not inherited. Errors never carry the value that caused them, // so an error reply has no secret to protect — and marking it // confidential would make it undeliverable exactly when the @@ -293,6 +330,7 @@ impl Message { interface: Some(interface), member: Some(member), error_name: None, + module_lifecycle: None, // Structurally impossible to set: a signal is a broadcast, and // `validate` refuses the combination on ingress. confidential: false, @@ -346,6 +384,7 @@ impl Message { interface: Some(interface), member: Some(member), error_name: None, + module_lifecycle: None, confidential: false, sensitive: false, stream_reply: false, @@ -356,14 +395,24 @@ impl Message { /// Reconstruct the [`Error`] an [`MessageKind::Error`] message carries. /// - /// The variant's structure does not survive the wire — only its name and - /// message do — so this always returns [`Error::MethodFailed`]. Callers - /// match on [`Error::wire_name`], which does survive. + /// Most error structure does not survive the wire — only its name and + /// message do. The optional closed module lifecycle metadata reconstructs + /// the typed initializing state while retaining the stable error name. pub fn into_error(self) -> Error { let name = self .header .error_name .unwrap_or_else(|| Error::FAILED.to_string()); + if self.header.kind == MessageKind::Error + && name == Error::MODULE_UNAVAILABLE + && self.header.module_lifecycle == Some(ModuleLifecycle::Initializing) + { + return Error::ModuleUnavailable { + module: "module".to_owned(), + state: "initializing".to_owned(), + detail: "module initialization is still in progress".to_owned(), + }; + } let message = match self.body { Value::String(s) => s, other => other.to_string(), diff --git a/crates/tinybus/src/message/mod_tests.rs b/crates/tinybus/src/message/mod_tests.rs index 95499323..b4d9e507 100644 --- a/crates/tinybus/src/message/mod_tests.rs +++ b/crates/tinybus/src/message/mod_tests.rs @@ -38,6 +38,92 @@ fn an_error_reply_keeps_the_dotted_name_matchable() { assert_eq!(reply.into_error().wire_name(), Error::UNKNOWN_METHOD); } +#[test] +fn initializing_metadata_roundtrips_without_changing_the_error_name() { + let call = call(); + let error = Error::ModuleUnavailable { + module: "module".into(), + state: "initializing".into(), + detail: "module is initializing".into(), + }; + let reply = Message::error_reply(&call.header, &error); + assert_eq!( + reply.header.error_name.as_deref(), + Some(Error::MODULE_UNAVAILABLE) + ); + assert_eq!( + reply.header.module_lifecycle, + Some(ModuleLifecycle::Initializing) + ); + let bytes = serde_json::to_vec(&reply).unwrap(); + let decoded: Message = serde_json::from_slice(&bytes).unwrap(); + assert!(matches!( + decoded.into_error(), + Error::ModuleUnavailable { state, .. } if state == "initializing" + )); +} + +#[test] +fn legacy_errors_and_unknown_or_misplaced_metadata_fall_back_safely() { + let call = call(); + let legacy = Message::error_reply( + &call.header, + &Error::ModuleUnavailable { + module: "module".into(), + state: "stopped".into(), + detail: "module is stopped".into(), + }, + ); + let legacy_json = serde_json::to_value(&legacy).unwrap(); + assert!(legacy_json["header"].get("module_lifecycle").is_none()); + assert!(matches!( + serde_json::from_value::(legacy_json) + .unwrap() + .into_error(), + Error::MethodFailed { name, .. } if name == Error::MODULE_UNAVAILABLE + )); + + let mut misplaced = Message::method_return(&call.header, Value::Null); + misplaced.header.module_lifecycle = Some(ModuleLifecycle::Initializing); + misplaced.validate().unwrap(); + assert!(matches!( + misplaced.into_error(), + Error::MethodFailed { name, .. } if name == Error::FAILED + )); + + let mut wrong_name = Message::error_reply( + &call.header, + &Error::ModuleUnavailable { + module: "module".into(), + state: "initializing".into(), + detail: "module is initializing".into(), + }, + ); + wrong_name.header.error_name = Some(Error::FAILED.to_owned()); + wrong_name.validate().unwrap(); + assert!(matches!( + wrong_name.into_error(), + Error::MethodFailed { name, .. } if name == Error::FAILED + )); + + let mut future: serde_json::Value = serde_json::to_value(Message::error_reply( + &call.header, + &Error::ModuleUnavailable { + module: "module".into(), + state: "initializing".into(), + detail: "module is initializing".into(), + }, + )) + .unwrap(); + future["header"]["module_lifecycle"] = Value::String("future_state".into()); + let future: Message = serde_json::from_value(future).unwrap(); + future.validate().unwrap(); + assert!(matches!( + future.into_error(), + Error::MethodFailed { name, .. } if name == Error::MODULE_UNAVAILABLE + )); +} + #[test] fn a_call_without_a_destination_is_refused_on_ingress() { let mut c = call(); diff --git a/crates/tinybus/src/module/abi.rs b/crates/tinybus/src/module/abi.rs index 07d33db4..51c445f1 100644 --- a/crates/tinybus/src/module/abi.rs +++ b/crates/tinybus/src/module/abi.rs @@ -28,6 +28,10 @@ pub const TB_PANICKED: i32 = -3; pub const TB_TIMEOUT: i32 = -4; /// A pointer, length, or frame was invalid. pub const TB_BAD_ARGUMENT: i32 = -5; +/// A caller-owned output buffer is too small; `out_len` carries the required size. +pub const TB_BUFFER_TOO_SMALL: i32 = -6; +/// The optional host capability is unavailable or has been revoked. +pub const TB_UNSUPPORTED: i32 = -7; /// Exactly this value permits native per-instance admission. Zero, unknown /// bits, or any combination with terminal bits must deny admission. A sampled @@ -202,6 +206,14 @@ pub struct TbHostVtable { /// The SDK's existing process-lifetime context retention is unbounded; /// this optional callback adds no aggregate resource bound. pub native_terminal_state: Option u32>, + /// Optional query for the controller peer pinned when this module transport + /// was created. Read only when `size` covers the complete field. The host + /// writes the peer's bytes to caller-owned storage and never retains either + /// output pointer. Absence means no trusted migration host is available. + /// The query is valid during admitted setup before `ready`, and must return + /// `TB_CLOSED` after the module or pinned controller becomes inactive. + pub trusted_host_binding: + Option i32>, } /// Size required from an older host before reading its frozen prefix. @@ -244,6 +256,15 @@ impl TbHostVtable { } else { None }; + let trusted_host_binding = if base.size as usize + >= std::mem::offset_of!(Self, trusted_host_binding) + + size_of::< + Option i32>, + >() { + unsafe { std::ptr::addr_of!((*host).trusted_host_binding).read() } + } else { + None + }; Some(Self { size: base.size, _reserved: base._reserved, @@ -256,6 +277,7 @@ impl TbHostVtable { ready: base.ready, broker_routing, native_terminal_state, + trusted_host_binding, }) } } diff --git a/crates/tinybus/src/module/abi_tests.rs b/crates/tinybus/src/module/abi_tests.rs index e7020fc4..a31b8ee1 100644 --- a/crates/tinybus/src/module/abi_tests.rs +++ b/crates/tinybus/src/module/abi_tests.rs @@ -72,6 +72,27 @@ unsafe extern "C" fn fault(_: *mut c_void, _: *const u8, _: usize) {} unsafe extern "C" fn query(ctx: *mut c_void) -> u32 { unsafe { ctx.cast::().read() } } +unsafe extern "C" fn trusted_host_query( + _: *mut c_void, + out: *mut u8, + capacity: usize, + out_len: *mut usize, +) -> i32 { + if out_len.is_null() || (out.is_null() && capacity != 0) { + return TB_BAD_ARGUMENT; + } + let sender = b":1.42"; + unsafe { out_len.write(0) }; + if capacity < sender.len() { + unsafe { out_len.write(sender.len()) }; + return TB_BUFFER_TOO_SMALL; + } + unsafe { + std::ptr::copy_nonoverlapping(sender.as_ptr(), out, sender.len()); + out_len.write(sender.len()); + } + TB_OK +} fn frozen_prefix(size: usize) -> FrozenHostPrefix { FrozenHostPrefix { @@ -197,3 +218,66 @@ fn terminal_tail_requires_its_complete_field() { } } } + +#[test] +fn trusted_host_tail_requires_its_complete_field_and_preserves_older_tails() { + let terminal_end = std::mem::offset_of!(TbHostVtable, native_terminal_state) + + size_of:: u32>>(); + let trusted_host_end = std::mem::offset_of!(TbHostVtable, trusted_host_binding) + + size_of:: i32>>(); + for size in terminal_end..=trusted_host_end { + let layout = std::alloc::Layout::from_size_align(size, align_of::()).unwrap(); + let storage = unsafe { std::alloc::alloc_zeroed(layout) }; + assert!(!storage.is_null()); + unsafe { + storage + .cast::() + .write(frozen_prefix(size)) + }; + unsafe { + storage + .add(std::mem::offset_of!(TbHostVtable, broker_routing)) + .cast:: u32>>() + .write(Some(query)); + storage + .add(std::mem::offset_of!(TbHostVtable, native_terminal_state)) + .cast:: u32>>() + .write(Some(query)); + } + if size >= trusted_host_end { + unsafe { + storage + .add(std::mem::offset_of!(TbHostVtable, trusted_host_binding)) + .cast:: i32, + >>() + .write(Some(trusted_host_query)); + } + } + let normalized = unsafe { TbHostVtable::read_compatible(storage.cast()) }.unwrap(); + unsafe { std::alloc::dealloc(storage, layout) }; + assert!(normalized.broker_routing.is_some()); + assert!(normalized.native_terminal_state.is_some()); + assert_eq!( + normalized.trusted_host_binding.is_some(), + size >= trusted_host_end, + "declared host vtable size {size}" + ); + if let Some(query) = normalized.trusted_host_binding { + let mut bytes = [0; 8]; + let mut len = usize::MAX; + assert_eq!( + unsafe { + query( + normalized.host_ctx, + bytes.as_mut_ptr(), + bytes.len(), + &mut len, + ) + }, + TB_OK + ); + assert_eq!(&bytes[..len], b":1.42"); + } + } +} diff --git a/crates/tinybus/src/module/availability.rs b/crates/tinybus/src/module/availability.rs new file mode 100644 index 00000000..d8ebe1b7 --- /dev/null +++ b/crates/tinybus/src/module/availability.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +/// Coarse module status a host can report, whether or not it enables loading. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Availability { + /// Known to this build, not loaded yet. + Available, + /// Being downloaded, verified or initialised. + Loading, + /// Serving its interface. + Ready, + /// Cannot be loaded in this process, and will not be retried. + Failed, + /// No artifact is published for this host, or modules are disabled. + Unsupported, + /// Switched off by host-side policy. + Disabled, +} diff --git a/crates/tinybus/src/module/client.rs b/crates/tinybus/src/module/client.rs index 801940b1..163a0a84 100644 --- a/crates/tinybus/src/module/client.rs +++ b/crates/tinybus/src/module/client.rs @@ -27,6 +27,15 @@ pub enum ProxyFailure { Contract, } +/// Outcome when bounded readiness did not return a serving module. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReadinessFailure { + /// The access layer reported that the module is unavailable. + Unavailable, + /// The caller's explicit preparation deadline elapsed. + TimedOut, +} + /// What a [`ModuleClient`] needs from the host to reach a registry module. /// /// With the `modules` feature the host's `Resolver` implements it, loading the @@ -50,6 +59,36 @@ pub trait ModuleAccess: Send + Sync { /// A message fit for a user or a model when the module cannot be loaded. fn ensure_loaded<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>>; + /// Ensure `id` is ready, optionally bounding this caller's wait. + /// + /// Without a deadline this keeps the existing unbounded behavior. The + /// default bounds the returned future when one is supplied. An access layer + /// whose load work belongs to an independent process-lifetime task should + /// override this method so timing out the caller does not cancel that work. + fn ensure_loaded_within<'a>( + &'a self, + id: &'a str, + within: Option, + ) -> AccessFuture<'a, Result<(), ReadinessFailure>> { + Box::pin(async move { + let loading = self.ensure_loaded(id); + match within { + Some(within) => tokio::time::timeout(within, loading) + .await + .map_err(|_| ReadinessFailure::TimedOut)? + .map_err(|_| ReadinessFailure::Unavailable), + None => loading.await.map_err(|_| ReadinessFailure::Unavailable), + } + }) + } + + /// Perform host-owned readiness work before a strict recipient-attestation + /// query. The default refuses because generic accessors cannot prove that + /// a lazy native initializer has run without sending a business call. + fn prepare_attested<'a>(&'a self, _id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async { Err("strict recipient preparation is unsupported".to_string()) }) + } + /// A proxy for `object_path` on the serving module at `bus_name`. /// /// # Errors @@ -60,6 +99,100 @@ pub trait ModuleAccess: Send + Sync { bus_name: &'a str, object_path: &'a str, ) -> AccessFuture<'a, Result>; + + /// Reinitialize a loaded module through its owning lifecycle host. + /// + /// Implementations without lifecycle support may keep the default, which + /// reports [`ReconfigureFailure::Unsupported`]. + fn reinitialize<'a>( + &'a self, + _id: &'a str, + _config: serde_json::Value, + ) -> AccessFuture<'a, Result<(), ReconfigureFailure>> { + Box::pin(async { Err(ReconfigureFailure::Unsupported) }) + } + + /// Record that reinitialization left a module unusable. Resolver-backed + /// access makes this terminal for the current process; other accessors may + /// leave it as a no-op. + fn mark_faulted(&self, _id: &str) {} +} + +/// Module access for hosts that compile the registry but omit the dynamic +/// loader. Known modules remain discoverable and report the closed +/// `loader_disabled` reason when requested; unknown identifiers are not copied +/// into diagnostics. +pub struct UnloadedAccess { + records: Vec<&'static ReleaseRecord>, + enabled: bool, + reporter: Arc, +} + +impl UnloadedAccess { + /// Create access over the host's compiled module records without a loader. + #[must_use] + pub fn new( + records: Vec<&'static ReleaseRecord>, + enabled: bool, + reporter: Arc, + ) -> Self { + Self { + records, + enabled, + reporter, + } + } +} + +impl ModuleAccess for UnloadedAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + self.records.iter().copied().find(|record| record.id == id) + } + + fn enabled(&self) -> bool { + self.enabled + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async move { + if let Some(record) = self.find(id) { + tracing::debug!(module = record.id, "module unavailable: loader is disabled"); + self.reporter + .report(record.id, record.version, Reason::LoaderDisabled); + Err(format!( + "module '{}' is unavailable: this build has no module loader", + record.id + )) + } else { + Err("module is unavailable: this build has no module loader".to_string()) + } + }) + } + + fn proxy<'a>( + &'a self, + _bus_name: &'a str, + _object_path: &'a str, + ) -> AccessFuture<'a, Result> { + Box::pin(async { Err(ProxyFailure::Transport) }) + } +} + +/// Sanitized lifecycle result used by configuration reconciliation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReconfigureFailure { + /// The module is still completing initialization and may become ready. + Initializing, + /// The module is unavailable in a terminal or unsupported state. + Unavailable, + /// The lifecycle operation failed for another reason. + Failed, + /// This access implementation does not expose module lifecycle control. + Unsupported, } /// A sanitised terminal module outcome. Arguments and remote error text are @@ -92,6 +225,75 @@ impl std::fmt::Display for ModuleCallError { impl std::error::Error for ModuleCallError {} +/// A detailed call failure with remote prose kept out of its renderings. +/// +/// The fields are untrusted peer-provided data available to a product adapter +/// that has its own display policy. A remote message may contain arguments or +/// credentials. `Debug`, `Display`, and the failure reporter never include +/// these fields; callers should not log or display them without their own +/// redaction policy. +#[derive(Clone, PartialEq, Eq)] +pub struct ModuleCallFailure { + /// The closed, sanitized module outcome. + pub kind: ModuleCallError, + /// The untrusted dotted name returned by a remote method failure, or a + /// fixed local name for failures that did not come from a remote method. + /// Treat remote values as sensitive and do not log them directly. + pub wire_name: String, + /// The exact untrusted remote method message, or a fixed local message. + /// It may contain arguments or credentials; do not log or display it + /// without applying a product-specific redaction policy. + pub message: String, +} + +impl ModuleCallFailure { + fn local(kind: ModuleCallError) -> Self { + let wire_name = match kind { + ModuleCallError::Unavailable => "ai.tinyhumans.tinybus.Error.ModuleUnavailable", + ModuleCallError::IncompatibleContract => { + "ai.tinyhumans.tinybus.Error.IncompatibleVersion" + } + ModuleCallError::TransportFailed => "ai.tinyhumans.tinybus.Error.Transport", + ModuleCallError::ModuleFault => "ai.tinyhumans.tinybus.Error.Failed", + }; + Self { + kind, + wire_name: wire_name.to_string(), + message: "module call failed".to_string(), + } + } + + fn from_error(error: &Error, kind: ModuleCallError) -> Self { + match error { + Error::MethodFailed { name, message } => Self { + kind, + wire_name: name.clone(), + message: message.clone(), + }, + _ => Self::local(kind), + } + } +} + +impl std::fmt::Debug for ModuleCallFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ModuleCallFailure") + .field("kind", &self.kind) + .field("wire_name", &"") + .field("message", &"") + .finish() + } +} + +impl std::fmt::Display for ModuleCallFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{kind}", kind = self.kind) + } +} + +impl std::error::Error for ModuleCallFailure {} + /// Calls contract members on registry modules, loading them lazily. #[derive(Clone)] pub struct ModuleClient { @@ -153,6 +355,94 @@ impl ModuleClient { self.invoke(module, member, args, false).await } + /// Execute a contract member and retain a remote method's wire failure. + /// + /// Remote details are available only in the returned fields. Its `Debug` + /// and `Display` renderings, and the terminal report, remain sanitized. + /// + /// # Errors + /// + /// A detailed [`ModuleCallFailure`]. + pub async fn call_detailed( + &self, + module: &str, + member: &str, + args: impl Serialize, + ) -> Result { + self.invoke_detailed(module, member, args, false).await + } + + /// Execute a contract member while uploading its bulk payload as a stream. + /// + /// The argument builder receives the stream handle only after the module + /// is validated and ready. Its serialized arguments, method call and + /// payload use the single proxy selected for this invocation. + /// + /// # Errors + /// + /// A detailed [`ModuleCallFailure`]. Remote method details remain redacted + /// from `Debug`, `Display`, and terminal reports. + pub async fn call_with_stream_detailed( + &self, + module: &str, + member: &str, + args: F, + bytes: &[u8], + ) -> Result + where + R: DeserializeOwned, + F: FnOnce(&crate::stream::StreamRef) -> A, + A: Serialize, + { + let record = self.record(module).map_err(ModuleCallFailure::local)?; + let member = crate::MemberName::new(member).map_err(|error| { + report_proxy_failure_detailed(self.access.reporter(), record, &error) + })?; + let proxy = if let Some(fixture) = &self.fixture { + fixture.clone() + } else { + self.load_proxy(record, record.object_path) + .await + .map_err(ModuleCallFailure::local)? + }; + let proxy = self.with_deadline(proxy); + proxy + .connection() + .call_with_stream_serialized( + proxy.destination().clone(), + proxy.path().clone(), + proxy.interface().clone(), + member, + args, + bytes, + proxy.timeout(), + ) + .await + .map_err(|error| report_proxy_failure_detailed(self.access.reporter(), record, &error)) + } + + /// Sanitized counterpart to [`Self::call_with_stream_detailed`]. + /// + /// # Errors + /// + /// A sanitized [`ModuleCallError`]. + pub async fn call_with_stream( + &self, + module: &str, + member: &str, + args: F, + bytes: &[u8], + ) -> Result + where + R: DeserializeOwned, + F: FnOnce(&crate::stream::StreamRef) -> A, + A: Serialize, + { + self.call_with_stream_detailed(module, member, args, bytes) + .await + .map_err(|failure| failure.kind) + } + /// Execute a confidential member after TinyBus recipient attestation. /// /// A failure to attest the pinned artifact fails the call; ordinary delivery @@ -170,6 +460,224 @@ impl ModuleClient { self.invoke(module, member, args, true).await } + /// Execute a confidential member only when the host registry pins both the + /// release archive and extracted library and the same unique peer proves + /// that exact pair. Lazy readiness is established without a business call, + /// before this method queries the broker or serializes `args`. + /// + /// # Errors + /// + /// A sanitized [`ModuleCallError`]. Missing or mismatched proofs fail + /// closed and are never retried through ordinary delivery. + pub async fn call_attested( + &self, + module: &str, + member: &str, + args: impl Serialize, + ) -> Result { + self.call_attested_detailed(module, member, args) + .await + .map_err(|failure| failure.kind) + } + + /// Bind to the exact unique peer that proves the registered artifact. + /// + /// Lazy readiness and strict attestation happen before this returns. Every + /// call and held-object client from the result keeps the same private unique + /// destination, even if the public well-known name later changes owners. + /// + /// # Errors + /// + /// A sanitized [`ModuleCallFailure`] when loading or attestation fails. + pub async fn bind_attested( + &self, + module: &str, + ) -> Result { + let record = self.record(module).map_err(ModuleCallFailure::local)?; + let candidates = crate::module::platform::host_candidates(); + // The resolver tries compatible release assets in host-candidate order + // and may fall back after an admission failure. Keep each archive and + // library pin paired from the trusted registry so the loaded fallback + // can prove itself without allowing digest mixing across assets. + let compatible_assets: Vec<_> = record + .assets + .iter() + .filter(|asset| candidates.iter().any(|host| host == asset.host_key)) + .filter(|asset| { + valid_sha256(asset.sha256) && asset.library_sha256.is_some_and(valid_sha256) + }) + .collect(); + if compatible_assets.is_empty() { + report(&*self.access, record, Reason::ModuleUnavailable); + return Err(ModuleCallFailure::local(ModuleCallError::Unavailable)); + } + let preparation = self.access.prepare_attested(record.id); + let prepared = if let Some(timeout) = self.timeout { + tokio::time::timeout(timeout, preparation) + .await + .is_ok_and(|result| result.is_ok()) + } else { + preparation.await.is_ok() + }; + if self.fixture.is_some() || !prepared { + report(&*self.access, record, Reason::ModuleUnavailable); + return Err(ModuleCallFailure::local(ModuleCallError::Unavailable)); + } + + let name_proxy = self + .load_proxy(record, record.object_path) + .await + .map_err(ModuleCallFailure::local)?; + let metadata_timeout = self.timeout.unwrap_or(crate::connection::DEFAULT_TIMEOUT); + let owner = match name_proxy + .connection() + .name_owner_with_timeout(record.bus_name, metadata_timeout) + .await + { + Ok(Some(owner)) if owner.is_unique() => owner, + Ok(_) => { + report(&*self.access, record, Reason::ModuleUnavailable); + return Err(ModuleCallFailure::local(ModuleCallError::Unavailable)); + } + Err(error) => { + return Err(report_proxy_failure_detailed( + self.access.reporter(), + record, + &error, + )); + } + }; + let unique_proxy = name_proxy + .connection() + .proxy(owner.as_str(), record.object_path, record.bus_name) + .map_err(|_| { + report(&*self.access, record, Reason::IncompatibleContract); + ModuleCallFailure::local(ModuleCallError::IncompatibleContract) + })?; + let attestation = match unique_proxy + .connection() + .attestation_with_timeout(owner.clone(), metadata_timeout) + .await + { + Ok(Some(attestation)) => attestation, + Ok(None) => { + report(&*self.access, record, Reason::ModuleUnavailable); + return Err(ModuleCallFailure::local(ModuleCallError::Unavailable)); + } + Err(error) => { + return Err(report_proxy_failure_detailed( + self.access.reporter(), + record, + &error, + )); + } + }; + let expected_name = crate::name::BusName::new(record.bus_name) + .expect("a release registry carries a validated bus name"); + if attestation.name != expected_name + || !compatible_assets.iter().any(|asset| { + attestation.sha256.eq_ignore_ascii_case(asset.sha256) + && attestation.library_sha256.as_deref().is_some_and(|digest| { + digest.eq_ignore_ascii_case( + asset.library_sha256.expect("filtered library pin"), + ) + }) + }) + { + report(&*self.access, record, Reason::ModuleUnavailable); + return Err(ModuleCallFailure::local(ModuleCallError::Unavailable)); + } + + Ok(BoundModuleClient { + access: Arc::clone(&self.access), + record, + proxy: self.with_deadline(unique_proxy), + timeout: self.timeout, + }) + } + + /// Bind ordinary calls to the currently serving peer for a module. + /// + /// The peer is resolved after lazy readiness and retained privately by the + /// returned client. This is ordinary delivery and makes no artifact or + /// confidentiality claim. Use [`Self::bind_attested`] for calls that + /// require strict native artifact proof. + /// + /// Explicit fixture proxies follow the same owner pinning rule, while + /// resolver-backed linked modules remain eligible without library hashes. + /// + /// # Errors + /// + /// A sanitized [`ModuleCallFailure`] when the module cannot be made ready, + /// has no serving owner, or its bus metadata cannot be read. + pub async fn bind(&self, module: &str) -> Result { + let record = self.record(module).map_err(ModuleCallFailure::local)?; + let alias_proxy = if let Some(fixture) = &self.fixture { + fixture.clone() + } else { + self.load_proxy_for_binding(record, record.object_path) + .await? + }; + // `ModuleAccess::proxy` and explicit fixtures may carry an integration- + // specific deadline. An explicit client timeout overrides it; otherwise + // preserve the deadline already attached to the selected proxy. + let effective_timeout = self.timeout.unwrap_or(alias_proxy.timeout()); + let owner = match alias_proxy + .connection() + .name_owner_with_timeout(alias_proxy.destination().as_str(), effective_timeout) + .await + { + Ok(Some(owner)) if owner.is_unique() => owner, + Ok(_) => { + report(&*self.access, record, Reason::ModuleUnavailable); + return Err(ModuleCallFailure::local(ModuleCallError::Unavailable)); + } + Err(error) => { + return Err(report_proxy_failure_detailed( + self.access.reporter(), + record, + &error, + )); + } + }; + let pinned_proxy = alias_proxy + .connection() + .proxy( + owner.as_str(), + alias_proxy.path().as_str(), + alias_proxy.interface().as_str(), + ) + .map_err(|_| { + report(&*self.access, record, Reason::IncompatibleContract); + ModuleCallFailure::local(ModuleCallError::IncompatibleContract) + })? + .with_timeout(effective_timeout); + + Ok(PinnedModuleClient { + access: Arc::clone(&self.access), + record, + proxy: pinned_proxy, + timeout: effective_timeout, + }) + } + + /// Execute a confidential member only after strict artifact attestation. + /// + /// The bound peer is selected and proved before `args` are serialized. + /// Remote failure fields remain available to the caller, while `Debug`, + /// `Display`, and reports stay sanitized. + pub async fn call_attested_detailed( + &self, + module: &str, + member: &str, + args: impl Serialize, + ) -> Result { + self.bind_attested(module) + .await? + .call_confidential_detailed(member, args) + .await + } + /// Call a member that returns a JSON-encoded string and deserialise that /// inner document. Malformed output is reported with only the registered /// module identity and the closed `module_fault` reason. @@ -225,7 +733,10 @@ impl ModuleClient { object_path, fixture.interface().as_str(), ) - .map_err(|_| ModuleCallError::IncompatibleContract)?; + .map_err(|_| { + report(&*self.access, record, Reason::IncompatibleContract); + ModuleCallError::IncompatibleContract + })?; return Ok(self.object(record, proxy)); } let proxy = self.load_proxy(record, object_path).await?; @@ -304,6 +815,59 @@ impl ModuleClient { }) } + /// Load a module for an ordinary persistent binding, honoring an explicit + /// client timeout without making a terminal loader failure from a caller + /// that merely stopped waiting. + async fn load_proxy_for_binding( + &self, + record: &'static ReleaseRecord, + object_path: &str, + ) -> Result { + match self + .access + .ensure_loaded_within(record.id, self.timeout) + .await + { + Ok(()) => {} + Err(ReadinessFailure::Unavailable) => { + return Err(ModuleCallFailure::local(ModuleCallError::Unavailable)); + } + Err(ReadinessFailure::TimedOut) => { + return Err(report_proxy_failure_detailed( + self.access.reporter(), + record, + &Error::transport("module readiness deadline elapsed"), + )); + } + } + + let proxy = self.access.proxy(record.bus_name, object_path); + let proxy = if let Some(timeout) = self.timeout { + match tokio::time::timeout(timeout, proxy).await { + Ok(result) => result, + Err(_) => { + return Err(report_proxy_failure_detailed( + self.access.reporter(), + record, + &Error::transport("module proxy preparation deadline elapsed"), + )); + } + } + } else { + proxy.await + }; + proxy.map_err(|failure| match failure { + ProxyFailure::Transport => { + report(&*self.access, record, Reason::TransportFailed); + ModuleCallFailure::local(ModuleCallError::TransportFailed) + } + ProxyFailure::Contract => { + report(&*self.access, record, Reason::IncompatibleContract); + ModuleCallFailure::local(ModuleCallError::IncompatibleContract) + } + }) + } + async fn invoke( &self, module: &str, @@ -321,6 +885,25 @@ impl ModuleClient { invoke_proxy(&*self.access, record, &proxy, member, args, confidential).await } + async fn invoke_detailed( + &self, + module: &str, + member: &str, + args: impl Serialize, + confidential: bool, + ) -> Result { + let record = self.record(module).map_err(ModuleCallFailure::local)?; + let proxy = if let Some(fixture) = &self.fixture { + fixture.clone() + } else { + self.load_proxy(record, record.object_path) + .await + .map_err(ModuleCallFailure::local)? + }; + let proxy = self.with_deadline(proxy); + invoke_proxy_detailed(&*self.access, record, &proxy, member, args, confidential).await + } + fn decode_json( &self, module: &str, @@ -333,6 +916,280 @@ impl ModuleClient { } } +/// A client permanently bound to one attested module peer. +/// +/// The selected unique bus destination is private and retained by every call +/// and object client created from this value. It is never resolved again by +/// well-known name. +#[derive(Clone)] +pub struct BoundModuleClient { + access: Arc, + record: &'static ReleaseRecord, + proxy: Proxy, + timeout: Option, +} + +impl BoundModuleClient { + /// Call an ordinary member on the bound peer. + pub async fn call( + &self, + member: &str, + args: impl Serialize, + ) -> Result { + invoke_proxy(&*self.access, self.record, &self.proxy, member, args, false).await + } + + /// Call an ordinary member and retain its remote wire failure fields. + pub async fn call_detailed( + &self, + member: &str, + args: impl Serialize, + ) -> Result { + invoke_proxy_detailed(&*self.access, self.record, &self.proxy, member, args, false).await + } + + /// Call a confidential member on the bound peer. + pub async fn call_confidential( + &self, + member: &str, + args: impl Serialize, + ) -> Result { + invoke_proxy(&*self.access, self.record, &self.proxy, member, args, true).await + } + + /// Call a confidential member and retain its remote wire failure fields. + pub async fn call_confidential_detailed( + &self, + member: &str, + args: impl Serialize, + ) -> Result { + invoke_proxy_detailed(&*self.access, self.record, &self.proxy, member, args, true).await + } + + /// Upload a bulk payload while calling a member on this same unique peer. + /// + /// The stream open, method call, and payload retain the pinned destination + /// even if the public bus name changes owners during argument construction. + /// This is ordinary bus delivery; it does not add a confidential stream + /// fallback. + /// + /// # Errors + /// + /// A detailed [`ModuleCallFailure`]. Its remote fields remain available to + /// the caller while `Debug`, `Display`, and reports stay sanitized. + pub async fn call_with_stream_detailed( + &self, + member: &str, + args: F, + bytes: &[u8], + ) -> Result + where + R: DeserializeOwned, + F: FnOnce(&crate::stream::StreamRef) -> A, + A: Serialize, + { + let member = crate::MemberName::new(member).map_err(|error| { + report_proxy_failure_detailed(self.access.reporter(), self.record, &error) + })?; + self.proxy + .connection() + .call_with_stream_serialized( + self.proxy.destination().clone(), + self.proxy.path().clone(), + self.proxy.interface().clone(), + member, + args, + bytes, + self.proxy.timeout(), + ) + .await + .map_err(|error| { + report_proxy_failure_detailed(self.access.reporter(), self.record, &error) + }) + } + + /// Sanitized counterpart to [`Self::call_with_stream_detailed`]. + pub async fn call_with_stream( + &self, + member: &str, + args: F, + bytes: &[u8], + ) -> Result + where + R: DeserializeOwned, + F: FnOnce(&crate::stream::StreamRef) -> A, + A: Serialize, + { + self.call_with_stream_detailed(member, args, bytes) + .await + .map_err(|failure| failure.kind) + } + + /// Create a held-object client pinned to this same unique module peer. + pub fn object_client(&self, object_path: &str) -> Result { + let proxy = self + .proxy + .connection() + .proxy( + self.proxy.destination().as_str(), + object_path, + self.proxy.interface().as_str(), + ) + .map_err(|_| { + report(&*self.access, self.record, Reason::IncompatibleContract); + ModuleCallError::IncompatibleContract + })?; + let proxy = self + .timeout + .map_or(proxy.clone(), |timeout| proxy.with_timeout(timeout)); + Ok(ModuleObjectClient { + access: Arc::clone(&self.access), + record: self.record, + proxy, + timeout: self.timeout, + }) + } +} + +/// A client pinned to one ordinary module peer. +/// +/// The selected unique destination stays private and is reused by ordinary +/// root calls, streamed calls, and object clients. This type carries no native +/// artifact proof and has no confidential-call API. +#[derive(Clone)] +pub struct PinnedModuleClient { + access: Arc, + record: &'static ReleaseRecord, + proxy: Proxy, + timeout: Duration, +} + +impl PinnedModuleClient { + /// Call an ordinary member on the selected peer. + pub async fn call( + &self, + member: &str, + args: impl Serialize, + ) -> Result { + invoke_proxy(&*self.access, self.record, &self.proxy, member, args, false).await + } + + /// Call an ordinary member and retain its remote wire failure fields. + pub async fn call_detailed( + &self, + member: &str, + args: impl Serialize, + ) -> Result { + invoke_proxy_detailed(&*self.access, self.record, &self.proxy, member, args, false).await + } + + /// Upload a payload while calling a member on the selected peer. + pub async fn call_with_stream_detailed( + &self, + member: &str, + args: F, + bytes: &[u8], + ) -> Result + where + R: DeserializeOwned, + F: FnOnce(&crate::stream::StreamRef) -> A, + A: Serialize, + { + let member = crate::MemberName::new(member).map_err(|error| { + report_proxy_failure_detailed(self.access.reporter(), self.record, &error) + })?; + self.proxy + .connection() + .call_with_stream_serialized( + self.proxy.destination().clone(), + self.proxy.path().clone(), + self.proxy.interface().clone(), + member, + args, + bytes, + self.proxy.timeout(), + ) + .await + .map_err(|error| { + report_proxy_failure_detailed(self.access.reporter(), self.record, &error) + }) + } + + /// Sanitized counterpart to [`Self::call_with_stream_detailed`]. + pub async fn call_with_stream( + &self, + member: &str, + args: F, + bytes: &[u8], + ) -> Result + where + R: DeserializeOwned, + F: FnOnce(&crate::stream::StreamRef) -> A, + A: Serialize, + { + self.call_with_stream_detailed(member, args, bytes) + .await + .map_err(|failure| failure.kind) + } + + /// Create an object client pinned to the same unique peer. + pub fn object_client( + &self, + object_path: &str, + ) -> Result { + let proxy = self + .proxy + .connection() + .proxy( + self.proxy.destination().as_str(), + object_path, + self.proxy.interface().as_str(), + ) + .map_err(|_| { + report(&*self.access, self.record, Reason::IncompatibleContract); + ModuleCallError::IncompatibleContract + })?; + let proxy = proxy.with_timeout(self.timeout); + Ok(PinnedModuleObjectClient { + access: Arc::clone(&self.access), + record: self.record, + proxy, + }) + } +} + +/// An ordinary client for an object owned by a pinned module peer. +/// +/// Its destination is private, and the surface has no confidential-call API. +#[derive(Clone)] +pub struct PinnedModuleObjectClient { + access: Arc, + record: &'static ReleaseRecord, + proxy: Proxy, +} + +impl PinnedModuleObjectClient { + /// Call an ordinary member of the object. + pub async fn call( + &self, + member: &str, + args: impl Serialize, + ) -> Result { + self.call_detailed(member, args) + .await + .map_err(|failure| failure.kind) + } + + /// Call the object and preserve an untrusted remote failure's wire details. + pub async fn call_detailed( + &self, + member: &str, + args: impl Serialize, + ) -> Result { + invoke_proxy_detailed(&*self.access, self.record, &self.proxy, member, args, false).await + } +} + /// A validated proxy for a module-owned object path. /// /// Bus object identities do not become product state or leak into logs and @@ -355,10 +1212,13 @@ impl ModuleObjectClient { let owner = self .proxy .connection() - .name_owner(self.proxy.destination()) + .name_owner_with_timeout(self.proxy.destination().as_str(), self.proxy.timeout()) .await .map_err(|error| report_proxy_failure(self.access.reporter(), self.record, &error))? - .ok_or(ModuleCallError::Unavailable)?; + .ok_or_else(|| { + report(&*self.access, self.record, Reason::ModuleUnavailable); + ModuleCallError::Unavailable + })?; self.derive(owner.as_str(), self.proxy.path().as_str()) } @@ -372,18 +1232,19 @@ impl ModuleObjectClient { } fn derive(&self, destination: &str, path: &str) -> Result { + let timeout = self.timeout.unwrap_or_else(|| self.proxy.timeout()); let proxy = self .proxy .connection() .proxy(destination, path, self.proxy.interface().as_str()) - .map_err(|error| report_proxy_failure(self.access.reporter(), self.record, &error))?; + .map_err(|error| { + report_proxy_failure_detailed(self.access.reporter(), self.record, &error).kind + })?; Ok(Self { access: Arc::clone(&self.access), record: self.record, - proxy: self - .timeout - .map_or(proxy.clone(), |timeout| proxy.with_timeout(timeout)), - timeout: self.timeout, + proxy: proxy.with_timeout(timeout), + timeout: Some(timeout), }) } @@ -397,7 +1258,25 @@ impl ModuleObjectClient { member: &str, args: impl Serialize, ) -> Result { - invoke_proxy(&*self.access, self.record, &self.proxy, member, args, false).await + self.call_detailed(member, args) + .await + .map_err(|failure| failure.kind) + } + + /// Call this object and preserve an untrusted remote failure's wire details. + /// + /// `Debug`, `Display`, and the reporter stay sanitized; the returned + /// `wire_name` and `message` fields require the caller's redaction policy. + /// + /// # Errors + /// + /// A detailed [`ModuleCallFailure`]. + pub async fn call_detailed( + &self, + member: &str, + args: impl Serialize, + ) -> Result { + invoke_proxy_detailed(&*self.access, self.record, &self.proxy, member, args, false).await } /// Call a confidential member of the object. @@ -422,12 +1301,25 @@ async fn invoke_proxy( args: impl Serialize, confidential: bool, ) -> Result { + invoke_proxy_detailed(access, record, proxy, member, args, confidential) + .await + .map_err(|failure| failure.kind) +} + +async fn invoke_proxy_detailed( + access: &dyn ModuleAccess, + record: &'static ReleaseRecord, + proxy: &Proxy, + member: &str, + args: impl Serialize, + confidential: bool, +) -> Result { let result = if confidential { proxy.call_confidential(member, args).await } else { proxy.call(member, args).await }; - result.map_err(|error| report_proxy_failure(access.reporter(), record, &error)) + result.map_err(|error| report_proxy_failure_detailed(access.reporter(), record, &error)) } /// Report a bus failure without exposing its payload. Product adapters may @@ -437,13 +1329,21 @@ pub fn report_proxy_failure( record: &ReleaseRecord, error: &Error, ) -> ModuleCallError { + report_proxy_failure_detailed(reporter, record, error).kind +} + +fn report_proxy_failure_detailed( + reporter: &FailureReporter, + record: &ReleaseRecord, + error: &Error, +) -> ModuleCallFailure { let (outcome, reason) = classify(error); if reason == Reason::ModuleUnavailable { reporter.report(record.id, record.version, reason); } else { reporter.report_invocation(record.id, record.version, reason); } - outcome + ModuleCallFailure::from_error(error, outcome) } fn report(access: &dyn ModuleAccess, record: &ReleaseRecord, reason: Reason) { @@ -460,6 +1360,10 @@ fn report_invocation(access: &dyn ModuleAccess, record: &ReleaseRecord, reason: #[must_use] pub fn classify(error: &Error) -> (ModuleCallError, Reason) { match error { + Error::InvalidName { .. } => ( + ModuleCallError::IncompatibleContract, + Reason::IncompatibleContract, + ), Error::ModuleUnavailable { .. } => { (ModuleCallError::Unavailable, Reason::ModuleUnavailable) } @@ -472,9 +1376,7 @@ pub fn classify(error: &Error) -> (ModuleCallError, Reason) { ModuleCallError::IncompatibleContract, Reason::IncompatibleContract, ), - Error::MethodFailed { name, .. } - if name == "ai.tinyhumans.tinybus.Error.ModuleUnavailable" => - { + Error::MethodFailed { name, .. } if name == Error::MODULE_UNAVAILABLE => { (ModuleCallError::Unavailable, Reason::ModuleUnavailable) } Error::MethodFailed { name, .. } @@ -495,7 +1397,9 @@ pub fn classify(error: &Error) -> (ModuleCallError, Reason) { Error::MethodFailed { name, .. } if matches!( name.as_str(), - Error::NOT_ATTESTED | Error::CONFIDENTIALITY_REQUIRED + Error::NOT_ATTESTED + | Error::CONFIDENTIALITY_REQUIRED + | "ai.tinyhumans.tinybus.Error.Protocol" ) => { (ModuleCallError::TransportFailed, Reason::TransportFailed) @@ -505,6 +1409,24 @@ pub fn classify(error: &Error) -> (ModuleCallError, Reason) { } } +/// Normalize any bus error to the closed module-call classification. +#[must_use] +pub fn wire_kind(error: &Error) -> ModuleCallError { + classify(error).0 +} + +fn valid_sha256(digest: &str) -> bool { + digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + #[cfg(test)] #[path = "client_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "client_stream_tests.rs"] +mod stream_tests; + +#[cfg(test)] +#[path = "client_pinned_tests.rs"] +mod pinned_tests; diff --git a/crates/tinybus/src/module/client_attested_tests.rs b/crates/tinybus/src/module/client_attested_tests.rs new file mode 100644 index 00000000..17ca2eff --- /dev/null +++ b/crates/tinybus/src/module/client_attested_tests.rs @@ -0,0 +1,1484 @@ +use super::*; + +fn strict_record() -> &'static ReleaseRecord { + let host_key = Box::leak( + crate::module::platform::host_candidates() + .into_iter() + .next() + .expect("the test host has a published module key") + .into_boxed_str(), + ); + let archive = Box::leak("attested-test.tar.gz".to_string().into_boxed_str()); + let archive_sha = Box::leak("a".repeat(64).into_boxed_str()); + let library_sha = Box::leak("b".repeat(64).into_boxed_str()); + strict_record_with_assets(vec![crate::module::ReleaseAsset { + host_key, + archive, + sha256: archive_sha, + library_sha256: Some(library_sha), + }]) +} + +fn strict_record_with_assets( + assets: Vec>, +) -> &'static ReleaseRecord { + let assets = Box::leak(assets.into_boxed_slice()); + Box::leak(Box::new(ReleaseRecord { + id: "attested-client-test", + bus_name: "ai.tinyhumans.test.AttestedClient", + object_path: "/ai/tinyhumans/test/AttestedClient", + version: "1.0.0", + release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v1.0.0", + assets, + })) +} + +#[cfg(feature = "modules")] +fn attested_asset( + host_key: &'static str, + suffix: &str, + archive_digest: char, + library_digest: char, +) -> crate::module::ReleaseAsset<'static> { + crate::module::ReleaseAsset { + host_key, + archive: Box::leak(format!("attested-{suffix}.tar.gz").into_boxed_str()), + sha256: Box::leak(archive_digest.to_string().repeat(64).into_boxed_str()), + library_sha256: Some(Box::leak( + library_digest.to_string().repeat(64).into_boxed_str(), + )), + } +} + +struct PreparedAccess { + record: &'static ReleaseRecord, + connection: crate::Connection, + reporter: FailureReporter, + preparations: Arc, +} + +impl ModuleAccess for PreparedAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == self.record.id).then_some(self.record) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn prepare_attested<'a>(&'a self, _: &'a str) -> AccessFuture<'a, Result<(), String>> { + self.preparations + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + 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) + }) + } +} + +struct UnpreparedAccess(PreparedAccess); + +impl ModuleAccess for UnpreparedAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + self.0.find(id) + } + + fn enabled(&self) -> bool { + self.0.enabled() + } + + fn reporter(&self) -> &FailureReporter { + self.0.reporter() + } + + fn ensure_loaded<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + self.0.ensure_loaded(id) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + self.0.proxy(bus_name, object_path) + } +} + +struct PendingAccess(PreparedAccess); + +impl ModuleAccess for PendingAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + self.0.find(id) + } + + fn enabled(&self) -> bool { + self.0.enabled() + } + + fn reporter(&self) -> &FailureReporter { + self.0.reporter() + } + + fn ensure_loaded<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + self.0.ensure_loaded(id) + } + + fn prepare_attested<'a>(&'a self, _: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(std::future::pending()) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + self.0.proxy(bus_name, object_path) + } +} + +#[cfg(feature = "modules")] +struct ReadinessAccess { + record: &'static ReleaseRecord, + connection: crate::Connection, + reporter: FailureReporter, + host: Arc, +} + +#[cfg(feature = "modules")] +impl ModuleAccess for ReadinessAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == self.record.id).then_some(self.record) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn prepare_attested<'a>(&'a self, _: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async move { + self.host + .ensure_ready_within(self.record.bus_name, Duration::from_millis(20)) + .await + .map_err(|_| "module did not reach READY".to_string()) + }) + } + + 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) + }) + } +} + +#[cfg(feature = "modules")] +unsafe extern "C" fn never_ready_deliver(_: *mut std::ffi::c_void, _: *const u8, _: usize) -> i32 { + crate::module::abi::TB_OK +} + +#[cfg(feature = "modules")] +unsafe extern "C" fn no_op_lifecycle(_: *mut std::ffi::c_void, _: u64) -> i32 { + crate::module::abi::TB_OK +} + +#[cfg(feature = "modules")] +unsafe extern "C" fn no_op_reinitialize(_: *mut std::ffi::c_void, _: *const u8, _: usize) -> i32 { + crate::module::abi::TB_OK +} + +#[cfg(feature = "modules")] +unsafe extern "C" fn never_ready_init( + _: *const crate::module::abi::TbHostVtable, + out: *mut crate::module::abi::TbModuleVtable, +) -> i32 { + unsafe { + *out = crate::module::abi::TbModuleVtable { + size: std::mem::size_of::() as u32, + _reserved: 0, + module_ctx: Box::into_raw(Box::new(0u8)).cast(), + deliver: never_ready_deliver, + shutdown: no_op_lifecycle, + reinitialize: Some(no_op_reinitialize), + }; + } + crate::module::abi::TB_OK +} + +struct SerializeCounter(Arc); + +impl serde::Serialize for SerializeCounter { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + serializer.serialize_unit() + } +} + +#[tokio::test] +async fn an_unprepared_custom_access_refuses_before_serializing_confidential_arguments() { + let bus = crate::transport::memory::MemoryBus::new(); + let broker = crate::broker::Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let connection = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let record = strict_record(); + let sink = Arc::new(Recording::default()); + let preparations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let access = UnpreparedAccess(PreparedAccess { + record, + connection, + reporter: FailureReporter::new(sink.clone()), + preparations, + }); + let client = ModuleClient::new(access); + let serializations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + + let result: Result = client + .call_attested_detailed( + record.id, + "Consume", + SerializeCounter(serializations.clone()), + ) + .await; + assert_eq!(result.unwrap_err().kind, ModuleCallError::Unavailable); + assert_eq!(serializations.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(record.id.to_string(), Reason::ModuleUnavailable)] + ); + broker_task.abort(); +} + +#[tokio::test] +async fn a_short_client_deadline_bounds_readiness_before_argument_serialization() { + let bus = crate::transport::memory::MemoryBus::new(); + let _broker = crate::broker::Broker::new().spawn(bus.clone()); + let connection = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let record = strict_record(); + let sink = Arc::new(Recording::default()); + let access = PendingAccess(PreparedAccess { + record, + connection, + reporter: FailureReporter::new(sink.clone()), + preparations: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }); + let client = ModuleClient::new(access).with_timeout(Duration::from_millis(10)); + let serializations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + + let result: Result = client + .call_attested_detailed( + record.id, + "Consume", + SerializeCounter(serializations.clone()), + ) + .await; + assert_eq!(result.unwrap_err().kind, ModuleCallError::Unavailable); + assert_eq!(serializations.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(record.id.to_string(), Reason::ModuleUnavailable)] + ); +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn a_lazy_module_that_never_announces_ready_fails_before_serializing_arguments() { + use crate::module::manifest::{ + MANIFEST_SCHEMA, ModuleIdentity, ModuleManifest, PanicPolicy, ProvidedInterface, + }; + use crate::version::{InterfaceVersion, Version}; + + let bus = crate::transport::memory::MemoryBus::new(); + let broker = crate::broker::Broker::new(); + let _broker_task = broker.clone().spawn(bus.clone()); + let host = Arc::new(crate::module::ModuleHost::new(broker)); + let connection = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let record = strict_record(); + let version = Version::new(1, 0, 0); + let manifest = ModuleManifest { + schema: MANIFEST_SCHEMA, + module: ModuleIdentity { + name: "attested-client-test".to_string(), + version: version.clone(), + description: String::new(), + homepage: None, + license: String::new(), + }, + bus_name: crate::BusName::new(record.bus_name).unwrap(), + object_path: crate::ObjectPath::new(record.object_path).unwrap(), + provides: vec![ProvidedInterface { + version: InterfaceVersion::provided( + crate::InterfaceName::new(record.bus_name).unwrap(), + version, + ), + methods: vec![crate::MemberName::new("Consume").unwrap()], + signals: Vec::new(), + }], + requires: Vec::new(), + environment: Vec::new(), + capabilities: Vec::new(), + lazy_init: true, + worker_threads: 1, + on_panic: PanicPolicy::Detach, + }; + unsafe { + host.attach_raw( + "attested-client.so", + crate::module::abi::TbAbiDescriptor::current("attested-client-test", "1.0.0"), + manifest, + never_ready_init, + ) + } + .unwrap(); + let sink = Arc::new(Recording::default()); + let client = ModuleClient::new(ReadinessAccess { + record, + connection, + reporter: FailureReporter::new(sink.clone()), + host, + }); + let serializations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + + let result: Result = client + .call_attested_detailed( + record.id, + "Consume", + SerializeCounter(serializations.clone()), + ) + .await; + assert_eq!(result.unwrap_err().kind, ModuleCallError::Unavailable); + assert_eq!(serializations.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(record.id.to_string(), Reason::ModuleUnavailable)] + ); +} + +struct AttestedReceiver { + label: &'static str, + calls: Arc>>, +} + +#[cfg(feature = "modules")] +struct AttestedFailureReceiver { + name: &'static str, + message: &'static str, +} + +#[cfg(feature = "modules")] +#[async_trait::async_trait] +impl crate::Interface for AttestedFailureReceiver { + fn name(&self) -> crate::InterfaceName { + crate::InterfaceName::new("ai.tinyhumans.test.AttestedClient").unwrap() + } + + fn members(&self) -> Vec { + vec![crate::MemberName::new("Consume").unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Err(crate::Error::MethodFailed { + name: self.name.to_string(), + message: self.message.to_string(), + }) + } +} + +#[cfg(feature = "modules")] +async fn call_with_attestation_failure( + name: &'static str, + message: &'static str, +) -> ( + Result, + Result, + Arc, +) { + let bus = crate::transport::memory::MemoryBus::new(); + let broker = crate::broker::Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let service = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let record = strict_record(); + service + .serve_at( + crate::ObjectPath::new(record.object_path).unwrap(), + AttestedFailureReceiver { name, message }, + ) + .await + .unwrap(); + service.request_name(record.bus_name).await.unwrap(); + broker.attest_module( + &service.unique_name().unwrap(), + crate::attest::Attestation { + name: crate::BusName::new(record.bus_name).unwrap(), + sha256: record.assets[0].sha256.to_string(), + library_sha256: record.assets[0].library_sha256.map(str::to_string), + }, + ); + let sink = Arc::new(Recording::default()); + let access = PreparedAccess { + record, + connection: caller, + reporter: FailureReporter::new(sink.clone()), + preparations: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }; + let client = ModuleClient::new(access); + let detailed = client + .call_attested_detailed(record.id, "Consume", ()) + .await; + let sanitized = client.call_attested(record.id, "Consume", ()).await; + broker_task.abort(); + (detailed, sanitized, sink) +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn strict_attested_detailed_calls_preserve_remote_input_refusals() { + let (failure, sanitized, sink) = call_with_attestation_failure( + "ai.tinyhumans.tinywallet.Error.InvalidInput", + "wallet address secret is invalid", + ) + .await; + let failure = failure.unwrap_err(); + assert_eq!(failure.kind, ModuleCallError::ModuleFault); + assert_eq!( + failure.wire_name, + "ai.tinyhumans.tinywallet.Error.InvalidInput" + ); + assert_eq!(failure.message, "wallet address secret is invalid"); + assert_eq!(sanitized, Err(ModuleCallError::ModuleFault)); + assert!(!format!("{failure:?}").contains("wallet address secret")); + assert!(!failure.to_string().contains("wallet address secret")); + let reports = sink.0.lock().unwrap(); + assert_eq!(reports.len(), 2); + assert!( + reports + .iter() + .all(|(_, reason)| *reason == Reason::ModuleFault) + ); + assert!(!format!("{reports:?}").contains("wallet address secret")); +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn strict_attested_detailed_calls_preserve_remote_chain_refusals() { + let (failure, sanitized, sink) = call_with_attestation_failure( + "ai.tinyhumans.tinywallet.Error.UnsupportedChain", + "private configured chain is unsupported", + ) + .await; + let failure = failure.unwrap_err(); + assert_eq!(failure.kind, ModuleCallError::ModuleFault); + assert_eq!( + failure.wire_name, + "ai.tinyhumans.tinywallet.Error.UnsupportedChain" + ); + assert_eq!(failure.message, "private configured chain is unsupported"); + assert_eq!(sanitized, Err(ModuleCallError::ModuleFault)); + assert!(!format!("{failure:?}").contains("private configured chain")); + assert!(!failure.to_string().contains("private configured chain")); + let reports = sink.0.lock().unwrap(); + assert_eq!(reports.len(), 2); + assert!( + reports + .iter() + .all(|(_, reason)| *reason == Reason::ModuleFault) + ); + assert!(!format!("{reports:?}").contains("private configured chain")); +} + +#[async_trait::async_trait] +impl crate::Interface for AttestedReceiver { + fn name(&self) -> crate::InterfaceName { + crate::InterfaceName::new("ai.tinyhumans.test.AttestedClient").unwrap() + } + + fn members(&self) -> Vec { + vec![crate::MemberName::new("Consume").unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + args: serde_json::Value, + ) -> crate::Result { + self.calls.lock().unwrap().push(args); + Ok(serde_json::json!(self.label)) + } +} + +#[cfg(feature = "modules")] +struct AttestedCallObservation { + result: Result, + serialized_arguments: usize, + receiver_calls: usize, +} + +#[cfg(feature = "modules")] +async fn call_with_attestation( + record: &'static ReleaseRecord, + archive_sha256: &str, + library_sha256: &str, +) -> AttestedCallObservation { + let bus = crate::transport::memory::MemoryBus::new(); + let broker = crate::broker::Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let service = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let receiver_calls = Arc::new(Mutex::new(Vec::new())); + service + .serve_at( + crate::ObjectPath::new(record.object_path).unwrap(), + AttestedReceiver { + label: "attested", + calls: receiver_calls.clone(), + }, + ) + .await + .unwrap(); + service.request_name(record.bus_name).await.unwrap(); + broker.attest_module( + &service.unique_name().unwrap(), + crate::attest::Attestation { + name: crate::BusName::new(record.bus_name).unwrap(), + sha256: archive_sha256.to_string(), + library_sha256: Some(library_sha256.to_string()), + }, + ); + let access = PreparedAccess { + record, + connection: caller, + reporter: FailureReporter::new(Arc::new(Recording::default())), + preparations: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }; + let serialized_arguments = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let result = ModuleClient::new(access) + .call_attested_detailed( + record.id, + "Consume", + SerializeCounter(serialized_arguments.clone()), + ) + .await; + broker_task.abort(); + AttestedCallObservation { + result, + serialized_arguments: serialized_arguments.load(std::sync::atomic::Ordering::SeqCst), + receiver_calls: receiver_calls.lock().unwrap().len(), + } +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn attested_calls_accept_a_verified_compatible_fallback_asset_pair() { + let candidates = crate::module::platform::host_candidates(); + let first_key = Box::leak(candidates[0].clone().into_boxed_str()); + let fallback_key = Box::leak(candidates.last().unwrap().clone().into_boxed_str()); + // A one-key host models two byte pairs for one compatible target; hosts + // with ordered fallback candidates exercise the real later candidate. + let first = attested_asset(first_key, "preferred", 'a', 'b'); + let fallback = attested_asset(fallback_key, "fallback", 'c', 'd'); + let record = strict_record_with_assets(vec![first, fallback]); + + let observed = + call_with_attestation(record, fallback.sha256, fallback.library_sha256.unwrap()).await; + assert_eq!(observed.result, Ok("attested".to_string())); + assert_eq!(observed.serialized_arguments, 1); + assert_eq!(observed.receiver_calls, 1); +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn attested_calls_reject_archive_and_library_digests_crossed_between_assets() { + let candidates = crate::module::platform::host_candidates(); + let first_key = Box::leak(candidates[0].clone().into_boxed_str()); + let fallback_key = Box::leak(candidates.last().unwrap().clone().into_boxed_str()); + let first = attested_asset(first_key, "preferred", 'a', 'b'); + let fallback = attested_asset(fallback_key, "fallback", 'c', 'd'); + let record = strict_record_with_assets(vec![first, fallback]); + + let observed = + call_with_attestation(record, first.sha256, fallback.library_sha256.unwrap()).await; + assert_eq!( + observed.result.unwrap_err().kind, + ModuleCallError::Unavailable + ); + assert_eq!(observed.serialized_arguments, 0); + assert_eq!(observed.receiver_calls, 0); +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn attested_calls_reject_a_post_sign_library_digest_not_in_the_release_pin() { + let host_key = Box::leak( + crate::module::platform::host_candidates()[0] + .clone() + .into_boxed_str(), + ); + let release_asset = attested_asset(host_key, "notarized", 'a', 'b'); + let record = strict_record_with_assets(vec![release_asset]); + let post_sign_library = "c".repeat(64); + + let observed = call_with_attestation(record, release_asset.sha256, &post_sign_library).await; + assert_eq!( + observed.result.unwrap_err().kind, + ModuleCallError::Unavailable + ); + assert_eq!(observed.serialized_arguments, 0); + assert_eq!(observed.receiver_calls, 0); +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn attested_calls_refuse_post_sign_bytes_before_sending_confidential_arguments() { + let host_key = Box::leak( + crate::module::platform::host_candidates()[0] + .clone() + .into_boxed_str(), + ); + let release_asset = attested_asset(host_key, "signed", 'a', 'b'); + let record = strict_record_with_assets(vec![release_asset]); + let post_sign_library = "c".repeat(64); + + let observed = call_with_attestation(record, release_asset.sha256, &post_sign_library).await; + assert_eq!( + observed.result.unwrap_err().kind, + ModuleCallError::Unavailable + ); + assert_eq!(observed.serialized_arguments, 0); + assert_eq!(observed.receiver_calls, 0); +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn attested_calls_reject_a_valid_pair_for_an_unrelated_platform() { + let compatible_key = Box::leak( + crate::module::platform::host_candidates()[0] + .clone() + .into_boxed_str(), + ); + let compatible = attested_asset(compatible_key, "compatible", 'a', 'b'); + let unrelated = attested_asset("unrelated-platform-key", "unrelated", 'c', 'd'); + let record = strict_record_with_assets(vec![compatible, unrelated]); + + let observed = + call_with_attestation(record, unrelated.sha256, unrelated.library_sha256.unwrap()).await; + assert_eq!( + observed.result.unwrap_err().kind, + ModuleCallError::Unavailable + ); + assert_eq!(observed.serialized_arguments, 0); + assert_eq!(observed.receiver_calls, 0); +} + +#[cfg(feature = "modules")] +struct HandoverArgs { + started: Mutex>>, + barrier: Arc, +} + +#[cfg(feature = "modules")] +impl serde::Serialize for HandoverArgs { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + if let Some(started) = self.started.lock().unwrap().take() { + let _ = started.send(()); + } + self.barrier.wait(); + serializer.serialize_unit() + } +} + +#[cfg(feature = "modules")] +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn a_confidential_call_stays_on_the_attested_unique_peer_after_name_handover() { + let bus = crate::transport::memory::MemoryBus::new(); + let broker = crate::broker::Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let service_a = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let service_b = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let record = strict_record(); + let calls_a = Arc::new(Mutex::new(Vec::new())); + let calls_b = Arc::new(Mutex::new(Vec::new())); + for (service, label, calls) in [ + (&service_a, "attested", calls_a.clone()), + (&service_b, "successor", calls_b.clone()), + ] { + service + .serve_at( + crate::ObjectPath::new(record.object_path).unwrap(), + AttestedReceiver { label, calls }, + ) + .await + .unwrap(); + } + service_a.request_name(record.bus_name).await.unwrap(); + broker.attest_module( + &service_a.unique_name().unwrap(), + crate::attest::Attestation { + name: crate::BusName::new(record.bus_name).unwrap(), + sha256: record.assets[0].sha256.to_string(), + library_sha256: record.assets[0].library_sha256.map(str::to_string), + }, + ); + let sink = Arc::new(Recording::default()); + let preparations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let access = PreparedAccess { + record, + connection: caller, + reporter: FailureReporter::new(sink), + preparations: preparations.clone(), + }; + let client = ModuleClient::new(access); + let barrier = Arc::new(std::sync::Barrier::new(2)); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let args = HandoverArgs { + started: Mutex::new(Some(started_tx)), + barrier: barrier.clone(), + }; + let call = tokio::spawn(async move { + client + .call_attested_detailed::(record.id, "Consume", args) + .await + }); + started_rx.await.unwrap(); + service_a.release_name(record.bus_name).await.unwrap(); + service_b.request_name(record.bus_name).await.unwrap(); + barrier.wait(); + + assert_eq!(call.await.unwrap().unwrap(), "attested".to_string()); + assert_eq!(preparations.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(calls_a.lock().unwrap().len(), 1); + assert!(calls_b.lock().unwrap().is_empty()); + broker_task.abort(); +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn a_bound_attested_client_and_its_objects_never_rebind_after_alias_handover() { + let bus = crate::transport::memory::MemoryBus::new(); + let broker = crate::broker::Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let service_a = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let service_b = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let record = strict_record(); + let calls_a = Arc::new(Mutex::new(Vec::new())); + let calls_b = Arc::new(Mutex::new(Vec::new())); + for (service, label, calls) in [ + (&service_a, "attested", calls_a.clone()), + (&service_b, "successor", calls_b.clone()), + ] { + service + .serve_at( + crate::ObjectPath::new(record.object_path).unwrap(), + AttestedReceiver { label, calls }, + ) + .await + .unwrap(); + } + service_a.request_name(record.bus_name).await.unwrap(); + broker.attest_module( + &service_a.unique_name().unwrap(), + crate::attest::Attestation { + name: crate::BusName::new(record.bus_name).unwrap(), + sha256: record.assets[0].sha256.to_string(), + library_sha256: record.assets[0].library_sha256.map(str::to_string), + }, + ); + let sink = Arc::new(Recording::default()); + let client = ModuleClient::new(PreparedAccess { + record, + connection: caller, + reporter: FailureReporter::new(sink), + preparations: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }); + let bound = client.bind_attested(record.id).await.unwrap(); + let held_object = bound.object_client(record.object_path).unwrap(); + + service_a.release_name(record.bus_name).await.unwrap(); + service_b.request_name(record.bus_name).await.unwrap(); + assert_eq!( + bound.call::("Consume", ()).await.unwrap(), + "attested" + ); + assert_eq!( + held_object.call::("Consume", ()).await.unwrap(), + "attested" + ); + assert_eq!( + bound.call_detailed::("Consume", ()).await.unwrap(), + "attested" + ); + assert_eq!( + bound + .call_confidential::("Consume", ()) + .await + .unwrap(), + "attested" + ); + assert_eq!( + bound + .call_confidential_detailed::("Consume", ()) + .await + .unwrap(), + "attested" + ); + assert_eq!( + held_object + .call_detailed::("Consume", ()) + .await + .unwrap(), + "attested" + ); + assert_eq!( + held_object + .call_confidential::("Consume", ()) + .await + .unwrap(), + "attested" + ); + assert!(matches!( + bound.object_client("invalid object path"), + Err(ModuleCallError::IncompatibleContract) + )); + assert_eq!(calls_a.lock().unwrap().len(), 7); + assert!(calls_b.lock().unwrap().is_empty()); + + drop(service_a); + assert!(bound.call::("Consume", ()).await.is_err()); + assert!(calls_b.lock().unwrap().is_empty()); + broker_task.abort(); +} + +#[cfg(feature = "modules")] +struct LifecyclePeer { + label: &'static str, + events: Arc>>, + reserve_started: Mutex>>, + reserve_release: Option>, +} + +#[cfg(feature = "modules")] +struct LateStartPeer { + events: Arc>>, + start_accepted: Mutex>>, + start_release: Arc, + start_completed: Mutex>>, +} + +#[cfg(feature = "modules")] +#[async_trait::async_trait] +impl crate::Interface for LateStartPeer { + fn name(&self) -> crate::InterfaceName { + crate::InterfaceName::new("ai.tinyhumans.test.AttestedClient").unwrap() + } + + fn members(&self) -> Vec { + ["Reserve", "Start", "Stop"] + .into_iter() + .map(|member| crate::MemberName::new(member).unwrap()) + .collect() + } + + async fn call( + &self, + member: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + self.events + .lock() + .unwrap() + .push(member.as_str().to_string()); + if member.as_str() == "Start" { + let started = self.start_accepted.lock().unwrap().take(); + if let Some(started) = started { + let _ = started.send(()); + } + let permit = self + .start_release + .acquire() + .await + .map_err(|_| crate::Error::failed("test START gate closed"))?; + permit.forget(); + let completed = self.start_completed.lock().unwrap().take(); + if let Some(completed) = completed { + let _ = completed.send(()); + } + } + Ok(serde_json::json!("peer-a")) + } +} + +#[cfg(feature = "modules")] +struct CountedArguments(Arc); + +#[cfg(feature = "modules")] +impl serde::Serialize for CountedArguments { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + serializer.serialize_unit() + } +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn a_ready_unattested_peer_is_refused_before_arguments_or_business_delivery() { + let bus = crate::transport::memory::MemoryBus::new(); + let broker = crate::broker::Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let service = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let record = strict_record(); + let calls = Arc::new(Mutex::new(Vec::new())); + service + .serve_at( + crate::ObjectPath::new(record.object_path).unwrap(), + AttestedReceiver { + label: "ready", + calls: calls.clone(), + }, + ) + .await + .unwrap(); + service.request_name(record.bus_name).await.unwrap(); + let preparations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let client = ModuleClient::new(PreparedAccess { + record, + connection: caller, + reporter: FailureReporter::new(Arc::new(Recording::default())), + preparations: preparations.clone(), + }); + let serializations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let refusal = client + .call_attested_detailed::( + record.id, + "Consume", + CountedArguments(serializations.clone()), + ) + .await; + assert_eq!(refusal.unwrap_err().kind, ModuleCallError::Unavailable); + assert_eq!(serializations.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(preparations.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!(calls.lock().unwrap().is_empty()); + + assert_eq!( + client + .call::(record.id, "Consume", ()) + .await + .unwrap(), + "ready" + ); + assert_eq!(calls.lock().unwrap().len(), 1, "the peer remains ready"); + broker_task.abort(); +} + +#[cfg(feature = "modules")] +#[async_trait::async_trait] +impl crate::Interface for LifecyclePeer { + fn name(&self) -> crate::InterfaceName { + crate::InterfaceName::new("ai.tinyhumans.test.AttestedClient").unwrap() + } + + fn members(&self) -> Vec { + ["Reserve", "Start", "Stop"] + .into_iter() + .map(|member| crate::MemberName::new(member).unwrap()) + .collect() + } + + async fn call( + &self, + member: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + self.events + .lock() + .unwrap() + .push(member.as_str().to_string()); + if member.as_str() == "Reserve" { + let started = self.reserve_started.lock().unwrap().take(); + if let Some(started) = started { + let _ = started.send(()); + } + if let Some(release) = &self.reserve_release { + let permit = release + .acquire() + .await + .map_err(|_| crate::Error::failed("test reservation gate closed"))?; + permit.forget(); + } + } + Ok(serde_json::json!(self.label)) + } +} + +#[cfg(feature = "modules")] +struct BoundLifecycleDriver { + client: crate::module::BoundModuleClient, + panic_after_accept: Mutex>>, +} + +#[cfg(feature = "modules")] +struct BoundLifecycleError; + +#[cfg(feature = "modules")] +impl std::fmt::Debug for BoundLifecycleError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("BoundLifecycleError") + } +} + +#[cfg(feature = "modules")] +impl std::fmt::Display for BoundLifecycleError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("lifecycle call failed") + } +} + +#[cfg(feature = "modules")] +impl std::error::Error for BoundLifecycleError {} + +#[cfg(feature = "modules")] +#[async_trait::async_trait] +impl crate::module::RuntimeLeaseDriver for BoundLifecycleDriver { + type Reservation = String; + type Object = (); + type Error = BoundLifecycleError; + + async fn reserve(&self) -> Result { + self.client + .call("Reserve", ()) + .await + .map_err(|_| BoundLifecycleError) + } + + fn validate(&self, reservation: &Self::Reservation) -> Result<(), Self::Error> { + if reservation == "peer-a" { + Ok(()) + } else { + Err(BoundLifecycleError) + } + } + + fn callback_address( + &self, + _: &Self::Reservation, + _: &Self::Object, + ) -> Result<(crate::BusName, crate::ObjectPath), Self::Error> { + Ok(( + "ai.tinyhumans.test.BoundLeaseCallback".parse().unwrap(), + "/ai/tinyhumans/test/BoundLeaseCallback".parse().unwrap(), + )) + } + + async fn object(&self, _: &Self::Reservation) -> Result { + Ok(()) + } + + async fn start(&self, _: &Self::Reservation) -> crate::module::StartOutcome { + let panic_after_accept = self.panic_after_accept.lock().unwrap().take(); + if let Some(panic_after_accept) = panic_after_accept { + let call = self.client.call::("Start", ()); + tokio::pin!(call); + tokio::select! { + biased; + _ = panic_after_accept => panic!("private START panic after remote acceptance"), + result = &mut call => map_bound_start(result), + } + } else { + map_bound_start(self.client.call::("Start", ()).await) + } + } + + async fn stop(&self, _: &Self::Reservation) -> crate::module::StopOutcome { + match self.client.call::("Stop", ()).await { + Ok(peer) if peer == "peer-a" => crate::module::StopOutcome::Acknowledged, + Ok(_) | Err(_) => crate::module::StopOutcome::Unacknowledged(BoundLifecycleError), + } + } + + fn failed(&self, _: &Self::Reservation) -> bool { + false + } +} + +#[cfg(feature = "modules")] +fn map_bound_start( + result: Result, +) -> crate::module::StartOutcome { + match result { + Ok(peer) if peer == "peer-a" => crate::module::StartOutcome::Started, + Ok(_) => crate::module::StartOutcome::SettledFailure(BoundLifecycleError), + Err(_) => crate::module::StartOutcome::Unacknowledged(BoundLifecycleError), + } +} + +#[cfg(feature = "modules")] +#[derive(Clone)] +struct BoundLeaseCallback; + +#[cfg(feature = "modules")] +#[async_trait::async_trait] +impl crate::Interface for BoundLeaseCallback { + fn name(&self) -> crate::InterfaceName { + crate::InterfaceName::new("ai.tinyhumans.test.BoundLeaseCallback").unwrap() + } + + fn members(&self) -> Vec { + vec![crate::MemberName::new("Ping").unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Ok(serde_json::Value::Null) + } +} + +#[cfg(feature = "modules")] +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn a_bound_lease_driver_keeps_all_lifecycle_calls_on_the_attested_peer() { + let bus = crate::transport::memory::MemoryBus::new(); + let broker = crate::broker::Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let service_a = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let service_b = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let record = strict_record(); + let calls_a = Arc::new(Mutex::new(Vec::new())); + let calls_b = Arc::new(Mutex::new(Vec::new())); + let (reserve_started, reserve_seen) = tokio::sync::oneshot::channel(); + let reserve_release = Arc::new(tokio::sync::Semaphore::new(0)); + service_a + .serve_at( + crate::ObjectPath::new(record.object_path).unwrap(), + LifecyclePeer { + label: "peer-a", + events: calls_a.clone(), + reserve_started: Mutex::new(Some(reserve_started)), + reserve_release: Some(reserve_release.clone()), + }, + ) + .await + .unwrap(); + service_b + .serve_at( + crate::ObjectPath::new(record.object_path).unwrap(), + LifecyclePeer { + label: "peer-b", + events: calls_b.clone(), + reserve_started: Mutex::new(None), + reserve_release: None, + }, + ) + .await + .unwrap(); + service_a.request_name(record.bus_name).await.unwrap(); + broker.attest_module( + &service_a.unique_name().unwrap(), + crate::attest::Attestation { + name: crate::BusName::new(record.bus_name).unwrap(), + sha256: record.assets[0].sha256.to_string(), + library_sha256: record.assets[0].library_sha256.map(str::to_string), + }, + ); + let client = ModuleClient::new(PreparedAccess { + record, + connection: caller, + reporter: FailureReporter::new(Arc::new(Recording::default())), + preparations: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }); + let bound = client.bind_attested(record.id).await.unwrap(); + let runtime = + crate::module::ModuleRuntime::start(&crate::module::RuntimeNames::default(), None).unwrap(); + let opening = runtime.lease( + BoundLifecycleDriver { + client: bound.clone(), + panic_after_accept: Mutex::new(None), + }, + |_, _| Ok(BoundLeaseCallback), + crate::module::RetryPolicy::new(std::time::Duration::from_millis(100), vec![]).unwrap(), + |_| {}, + ); + let opening = tokio::spawn(opening); + reserve_seen.await.unwrap(); + service_a.release_name(record.bus_name).await.unwrap(); + service_b.request_name(record.bus_name).await.unwrap(); + reserve_release.add_permits(1); + let lease = opening.await.unwrap().unwrap(); + let closing = tokio::spawn(async move { lease.close().await }); + assert!(closing.await.unwrap().is_ok()); + assert_eq!(*calls_a.lock().unwrap(), vec!["Reserve", "Start", "Stop"]); + assert!(calls_b.lock().unwrap().is_empty()); + + drop(service_a); + assert!(bound.call::("Reserve", ()).await.is_err()); + assert!(calls_b.lock().unwrap().is_empty()); + broker_task.abort(); +} + +#[cfg(feature = "modules")] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_timed_out_bound_start_does_not_stop_before_late_provider_completion() { + let bus = crate::transport::memory::MemoryBus::new(); + let broker = crate::broker::Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let service = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let record = strict_record(); + let events = Arc::new(Mutex::new(Vec::new())); + let (start_accepted, start_accepted_rx) = tokio::sync::oneshot::channel(); + let (start_completed, start_completed_rx) = tokio::sync::oneshot::channel(); + let start_release = Arc::new(tokio::sync::Semaphore::new(0)); + service + .serve_at( + crate::ObjectPath::new(record.object_path).unwrap(), + LateStartPeer { + events: events.clone(), + start_accepted: Mutex::new(Some(start_accepted)), + start_release: start_release.clone(), + start_completed: Mutex::new(Some(start_completed)), + }, + ) + .await + .unwrap(); + service.request_name(record.bus_name).await.unwrap(); + // This unit fixture proves actual broker routing and timeout behavior. It + // seeds the private broker attestation directly; it does not test native + // artifact admission. + broker.attest_module( + &service.unique_name().unwrap(), + crate::attest::Attestation { + name: crate::BusName::new(record.bus_name).unwrap(), + sha256: record.assets[0].sha256.to_string(), + library_sha256: record.assets[0].library_sha256.map(str::to_string), + }, + ); + let client = ModuleClient::new(PreparedAccess { + record, + connection: caller, + reporter: FailureReporter::new(Arc::new(Recording::default())), + preparations: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }) + .with_timeout(Duration::from_millis(40)); + let bound = client.bind_attested(record.id).await.unwrap(); + let runtime = + crate::module::ModuleRuntime::start(&crate::module::RuntimeNames::default(), None).unwrap(); + let callback = runtime + .proxy( + "ai.tinyhumans.test.BoundLeaseCallback", + "/ai/tinyhumans/test/BoundLeaseCallback", + ) + .unwrap(); + let opening = tokio::spawn(runtime.lease( + BoundLifecycleDriver { + client: bound, + panic_after_accept: Mutex::new(None), + }, + |_, _| Ok(BoundLeaseCallback), + crate::module::RetryPolicy::new(Duration::from_millis(100), vec![]).unwrap(), + |_| {}, + )); + start_accepted_rx.await.unwrap(); + let opened = tokio::time::timeout(Duration::from_secs(2), opening) + .await + .expect("the bound START's inner bus deadline returns to activation") + .unwrap(); + let Err(error) = opened else { + panic!("an inner START deadline cannot return a live lease"); + }; + assert_eq!(error.phase(), crate::module::LeasePhase::Start); + let callback_before_late_completion: Result = + callback.call("Ping", ()).await; + let calls_before_late_completion = events.lock().unwrap().clone(); + + start_release.add_permits(1); + start_completed_rx.await.unwrap(); + let observed_calls = events.lock().unwrap().clone(); + let callback_after_late_completion: Result = + callback.call("Ping", ()).await; + assert!( + !error.acknowledged() + && calls_before_late_completion == vec!["Reserve", "Start"] + && observed_calls == vec!["Reserve", "Start"] + && callback_before_late_completion.is_ok() + && callback_after_late_completion.is_ok(), + "uncertain START must not be stopped or withdraw its callback before or after late completion; acknowledged={}, calls-before={calls_before_late_completion:?}, calls-after={observed_calls:?}, callback-before={}, callback-after={}", + error.acknowledged(), + callback_before_late_completion.is_ok(), + callback_after_late_completion.is_ok(), + ); + broker_task.abort(); +} + +#[cfg(feature = "modules")] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_start_panic_after_remote_acceptance_parks_without_stop() { + let bus = crate::transport::memory::MemoryBus::new(); + let broker = crate::broker::Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let service = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let record = strict_record(); + let events = Arc::new(Mutex::new(Vec::new())); + let (start_accepted, start_accepted_rx) = tokio::sync::oneshot::channel(); + let (start_completed, start_completed_rx) = tokio::sync::oneshot::channel(); + let (panic_after_accept, panic_after_accept_rx) = tokio::sync::oneshot::channel(); + let start_release = Arc::new(tokio::sync::Semaphore::new(0)); + service + .serve_at( + crate::ObjectPath::new(record.object_path).unwrap(), + LateStartPeer { + events: events.clone(), + start_accepted: Mutex::new(Some(start_accepted)), + start_release: start_release.clone(), + start_completed: Mutex::new(Some(start_completed)), + }, + ) + .await + .unwrap(); + service.request_name(record.bus_name).await.unwrap(); + // This fixture proves broker behavior through the private host attestation + // helper, not the separate native artifact-admission path. + broker.attest_module( + &service.unique_name().unwrap(), + crate::attest::Attestation { + name: crate::BusName::new(record.bus_name).unwrap(), + sha256: record.assets[0].sha256.to_string(), + library_sha256: record.assets[0].library_sha256.map(str::to_string), + }, + ); + let client = ModuleClient::new(PreparedAccess { + record, + connection: caller, + reporter: FailureReporter::new(Arc::new(Recording::default())), + preparations: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }); + let bound = client.bind_attested(record.id).await.unwrap(); + let runtime = + crate::module::ModuleRuntime::start(&crate::module::RuntimeNames::default(), None).unwrap(); + let callback = runtime + .proxy( + "ai.tinyhumans.test.BoundLeaseCallback", + "/ai/tinyhumans/test/BoundLeaseCallback", + ) + .unwrap(); + let opening = tokio::spawn(runtime.lease( + BoundLifecycleDriver { + client: bound, + panic_after_accept: Mutex::new(Some(panic_after_accept_rx)), + }, + |_, _| Ok(BoundLeaseCallback), + crate::module::RetryPolicy::new(Duration::from_millis(100), vec![]).unwrap(), + |_| {}, + )); + start_accepted_rx.await.unwrap(); + panic_after_accept.send(()).unwrap(); + let opened = tokio::time::timeout(Duration::from_secs(2), opening) + .await + .expect("the START child panic is reported to activation") + .unwrap(); + let Err(error) = opened else { + panic!("a panicking START cannot produce a lease"); + }; + assert_eq!(error.phase(), crate::module::LeasePhase::Start); + + start_release.add_permits(1); + start_completed_rx.await.unwrap(); + let observed_calls = events.lock().unwrap().clone(); + let callback_live: Result = callback.call("Ping", ()).await; + assert!(!error.acknowledged()); + assert_eq!(observed_calls, vec!["Reserve", "Start"]); + assert!(callback_live.is_ok()); + broker_task.abort(); +} + +#[path = "client_attested_timeout_tests.rs"] +mod timeout_tests; diff --git a/crates/tinybus/src/module/client_attested_timeout_tests.rs b/crates/tinybus/src/module/client_attested_timeout_tests.rs new file mode 100644 index 00000000..a3d6dec2 --- /dev/null +++ b/crates/tinybus/src/module/client_attested_timeout_tests.rs @@ -0,0 +1,443 @@ +use super::*; +use crate::message::{Message, MessageKind}; +use crate::ports::{Listener, Transport}; +use crate::transport::memory::MemoryTransport; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Duration; +use tokio::sync::{Mutex as AsyncMutex, Notify, mpsc}; + +#[derive(Clone)] +struct HeldMetadataBus { + accepted: mpsc::Sender>, + pending: Arc>>>, +} + +impl HeldMetadataBus { + fn new() -> Self { + let (accepted, pending) = mpsc::channel(8); + Self { + accepted, + pending: Arc::new(AsyncMutex::new(pending)), + } + } + + async fn connect( + &self, + held_member: Option<&'static str>, + ) -> (crate::Connection, Arc, Arc>>) { + let (client, broker) = MemoryTransport::pair(); + let held = Arc::new(Notify::new()); + let observed = Arc::new(StdMutex::new(Vec::new())); + self.accepted + .send(Box::new(HoldMetadataRequest { + inner: broker, + held_member, + held: held.clone(), + observed: observed.clone(), + })) + .await + .unwrap(); + ( + crate::Connection::connect(Box::new(client)).await.unwrap(), + held, + observed, + ) + } +} + +#[async_trait::async_trait] +impl Listener for HeldMetadataBus { + async fn accept(&self) -> crate::Result>> { + Ok(self.pending.lock().await.recv().await) + } + + fn describe(&self) -> String { + "test-held-metadata".to_string() + } +} + +struct HoldMetadataRequest { + inner: MemoryTransport, + held_member: Option<&'static str>, + held: Arc, + observed: Arc>>, +} + +#[async_trait::async_trait] +impl Transport for HoldMetadataRequest { + async fn send(&self, message: Message) -> crate::Result<()> { + self.inner.send_brokered(message).await + } + + async fn recv(&self) -> crate::Result> { + loop { + let Some((message, _)) = self.inner.recv_with_provenance().await? else { + return Ok(None); + }; + if let Some(member) = message.header.member.as_ref() { + self.observed + .lock() + .unwrap() + .push(member.as_str().to_string()); + } + let is_held_query = self.held_member.is_some_and(|member| { + message.header.kind == MessageKind::MethodCall + && message + .header + .member + .as_ref() + .is_some_and(|actual| actual.as_str() == member) + }); + if is_held_query { + self.held.notify_one(); + continue; + } + return Ok(Some(message)); + } + } + + async fn close(&self) -> crate::Result<()> { + self.inner.close().await + } + + fn describe(&self) -> String { + self.inner.describe() + } +} + +async fn client_waiting_on_metadata( + member: &'static str, +) -> ( + ModuleClient, + crate::Connection, + &'static ReleaseRecord, + Arc, + Arc>>, + Arc, + Arc>>, + tokio::task::JoinHandle>, +) { + let bus = HeldMetadataBus::new(); + let broker = crate::broker::Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let (service, _, _) = bus.connect(None).await; + let (caller, held, observed) = bus.connect(Some(member)).await; + let record = strict_record(); + let calls = Arc::new(Mutex::new(Vec::new())); + service + .serve_at( + crate::ObjectPath::new(record.object_path).unwrap(), + AttestedReceiver { + label: "attested", + calls: calls.clone(), + }, + ) + .await + .unwrap(); + service.request_name(record.bus_name).await.unwrap(); + if member == "GetAttestation" { + assert_eq!( + caller.name_owner(record.bus_name).await.unwrap(), + service.unique_name() + ); + } + + let sink = Arc::new(Recording::default()); + let access = PreparedAccess { + record, + connection: caller, + reporter: FailureReporter::new(sink.clone()), + preparations: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }; + let client = ModuleClient::new(access).with_timeout(Duration::from_millis(5)); + ( + client, + service, + record, + held, + calls, + sink, + observed, + broker_task, + ) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum StalledPreparationStage { + Readiness, + Proxy, +} + +struct StalledPreparationAccess { + record: &'static ReleaseRecord, + connection: crate::Connection, + reporter: FailureReporter, + stage: StalledPreparationStage, + stall: Arc, + entered: Arc, + readiness_calls: Arc, + proxy_calls: Arc, +} + +impl ModuleAccess for StalledPreparationAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == self.record.id).then_some(self.record) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _: &'a str) -> AccessFuture<'a, Result<(), String>> { + self.readiness_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if self.stage == StalledPreparationStage::Readiness + && self.stall.load(std::sync::atomic::Ordering::SeqCst) + { + self.entered.notify_one(); + Box::pin(std::future::pending()) + } else { + Box::pin(async { Ok(()) }) + } + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + self.proxy_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if self.stage == StalledPreparationStage::Proxy + && self.stall.load(std::sync::atomic::Ordering::SeqCst) + { + self.entered.notify_one(); + return Box::pin(std::future::pending()); + } + Box::pin(async move { + self.connection + .proxy(bus_name, object_path, bus_name) + .map_err(|_| ProxyFailure::Contract) + }) + } +} + +async fn stalled_binding_preparation_is_bounded(stage: StalledPreparationStage) { + let bus = HeldMetadataBus::new(); + let broker = crate::broker::Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let (service, _, _) = bus.connect(None).await; + let (caller, _, observed) = bus.connect(None).await; + let record = strict_record(); + let calls = Arc::new(Mutex::new(Vec::new())); + service + .serve_at( + crate::ObjectPath::new(record.object_path).unwrap(), + AttestedReceiver { + label: "ready-after-timeout", + calls: calls.clone(), + }, + ) + .await + .unwrap(); + service.request_name(record.bus_name).await.unwrap(); + + let sink = Arc::new(Recording::default()); + let stall = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let entered = Arc::new(Notify::new()); + let readiness_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let proxy_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let client = ModuleClient::new(StalledPreparationAccess { + record, + connection: caller, + reporter: FailureReporter::new(sink.clone()), + stage, + stall: stall.clone(), + entered: entered.clone(), + readiness_calls: readiness_calls.clone(), + proxy_calls: proxy_calls.clone(), + }) + .with_timeout(Duration::from_millis(5)); + + let entered_wait = entered.notified(); + let client_for_bind = client.clone(); + let mut binding = tokio::spawn(async move { client_for_bind.bind(record.id).await }); + tokio::time::timeout(Duration::from_millis(100), entered_wait) + .await + .expect("the configured preparation stage should begin"); + tokio::time::advance(Duration::from_millis(5)).await; + let result = match tokio::time::timeout(Duration::from_millis(100), &mut binding).await { + Ok(joined) => joined.unwrap(), + Err(_) => { + binding.abort(); + panic!("explicit client timeout must bound module preparation"); + } + }; + + assert!(matches!( + result, + Err(failure) if failure.kind == ModuleCallError::TransportFailed + )); + assert_eq!(readiness_calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!( + proxy_calls.load(std::sync::atomic::Ordering::SeqCst), + usize::from(stage == StalledPreparationStage::Proxy) + ); + assert!( + observed + .lock() + .unwrap() + .iter() + .all(|member| member != "GetNameOwner"), + "a timed-out preparation must not query the owner" + ); + assert!(calls.lock().unwrap().is_empty()); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(record.id.to_string(), Reason::TransportFailed)] + ); + + // Preparation is an invocation deadline, not a terminal loader failure. + stall.store(false, std::sync::atomic::Ordering::SeqCst); + assert!(client.bind(record.id).await.is_ok()); + let observed = observed.lock().unwrap(); + assert_eq!( + observed + .iter() + .filter(|member| member.as_str() == "GetNameOwner") + .count(), + 1 + ); + assert!(calls.lock().unwrap().is_empty()); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(record.id.to_string(), Reason::TransportFailed)] + ); + broker_task.abort(); +} + +#[tokio::test(start_paused = true)] +async fn ordinary_binding_timeout_bounds_stalled_readiness_without_poisoning_it() { + stalled_binding_preparation_is_bounded(StalledPreparationStage::Readiness).await; +} + +#[tokio::test(start_paused = true)] +async fn ordinary_binding_timeout_bounds_stalled_proxy_preparation_without_poisoning_it() { + stalled_binding_preparation_is_bounded(StalledPreparationStage::Proxy).await; +} + +async fn stalled_metadata_call_is_bounded(member: &'static str) { + let (client, _service, record, held, calls, sink, observed, broker_task) = + client_waiting_on_metadata(member).await; + let serializations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let serializations_for_call = serializations.clone(); + let held_request = held.notified(); + let mut call = tokio::spawn(async move { + client + .call_attested::( + record.id, + "Consume", + SerializeCounter(serializations_for_call), + ) + .await + }); + let held_result = tokio::time::timeout(Duration::from_millis(100), async { + tokio::select! { + _ = held_request => true, + joined = &mut call => panic!("client returned before the selected metadata query was held: {:?}; observed={:?}", joined, observed.lock().unwrap()), + } + }) + .await + .expect("the selected metadata request should reach the holding transport"); + assert!(held_result); + tokio::time::advance(Duration::from_millis(5)).await; + let result = match tokio::time::timeout(Duration::from_millis(100), &mut call).await { + Ok(joined) => joined.unwrap(), + Err(_) => { + call.abort(); + panic!("the configured ModuleClient timeout must bound metadata requests"); + } + }; + + assert_eq!(result, Err(ModuleCallError::TransportFailed)); + assert_eq!(serializations.load(std::sync::atomic::Ordering::SeqCst), 0); + assert!(calls.lock().unwrap().is_empty()); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(record.id.to_string(), Reason::TransportFailed)] + ); + broker_task.abort(); +} + +#[tokio::test(start_paused = true)] +async fn a_stalled_name_owner_lookup_uses_the_client_timeout() { + stalled_metadata_call_is_bounded("GetNameOwner").await; +} + +#[tokio::test(start_paused = true)] +async fn ordinary_binding_uses_the_fixture_proxy_deadline_for_owner_lookup() { + let bus = HeldMetadataBus::new(); + let broker = crate::broker::Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let (service, _, _) = bus.connect(None).await; + let (caller, held, observed) = bus.connect(Some("GetNameOwner")).await; + let record = strict_record(); + service + .serve_at( + crate::ObjectPath::new(record.object_path).unwrap(), + AttestedReceiver { + label: "linked", + calls: Arc::new(Mutex::new(Vec::new())), + }, + ) + .await + .unwrap(); + service.request_name(record.bus_name).await.unwrap(); + + let sink = Arc::new(Recording::default()); + let proxy = caller + .proxy(record.bus_name, record.object_path, record.bus_name) + .unwrap() + .with_timeout(Duration::from_millis(5)); + let client = ModuleClient::new(PreparedAccess { + record, + connection: caller, + reporter: FailureReporter::new(sink.clone()), + preparations: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }) + .with_fixture(proxy); + let mut binding = tokio::spawn(async move { client.bind(record.id).await }); + let held_request = held.notified(); + tokio::time::timeout(Duration::from_millis(100), async { + tokio::select! { + _ = held_request => {}, + _joined = &mut binding => panic!("bind returned before owner lookup was held; observed={:?}", observed.lock().unwrap()), + } + }) + .await + .expect("owner lookup should reach the holding transport"); + + tokio::time::advance(Duration::from_millis(5)).await; + let result = match tokio::time::timeout(Duration::from_millis(100), &mut binding).await { + Ok(joined) => joined.unwrap(), + Err(_) => { + binding.abort(); + panic!("bind must use the fixture proxy deadline for owner lookup"); + } + }; + assert!(matches!(result, Err(failure) if failure.kind == ModuleCallError::TransportFailed)); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(record.id.to_string(), Reason::TransportFailed)] + ); + broker_task.abort(); +} + +#[tokio::test(start_paused = true)] +async fn a_stalled_attestation_lookup_uses_the_client_timeout() { + stalled_metadata_call_is_bounded("GetAttestation").await; +} diff --git a/crates/tinybus/src/module/client_pinned_tests.rs b/crates/tinybus/src/module/client_pinned_tests.rs new file mode 100644 index 00000000..b250c3b1 --- /dev/null +++ b/crates/tinybus/src/module/client_pinned_tests.rs @@ -0,0 +1,1122 @@ +//! Ordinary module bindings retain their resolved peer without native proof. + +use super::*; +use crate::broker::Broker; +use crate::module::failure::{FailureReport, FailureSink}; +use crate::transport::memory::MemoryBus; +use crate::{Connection, Interface, InterfaceName, MemberName, ObjectPath, StreamRef}; +use async_trait::async_trait; +use serde_json::Value; +use std::sync::{Arc, Mutex}; + +static RECORD: ReleaseRecord = ReleaseRecord { + id: "ordinary-pinned-client-test", + bus_name: "ai.tinyhumans.test.OrdinaryPinnedClient", + object_path: "/ai/tinyhumans/test/OrdinaryPinnedClient", + version: "0.3.3", + release_url: "https://example.invalid/tinybus", + // Ordinary fixtures and linked host factories intentionally have no + // release archive/library pair to attest. + assets: &[], +}; + +const REMOTE_DETAIL: &str = "private ordinary-call detail: fixture-token-7319"; + +struct DiscardReports; + +impl FailureSink for DiscardReports { + fn capture(&self, _: &FailureReport<'_>) {} +} + +fn reporter() -> FailureReporter { + FailureReporter::new(Arc::new(DiscardReports)) +} + +#[derive(Default)] +struct RecordedReports(Mutex>); + +impl FailureSink for RecordedReports { + fn capture(&self, report: &FailureReport<'_>) { + self.0.lock().unwrap().push(report.reason); + } +} + +struct Access { + connection: Connection, + reporter: FailureReporter, + enabled: bool, +} + +impl ModuleAccess for Access { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == RECORD.id).then_some(&RECORD) + } + + fn enabled(&self) -> bool { + self.enabled + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _: &'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)] +struct Peer { + label: &'static str, + connection: Connection, + received: tokio::sync::mpsc::UnboundedSender>, + events: Arc>>, +} + +#[async_trait] +impl Interface for Peer { + fn name(&self) -> InterfaceName { + InterfaceName::new(RECORD.bus_name).unwrap() + } + + fn members(&self) -> Vec { + ["Who", "Upload", "Read", "Release", "Fail", "Hang"] + .map(|member| MemberName::new(member).unwrap()) + .to_vec() + } + + async fn call(&self, member: &MemberName, args: Value) -> crate::Result { + self.events.lock().unwrap().push(member.as_str().to_owned()); + match member.as_str() { + "Who" | "Read" | "Release" => Ok(serde_json::json!(self.label)), + "Upload" => { + let (stream,): (StreamRef,) = serde_json::from_value(args)?; + let bytes = self.connection.read_stream(&stream).await?; + self.received.send(bytes.clone()).unwrap(); + Ok(serde_json::json!(self.label)) + } + "Fail" => Err(crate::Error::MethodFailed { + name: "ai.tinyhumans.docs.Error.Rejected".to_owned(), + message: REMOTE_DETAIL.to_owned(), + }), + "Hang" => std::future::pending().await, + _ => Err(crate::Error::failed("unexpected test method")), + } + } +} + +#[derive(Clone)] +struct HandoverArguments { + stream: StreamRef, + started: Arc>>>, + barrier: Arc, +} + +impl serde::Serialize for HandoverArguments { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + if let Some(started) = self.started.lock().unwrap().take() { + let _ = started.send(()); + } + // Delay method argument construction after the stream has opened, then + // reassign the public alias before the upload method is sent. + self.barrier.wait(); + serde::Serialize::serialize(&self.stream, serializer) + } +} + +async fn peer( + bus: &MemoryBus, + label: &'static str, + received: tokio::sync::mpsc::UnboundedSender>, + events: Arc>>, +) -> Connection { + let connection = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + for path in [RECORD.object_path, "/child"] { + connection + .serve_at( + ObjectPath::new(path).unwrap(), + Peer { + label, + connection: connection.clone(), + received: received.clone(), + events: events.clone(), + }, + ) + .await + .unwrap(); + } + connection +} + +#[tokio::test] +async fn an_ordinary_fixture_with_no_release_assets_can_bind() { + let bus = MemoryBus::new(); + let _broker = Broker::new().spawn(bus.clone()); + let (received, _received_rx) = tokio::sync::mpsc::unbounded_channel(); + let service = peer(&bus, "fixture", received, Arc::new(Mutex::new(Vec::new()))).await; + service.request_name(RECORD.bus_name).await.unwrap(); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let proxy = caller + .proxy(RECORD.bus_name, RECORD.object_path, RECORD.bus_name) + .unwrap(); + let client = ModuleClient::new(Access { + connection: caller, + reporter: reporter(), + enabled: true, + }) + .with_fixture(proxy); + + let pinned = client.bind(RECORD.id).await.unwrap(); + let response: String = pinned.call("Who", ()).await.unwrap(); + assert_eq!(response, "fixture"); +} + +#[cfg(feature = "modules")] +struct LinkedOrdinaryContext { + sender: tokio::sync::mpsc::UnboundedSender>, +} + +#[cfg(feature = "modules")] +unsafe extern "C" fn linked_ordinary_deliver( + context: *mut std::ffi::c_void, + bytes: *const u8, + len: usize, +) -> i32 { + let context = unsafe { &*(context.cast::()) }; + let bytes = unsafe { std::slice::from_raw_parts(bytes, len) }.to_vec(); + context + .sender + .send(bytes) + .map_or(-1, |_| crate::module::abi::TB_OK) +} + +#[cfg(feature = "modules")] +unsafe extern "C" fn linked_ordinary_shutdown(context: *mut std::ffi::c_void, _: u64) -> i32 { + drop(unsafe { Box::from_raw(context.cast::()) }); + crate::module::abi::TB_OK +} + +#[cfg(feature = "modules")] +unsafe extern "C" fn linked_ordinary_reinitialize( + _: *mut std::ffi::c_void, + _: *const u8, + _: usize, +) -> i32 { + crate::module::abi::TB_OK +} + +#[cfg(feature = "modules")] +unsafe extern "C" fn linked_ordinary_init( + host: *const crate::module::abi::TbHostVtable, + out: *mut crate::module::abi::TbModuleVtable, +) -> i32 { + let host = unsafe { *host }; + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::>(); + let context = Box::into_raw(Box::new(LinkedOrdinaryContext { sender })); + unsafe { + *out = crate::module::abi::TbModuleVtable { + size: std::mem::size_of::() as u32, + _reserved: 0, + module_ctx: context.cast(), + deliver: linked_ordinary_deliver, + shutdown: linked_ordinary_shutdown, + reinitialize: Some(linked_ordinary_reinitialize), + }; + } + let host_context = host.host_ctx as usize; + let send = host.send; + std::thread::spawn(move || { + while let Some(bytes) = receiver.blocking_recv() { + let Ok(message) = serde_json::from_slice::(&bytes) else { + continue; + }; + if message.header.kind != crate::message::MessageKind::MethodCall { + continue; + } + let value = message + .body + .as_array() + .and_then(|values| values.first()) + .cloned() + .unwrap_or(serde_json::Value::Null); + let reply = crate::Message::method_return(&message.header, value); + let bytes = serde_json::to_vec(&reply).unwrap(); + let _ = unsafe { + send( + host_context as *mut std::ffi::c_void, + bytes.as_ptr(), + bytes.len(), + ) + }; + } + }); + unsafe { (host.ready)(host.host_ctx) }; + crate::module::abi::TB_OK +} + +#[cfg(feature = "modules")] +fn linked_ordinary_factory() -> crate::Result { + use crate::module::manifest::{MANIFEST_SCHEMA, ModuleIdentity, PanicPolicy}; + use crate::{BusName, ObjectPath, Version}; + + Ok(crate::module::LinkedModule { + descriptor: crate::module::abi::TbAbiDescriptor::current(RECORD.id, RECORD.version), + manifest: crate::module::manifest::ModuleManifest { + schema: MANIFEST_SCHEMA, + module: ModuleIdentity { + name: RECORD.id.to_owned(), + version: Version::new(0, 3, 3), + description: String::new(), + homepage: None, + license: String::new(), + }, + bus_name: BusName::new(RECORD.bus_name).unwrap(), + object_path: ObjectPath::new(RECORD.object_path).unwrap(), + provides: Vec::new(), + requires: Vec::new(), + environment: Vec::new(), + capabilities: Vec::new(), + lazy_init: false, + worker_threads: 1, + on_panic: PanicPolicy::Detach, + }, + init: linked_ordinary_init, + init_with_setup: None, + }) +} + +#[cfg(feature = "modules")] +static RESOLVER_LINKED_STARTS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +#[cfg(feature = "modules")] +static RESOLVER_LINKED_DELIVERIES: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +#[cfg(feature = "modules")] +static RESOLVER_LINKED_FACTORY_CALLS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +#[cfg(feature = "modules")] +static RESOLVER_LINKED_GATE: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); + +#[cfg(feature = "modules")] +static RESOLVER_LINKED_STARTED: std::sync::OnceLock< + Mutex>>, +> = std::sync::OnceLock::new(); + +#[cfg(feature = "modules")] +static RESOLVER_LINKED_RECORD: ReleaseRecord = ReleaseRecord { + id: "ordinary-binding-linked-readiness", + bus_name: "ai.tinyhumans.test.OrdinaryBindingLinkedReadiness", + object_path: "/ai/tinyhumans/test/OrdinaryBindingLinkedReadiness", + version: "0.3.3", + release_url: "https://example.invalid/tinybus", + assets: &[], +}; + +#[cfg(feature = "modules")] +unsafe extern "C" fn resolver_linked_deliver( + _: *mut std::ffi::c_void, + _: *const u8, + _: usize, +) -> i32 { + RESOLVER_LINKED_DELIVERIES.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + crate::module::abi::TB_OK +} + +#[cfg(feature = "modules")] +unsafe extern "C" fn resolver_linked_shutdown(context: *mut std::ffi::c_void, _: u64) -> i32 { + drop(unsafe { Box::from_raw(context.cast::()) }); + crate::module::abi::TB_OK +} + +#[cfg(feature = "modules")] +unsafe extern "C" fn resolver_linked_reinitialize( + _: *mut std::ffi::c_void, + _: *const u8, + _: usize, +) -> i32 { + crate::module::abi::TB_OK +} + +#[cfg(feature = "modules")] +unsafe extern "C" fn resolver_linked_init( + host: *const crate::module::abi::TbHostVtable, + out: *mut crate::module::abi::TbModuleVtable, +) -> i32 { + RESOLVER_LINKED_STARTS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let host = unsafe { *host }; + let context = Box::into_raw(Box::new(0_u8)); + unsafe { + *out = crate::module::abi::TbModuleVtable { + size: std::mem::size_of::() as u32, + _reserved: 0, + module_ctx: context.cast(), + deliver: resolver_linked_deliver, + shutdown: resolver_linked_shutdown, + reinitialize: Some(resolver_linked_reinitialize), + }; + } + if let Some(started) = RESOLVER_LINKED_STARTED + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap() + .take() + { + let _ = started.send(()); + } + if let Some(gate) = RESOLVER_LINKED_GATE + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap() + .take() + { + let _ = gate.recv(); + } + unsafe { (host.ready)(host.host_ctx) }; + crate::module::abi::TB_OK +} + +#[cfg(feature = "modules")] +fn resolver_linked_factory() -> crate::Result { + use crate::module::manifest::{MANIFEST_SCHEMA, ModuleIdentity, PanicPolicy}; + use crate::{BusName, ObjectPath, Version}; + + RESOLVER_LINKED_FACTORY_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let record = &RESOLVER_LINKED_RECORD; + Ok(crate::module::LinkedModule { + descriptor: crate::module::abi::TbAbiDescriptor::current(record.id, record.version), + manifest: crate::module::manifest::ModuleManifest { + schema: MANIFEST_SCHEMA, + module: ModuleIdentity { + name: record.id.to_owned(), + version: Version::new(0, 3, 3), + description: String::new(), + homepage: None, + license: String::new(), + }, + bus_name: BusName::new(record.bus_name).unwrap(), + object_path: ObjectPath::new(record.object_path).unwrap(), + provides: Vec::new(), + requires: Vec::new(), + environment: Vec::new(), + capabilities: Vec::new(), + lazy_init: false, + worker_threads: 1, + on_panic: PanicPolicy::Detach, + }, + init: resolver_linked_init, + init_with_setup: None, + }) +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn ordinary_binding_timeout_keeps_resolver_owned_initialization_for_retry() { + use crate::module::runtime::{ModuleRuntime, RuntimeNames}; + use crate::module::{Availability, LinkedRegistry, ModuleSource, Resolver, ResolverInputs}; + use std::collections::HashMap; + use std::sync::OnceLock; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + // Warm the executable digest through a real linked admission on a distinct + // record. The resolver case below is about preserving cold ModuleRuntime + // startup across caller teardown; it must not also budget cold executable + // hashing, which can be slow on hosted coverage machines. + let digest_warm_runtime = + ModuleRuntime::start(&RuntimeNames::default(), None).expect("digest warmup runtime starts"); + digest_warm_runtime + .host() + .attach_linked_with_config( + linked_ordinary_factory().expect("digest warmup linked factory succeeds"), + serde_json::Value::Null, + ) + .expect("a real linked admission warms the executable digest"); + + RESOLVER_LINKED_STARTS.store(0, Ordering::SeqCst); + RESOLVER_LINKED_DELIVERIES.store(0, Ordering::SeqCst); + RESOLVER_LINKED_FACTORY_CALLS.store(0, Ordering::SeqCst); + let (release, gate) = std::sync::mpsc::channel(); + *RESOLVER_LINKED_GATE + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap() = Some(gate); + let (started, started_rx) = tokio::sync::oneshot::channel(); + *RESOLVER_LINKED_STARTED + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap() = Some(started); + + static RUNTIME: OnceLock = OnceLock::new(); + let runtime_provider_started = Arc::new(tokio::sync::Notify::new()); + let runtime_provider_gate = Arc::new(tokio::sync::Notify::new()); + let first_runtime_request = Arc::new(AtomicBool::new(true)); + let runtime_provider_calls = Arc::new(AtomicUsize::new(0)); + let started_runtime_provider = runtime_provider_started.clone(); + let release_runtime_provider = runtime_provider_gate.clone(); + let first_runtime_request_for_provider = first_runtime_request.clone(); + let runtime_provider_calls_for_provider = runtime_provider_calls.clone(); + let linked = Arc::new(LinkedRegistry::new()); + linked.register(RESOLVER_LINKED_RECORD.id, resolver_linked_factory); + let reports = Arc::new(RecordedReports::default()); + let resolver = Resolver::new(ResolverInputs { + enabled: true, + install_dir: None, + allow_download: false, + bundled_dir: None, + sources: HashMap::from([(RESOLVER_LINKED_RECORD.id.to_owned(), ModuleSource::Linked)]), + linked, + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![&RESOLVER_LINKED_RECORD], + terminal_marker: String::new(), + runtime: Arc::new(move || { + runtime_provider_calls_for_provider.fetch_add(1, Ordering::SeqCst); + let first = first_runtime_request_for_provider.swap(false, Ordering::SeqCst); + let started = started_runtime_provider.clone(); + let gate = release_runtime_provider.clone(); + Box::pin(async move { + if first { + started.notify_one(); + gate.notified().await; + } + Ok(RUNTIME.get_or_init(|| { + ModuleRuntime::start(&RuntimeNames::default(), None) + .expect("test runtime starts after provider gate opens") + })) + }) + }), + module_config: Arc::new(|_| serde_json::Value::Null), + async_module_config: None, + reporter: Arc::new(FailureReporter::new(reports.clone())), + }); + + let proxy_calls = Arc::new(AtomicUsize::new(0)); + struct CountedResolverAccess { + resolver: Resolver, + proxy_calls: Arc, + } + impl ModuleAccess for CountedResolverAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + self.resolver.find(id) + } + + fn enabled(&self) -> bool { + self.resolver.enabled() + } + + fn reporter(&self) -> &FailureReporter { + self.resolver.reporter() + } + + fn ensure_loaded<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(self.resolver.ensure_loaded(id)) + } + + fn ensure_loaded_within<'a>( + &'a self, + id: &'a str, + within: Option, + ) -> AccessFuture<'a, Result<(), crate::module::ReadinessFailure>> { + Box::pin(ModuleAccess::ensure_loaded_within( + &self.resolver, + id, + within, + )) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + let calls = Arc::clone(&self.proxy_calls); + Box::pin(async move { + calls.fetch_add(1, Ordering::SeqCst); + ModuleAccess::proxy(&self.resolver, bus_name, object_path).await + }) + } + } + + let client = ModuleClient::new(CountedResolverAccess { + resolver: resolver.clone(), + proxy_calls: proxy_calls.clone(), + }) + .with_timeout(std::time::Duration::from_millis(50)); + let runtime_provider_started_for_runtime = runtime_provider_started.clone(); + let client_for_abandoned_runtime = client.clone(); + let (runtime_dropped_tx, runtime_dropped_rx) = tokio::sync::oneshot::channel(); + let abandoned_runtime = std::thread::spawn(move || { + let caller_runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("temporary caller runtime starts"); + let bind = caller_runtime.spawn(async move { + client_for_abandoned_runtime + .bind(RESOLVER_LINKED_RECORD.id) + .await + }); + caller_runtime.block_on(async { + tokio::time::timeout( + std::time::Duration::from_secs(2), + runtime_provider_started_for_runtime.notified(), + ) + .await + .expect("the first runtime-provider request should enter its gate"); + }); + drop(caller_runtime); + drop(bind); + let _ = runtime_dropped_tx.send(()); + }); + tokio::time::timeout(std::time::Duration::from_secs(2), runtime_dropped_rx) + .await + .expect("the caller runtime should shut down while its provider is gated") + .unwrap(); + abandoned_runtime.join().unwrap(); + assert_eq!(RESOLVER_LINKED_STARTS.load(Ordering::SeqCst), 0); + assert_eq!(runtime_provider_calls.load(Ordering::SeqCst), 1); + assert_eq!(RESOLVER_LINKED_DELIVERIES.load(Ordering::SeqCst), 0); + assert_eq!(proxy_calls.load(Ordering::SeqCst), 0); + assert!(reports.0.lock().unwrap().is_empty()); + + let linked_initializer_started = started_rx; + runtime_provider_gate.notify_one(); + let initializer_started = tokio::time::timeout( + std::time::Duration::from_secs(5), + linked_initializer_started, + ) + .await; + if !matches!(initializer_started, Ok(Ok(()))) { + let runtime_worker_alive = if let Some(runtime) = RUNTIME.get() { + let (probe_tx, probe_rx) = tokio::sync::oneshot::channel(); + runtime.spawn(async move { + let _ = probe_tx.send(()); + }); + matches!( + tokio::time::timeout(std::time::Duration::from_secs(1), probe_rx).await, + Ok(Ok(())) + ) + } else { + false + }; + panic!( + "the process-owned resolver should start linked initialization; availability={:?}, runtime_provider_calls={}, linked_factory_calls={}, runtime_worker_alive={}", + resolver.state_of(RESOLVER_LINKED_RECORD.id), + runtime_provider_calls.load(Ordering::SeqCst), + RESOLVER_LINKED_FACTORY_CALLS.load(Ordering::SeqCst), + runtime_worker_alive, + ) + } + assert_eq!(RESOLVER_LINKED_STARTS.load(Ordering::SeqCst), 1); + assert_eq!(RESOLVER_LINKED_FACTORY_CALLS.load(Ordering::SeqCst), 1); + assert_eq!(runtime_provider_calls.load(Ordering::SeqCst), 3); + assert_eq!(proxy_calls.load(Ordering::SeqCst), 0); + + let client_for_second_bind = client.clone(); + let mut second_bind = + tokio::spawn(async move { client_for_second_bind.bind(RESOLVER_LINKED_RECORD.id).await }); + let second_result = tokio::time::timeout(std::time::Duration::from_secs(1), &mut second_bind) + .await + .expect("the second caller should use a bounded wait on the existing resolution") + .unwrap(); + assert!(matches!( + second_result, + Err(failure) if failure.kind == ModuleCallError::TransportFailed + )); + assert_eq!(RESOLVER_LINKED_STARTS.load(Ordering::SeqCst), 1); + assert_eq!(runtime_provider_calls.load(Ordering::SeqCst), 3); + assert_eq!(proxy_calls.load(Ordering::SeqCst), 0); + assert_eq!( + reports.0.lock().unwrap().as_slice(), + &[Reason::TransportFailed] + ); + + release.send(()).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if resolver.state_of(RESOLVER_LINKED_RECORD.id) == Availability::Ready { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("the existing resolver attempt should finish after readiness opens"); + let pinned = client.bind(RESOLVER_LINKED_RECORD.id).await.unwrap(); + let (runtime_task_tx, runtime_task_rx) = tokio::sync::oneshot::channel(); + let runtime_task = RUNTIME + .get() + .expect("the gated provider should have created the module runtime") + .spawn(async move { + let _ = runtime_task_tx.send(7); + }); + runtime_task.await.unwrap(); + assert_eq!(runtime_task_rx.await.unwrap(), 7); + assert_eq!(RESOLVER_LINKED_STARTS.load(Ordering::SeqCst), 1); + // The bind above revisits the cached `Ready` slot, which re-checks the host + // for a module that faulted after load: one more runtime lookup. + assert_eq!(runtime_provider_calls.load(Ordering::SeqCst), 5); + assert_eq!(RESOLVER_LINKED_DELIVERIES.load(Ordering::SeqCst), 0); + assert_eq!(proxy_calls.load(Ordering::SeqCst), 1); + assert_eq!( + reports.0.lock().unwrap().as_slice(), + &[Reason::TransportFailed] + ); + drop(pinned); +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn ordinary_binding_accepts_a_resolver_owned_linked_factory_without_library_proof() { + use crate::module::runtime::{ModuleRuntime, RuntimeNames}; + use crate::module::{LinkedRegistry, ModuleSource, Resolver, ResolverInputs}; + use std::collections::HashMap; + use std::sync::OnceLock; + + static RUNTIME: OnceLock = OnceLock::new(); + let runtime = RUNTIME.get_or_init(|| { + ModuleRuntime::start(&RuntimeNames::default(), None).expect("test runtime starts") + }); + let linked = Arc::new(LinkedRegistry::new()); + linked.register(RECORD.id, linked_ordinary_factory); + let resolver = Resolver::new(ResolverInputs { + enabled: true, + install_dir: None, + allow_download: false, + bundled_dir: None, + sources: HashMap::from([(RECORD.id.to_owned(), ModuleSource::Linked)]), + linked, + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![&RECORD], + terminal_marker: String::new(), + runtime: Arc::new(move || Box::pin(async move { Ok(runtime) })), + module_config: Arc::new(|_| serde_json::Value::Null), + async_module_config: None, + reporter: Arc::new(FailureReporter::new(Arc::new(DiscardReports))), + }); + + assert!(RECORD.assets.is_empty()); + let diagnostic_resolver = resolver.clone(); + let client = ModuleClient::new(resolver); + let pinned = client.bind(RECORD.id).await.unwrap_or_else(|failure| { + panic!( + "ordinary linked factory binding failed ({failure:?}); resolver state: {:?}", + diagnostic_resolver.state_of(RECORD.id) + ) + }); + let response: String = pinned.call("Echo", "linked-host-factory").await.unwrap(); + assert_eq!(response, "linked-host-factory"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn ordinary_binding_keeps_root_object_and_stream_with_the_original_peer() { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let (received_a_tx, mut received_a_rx) = tokio::sync::mpsc::unbounded_channel(); + let (received_b_tx, mut received_b_rx) = tokio::sync::mpsc::unbounded_channel(); + let events_a = Arc::new(Mutex::new(Vec::new())); + let events_b = Arc::new(Mutex::new(Vec::new())); + let service_a = peer(&bus, "peer-a", received_a_tx, events_a.clone()).await; + let service_b = peer(&bus, "peer-b", received_b_tx, events_b.clone()).await; + service_a.request_name(RECORD.bus_name).await.unwrap(); + + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let proxy = caller + .proxy(RECORD.bus_name, RECORD.object_path, RECORD.bus_name) + .unwrap(); + let client = ModuleClient::new(Access { + connection: caller, + reporter: reporter(), + enabled: true, + }) + .with_fixture(proxy); + let pinned = client.bind(RECORD.id).await.unwrap(); + + let barrier = Arc::new(std::sync::Barrier::new(2)); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let started = Arc::new(Mutex::new(Some(started_tx))); + let call_client = pinned.clone(); + let payload = b"ordinary pinned payload".to_vec(); + let call_payload = payload.clone(); + let call_started = started.clone(); + let call_barrier = barrier.clone(); + let upload = tokio::spawn(async move { + call_client + .call_with_stream::( + "Upload", + move |stream| HandoverArguments { + stream: stream.clone(), + started: call_started, + barrier: call_barrier, + }, + &call_payload, + ) + .await + }); + + started_rx.await.unwrap(); + service_a.release_name(RECORD.bus_name).await.unwrap(); + service_b.request_name(RECORD.bus_name).await.unwrap(); + barrier.wait(); + assert_eq!(upload.await.unwrap().unwrap(), "peer-a"); + assert_eq!(received_a_rx.recv().await.unwrap(), payload); + assert!(received_b_rx.try_recv().is_err()); + + // This opens a new stream after B already owns the alias, proving the + // stream endpoint itself is selected from the bound unique destination. + let second_payload = b"opened after handover".to_vec(); + let response: String = pinned + .call_with_stream("Upload", |stream| (stream.clone(),), &second_payload) + .await + .unwrap(); + assert_eq!(response, "peer-a"); + assert_eq!(received_a_rx.recv().await.unwrap(), second_payload); + assert!(received_b_rx.try_recv().is_err()); + + let root: String = pinned.call("Who", ()).await.unwrap(); + assert_eq!(root, "peer-a"); + let object = pinned.object_client("/child").unwrap(); + let response: String = object.call("Read", ()).await.unwrap(); + assert_eq!(response, "peer-a"); + let response: String = object.call("Release", ()).await.unwrap(); + assert_eq!(response, "peer-a"); + assert_eq!( + events_a.lock().unwrap().as_slice(), + &["Upload", "Upload", "Who", "Read", "Release"] + ); + assert!(events_b.lock().unwrap().is_empty()); + + service_a.close().await.unwrap(); + let root_after_close: Result = pinned.call("Who", ()).await; + let object_after_close: Result = object.call("Read", ()).await; + let stream_after_close: Result = pinned + .call_with_stream("Upload", |stream| (stream.clone(),), b"after close") + .await; + assert!(root_after_close.is_err()); + assert!(object_after_close.is_err()); + assert!(stream_after_close.is_err()); + assert!(events_b.lock().unwrap().is_empty()); + assert!(received_b_rx.try_recv().is_err()); + broker_task.abort(); +} + +#[tokio::test] +async fn ordinary_binding_refuses_disabled_unknown_and_unowned_modules() { + let bus = MemoryBus::new(); + let _broker = Broker::new().spawn(bus.clone()); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + + let disabled_reports = Arc::new(RecordedReports::default()); + let disabled = ModuleClient::new(Access { + connection: caller.clone(), + reporter: FailureReporter::new(disabled_reports.clone()), + enabled: false, + }); + let disabled_result = disabled.bind(RECORD.id).await; + assert!( + matches!(disabled_result, Err(failure) if failure.kind == ModuleCallError::Unavailable) + ); + assert_eq!( + disabled_reports.0.lock().unwrap().as_slice(), + &[Reason::Disabled] + ); + + let unknown_reports = Arc::new(RecordedReports::default()); + let unknown = ModuleClient::new(Access { + connection: caller.clone(), + reporter: FailureReporter::new(unknown_reports.clone()), + enabled: true, + }); + let unknown_result = unknown.bind("not-registered").await; + assert!(matches!(unknown_result, Err(failure) if failure.kind == ModuleCallError::Unavailable)); + assert_eq!(unknown_reports.0.lock().unwrap().len(), 1); + + // The broker has no peer advertising the registered well-known name. + let unowned_reports = Arc::new(RecordedReports::default()); + let unowned_proxy = caller + .proxy(RECORD.bus_name, RECORD.object_path, RECORD.bus_name) + .unwrap(); + let unowned = ModuleClient::new(Access { + connection: caller, + reporter: FailureReporter::new(unowned_reports.clone()), + enabled: true, + }) + .with_fixture(unowned_proxy); + let unowned_result = unowned.bind(RECORD.id).await; + assert!(matches!(unowned_result, Err(failure) if failure.kind == ModuleCallError::Unavailable)); + assert_eq!( + unowned_reports.0.lock().unwrap().as_slice(), + &[Reason::ModuleUnavailable] + ); +} + +#[tokio::test] +async fn ordinary_bound_detailed_failures_keep_fields_and_sanitize_renderings() { + let bus = MemoryBus::new(); + let _broker = Broker::new().spawn(bus.clone()); + let (received, _received_rx) = tokio::sync::mpsc::unbounded_channel(); + let service = peer( + &bus, + "failure-peer", + received, + Arc::new(Mutex::new(Vec::new())), + ) + .await; + service.request_name(RECORD.bus_name).await.unwrap(); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let proxy = caller + .proxy(RECORD.bus_name, RECORD.object_path, RECORD.bus_name) + .unwrap(); + let pinned = ModuleClient::new(Access { + connection: caller, + reporter: reporter(), + enabled: true, + }) + .with_fixture(proxy) + .bind(RECORD.id) + .await + .unwrap(); + + let failure = pinned + .call_detailed::("Fail", ()) + .await + .unwrap_err(); + assert_eq!(failure.kind, ModuleCallError::ModuleFault); + assert_eq!(failure.wire_name, "ai.tinyhumans.docs.Error.Rejected"); + assert_eq!(failure.message, REMOTE_DETAIL); + assert!(!format!("{failure:?}").contains(REMOTE_DETAIL)); + assert!(!failure.to_string().contains(REMOTE_DETAIL)); +} + +#[tokio::test(start_paused = true)] +async fn ordinary_bound_calls_keep_the_configured_deadline_and_report_timeout() { + let bus = MemoryBus::new(); + let _broker = Broker::new().spawn(bus.clone()); + let (received, _received_rx) = tokio::sync::mpsc::unbounded_channel(); + let service = peer( + &bus, + "slow-peer", + received, + Arc::new(Mutex::new(Vec::new())), + ) + .await; + service.request_name(RECORD.bus_name).await.unwrap(); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let proxy = caller + .proxy(RECORD.bus_name, RECORD.object_path, RECORD.bus_name) + .unwrap(); + let reports = Arc::new(RecordedReports::default()); + let pinned = ModuleClient::new(Access { + connection: caller, + reporter: FailureReporter::new(reports.clone()), + enabled: true, + }) + .with_fixture(proxy.with_timeout(std::time::Duration::from_secs(2))) + .with_timeout(std::time::Duration::from_millis(20)) + .bind(RECORD.id) + .await + .unwrap(); + + let result = tokio::time::timeout( + std::time::Duration::from_millis(21), + pinned.call::("Hang", ()), + ) + .await; + assert!(matches!(result, Ok(Err(ModuleCallError::TransportFailed)))); + assert_eq!( + reports.0.lock().unwrap().as_slice(), + &[Reason::TransportFailed] + ); +} + +#[tokio::test] +async fn ordinary_binding_preserves_fixture_proxy_deadlines_for_root_and_objects() { + let bus = MemoryBus::new(); + let _broker = Broker::new().spawn(bus.clone()); + let (received, _received_rx) = tokio::sync::mpsc::unbounded_channel(); + let service = peer( + &bus, + "deadline-peer", + received, + Arc::new(Mutex::new(Vec::new())), + ) + .await; + service.request_name(RECORD.bus_name).await.unwrap(); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let proxy = caller + .proxy(RECORD.bus_name, RECORD.object_path, RECORD.bus_name) + .unwrap() + .with_timeout(std::time::Duration::from_millis(20)); + let pinned = ModuleClient::new(Access { + connection: caller, + reporter: reporter(), + enabled: true, + }) + .with_fixture(proxy) + .bind(RECORD.id) + .await + .unwrap(); + + let root_result = tokio::time::timeout( + std::time::Duration::from_millis(300), + pinned.call::("Hang", ()), + ) + .await + .expect("root calls inherit the fixture proxy deadline"); + assert!(matches!(root_result, Err(ModuleCallError::TransportFailed))); + + let object = pinned.object_client("/child").unwrap(); + let object_result = tokio::time::timeout( + std::time::Duration::from_millis(300), + object.call::("Hang", ()), + ) + .await + .expect("held-object calls inherit the fixture proxy deadline"); + assert!(matches!( + object_result, + Err(ModuleCallError::TransportFailed) + )); +} + +#[test] +fn unsupported_module_availability_keeps_the_unavailable_classification() { + let error = crate::Error::ModuleUnavailable { + module: RECORD.id.to_owned(), + state: "unsupported".to_owned(), + detail: "the host does not support this module source".to_owned(), + }; + + assert_eq!( + classify(&error), + (ModuleCallError::Unavailable, Reason::ModuleUnavailable) + ); +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn ordinary_binding_refuses_an_unsupported_resolver_without_proxy_or_business_calls() { + use crate::module::runtime::{ModuleRuntime, RuntimeNames}; + use crate::module::{Availability, LinkedRegistry, Resolver, ResolverInputs}; + use std::collections::HashMap; + use std::sync::OnceLock; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static RECORD: ReleaseRecord = ReleaseRecord { + id: "ordinary-binding-unsupported", + bus_name: "ai.tinyhumans.test.OrdinaryBindingUnsupported", + object_path: "/ai/tinyhumans/test/OrdinaryBindingUnsupported", + version: "0.3.3", + release_url: "https://example.invalid/tinybus", + assets: &[], + }; + static RUNTIME: OnceLock = OnceLock::new(); + let runtime = RUNTIME.get_or_init(|| { + ModuleRuntime::start(&RuntimeNames::default(), None).expect("test runtime starts") + }); + let reports = Arc::new(RecordedReports::default()); + let resolver = Resolver::new(ResolverInputs { + enabled: true, + install_dir: None, + allow_download: false, + bundled_dir: None, + sources: HashMap::new(), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![&RECORD], + terminal_marker: String::new(), + runtime: Arc::new(move || Box::pin(async move { Ok(runtime) })), + module_config: Arc::new(|_| serde_json::Value::Null), + async_module_config: None, + reporter: Arc::new(FailureReporter::new(reports.clone())), + }); + + assert_eq!(resolver.status_of(&RECORD).0, Availability::Unsupported); + let proxy_calls = Arc::new(AtomicUsize::new(0)); + struct CountedAccess { + resolver: Resolver, + proxy_calls: Arc, + } + impl ModuleAccess for CountedAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + self.resolver.find(id) + } + + fn enabled(&self) -> bool { + self.resolver.enabled() + } + + fn reporter(&self) -> &FailureReporter { + self.resolver.reporter() + } + + fn ensure_loaded<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(self.resolver.ensure_loaded(id)) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + let calls = Arc::clone(&self.proxy_calls); + Box::pin(async move { + calls.fetch_add(1, Ordering::SeqCst); + self.resolver.proxy(bus_name, object_path).await + }) + } + } + let client = ModuleClient::new(CountedAccess { + resolver: resolver.clone(), + proxy_calls: proxy_calls.clone(), + }); + + for _ in 0..2 { + let result = client.bind(RECORD.id).await; + assert!(matches!(result, Err(failure) if failure.kind == ModuleCallError::Unavailable)); + } + assert_eq!(proxy_calls.load(Ordering::SeqCst), 0); + assert_eq!( + reports.0.lock().unwrap().as_slice(), + &[Reason::ResolutionFailed] + ); +} diff --git a/crates/tinybus/src/module/client_stream_tests.rs b/crates/tinybus/src/module/client_stream_tests.rs new file mode 100644 index 00000000..7ec05946 --- /dev/null +++ b/crates/tinybus/src/module/client_stream_tests.rs @@ -0,0 +1,882 @@ +//! Streamed module calls preserve the same checks and reporting as typed calls. + +use super::*; +use crate::broker::Broker; +use crate::module::failure::{FailureReport, FailureSink}; +use crate::transport::memory::MemoryBus; +use crate::{Connection, Error, Interface, InterfaceName, MemberName, ObjectPath, StreamRef}; +use async_trait::async_trait; +use serde_json::Value; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +static RECORD: ReleaseRecord = ReleaseRecord { + id: "stream-client-test", + bus_name: "ai.tinyhumans.test.StreamClient", + object_path: "/ai/tinyhumans/test/StreamClient", + version: "0.2.1", + release_url: "https://example.invalid/tinybus", + assets: &[], +}; + +#[cfg(feature = "modules")] +fn bound_stream_record() -> &'static ReleaseRecord { + let host_key = Box::leak( + crate::module::platform::host_candidates() + .into_iter() + .next() + .expect("the test host has a published module key") + .into_boxed_str(), + ); + let assets = Box::leak( + vec![crate::module::ReleaseAsset { + host_key, + archive: Box::leak("bound-stream-test.tar.gz".to_string().into_boxed_str()), + sha256: Box::leak("a".repeat(64).into_boxed_str()), + library_sha256: Some(Box::leak("b".repeat(64).into_boxed_str())), + }] + .into_boxed_slice(), + ); + Box::leak(Box::new(ReleaseRecord { + id: "bound-stream-test", + bus_name: "ai.tinyhumans.test.BoundStreamClient", + object_path: "/ai/tinyhumans/test/BoundStreamClient", + version: "0.3.1", + release_url: "https://example.invalid/tinybus", + assets, + })) +} + +#[cfg(feature = "modules")] +struct HandoverStreamArguments { + stream: StreamRef, + started: Arc>>>, + barrier: Arc, +} + +#[cfg(feature = "modules")] +impl serde::Serialize for HandoverStreamArguments { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + if let Some(started) = self.started.lock().unwrap().take() { + let _ = started.send(()); + } + self.barrier.wait(); + serde::Serialize::serialize(&self.stream, serializer) + } +} + +#[derive(Default)] +struct Reports(Mutex>); + +impl FailureSink for Reports { + fn capture(&self, report: &FailureReport<'_>) { + self.0.lock().unwrap().push(report.reason); + } +} + +struct StreamAccess { + record: &'static ReleaseRecord, + connection: Connection, + reporter: FailureReporter, + enabled: bool, + ready: bool, +} + +impl ModuleAccess for StreamAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == self.record.id).then_some(self.record) + } + + fn enabled(&self) -> bool { + self.enabled + } + + 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.ready { + Ok(()) + } else { + Err("not ready".to_string()) + } + }) + } + + fn prepare_attested<'a>(&'a self, _: &'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) + }) + } +} + +struct ObservedTransport { + inner: Box, + opens: Arc, + aborts: tokio::sync::mpsc::UnboundedSender<()>, + refuse_first_write: Arc, +} + +#[async_trait] +impl crate::ports::Transport for ObservedTransport { + async fn send(&self, message: crate::Message) -> crate::Result<()> { + let interface = message.header.interface.as_ref().map(InterfaceName::as_str); + let member = message.header.member.as_ref().map(MemberName::as_str); + if interface == Some(crate::stream::STREAM_INTERFACE) { + match member { + Some("Open") => { + self.opens.fetch_add(1, Ordering::SeqCst); + } + Some("Abort") => { + self.aborts.send(()).unwrap(); + } + _ => {} + } + if member == Some("Write") + && self + .refuse_first_write + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + return Err(Error::protocol("private write refusal detail")); + } + } + self.inner.send(message).await + } + + async fn recv(&self) -> crate::Result> { + self.inner.recv().await + } + + async fn close(&self) -> crate::Result<()> { + self.inner.close().await + } +} + +#[derive(Clone)] +enum Behavior { + Consume(tokio::sync::mpsc::UnboundedSender>), + #[cfg(feature = "modules")] + Bound { + label: &'static str, + received: tokio::sync::mpsc::UnboundedSender>, + }, + Invalid, + Unavailable, + Stall { + entered: tokio::sync::mpsc::UnboundedSender<()>, + resume: Arc, + }, +} + +struct StreamService { + interface_name: &'static str, + connection: Connection, + behavior: Behavior, + calls: Arc, + events: Option>>>, +} + +#[async_trait] +impl Interface for StreamService { + fn name(&self) -> InterfaceName { + InterfaceName::new(self.interface_name).unwrap() + } + + fn members(&self) -> Vec { + [ + "Upload", + "Generate", + "Read", + "Release", + "InvalidInput", + "Unavailable", + "Stall", + ] + .map(|name| MemberName::new(name).unwrap()) + .to_vec() + } + + async fn call(&self, member: &MemberName, args: Value) -> crate::Result { + self.calls.fetch_add(1, Ordering::SeqCst); + if let Some(events) = &self.events { + events.lock().unwrap().push(member.as_str().to_string()); + } + match (&self.behavior, member.as_str()) { + (Behavior::Consume(received), "Upload") => { + let (stream,): (StreamRef,) = serde_json::from_value(args)?; + let bytes = self.connection.read_stream(&stream).await?; + received.send(bytes.clone()).unwrap(); + Ok(serde_json::json!(bytes.len())) + } + #[cfg(feature = "modules")] + (Behavior::Bound { received, .. }, "Generate") => { + let (stream,): (StreamRef,) = serde_json::from_value(args)?; + let bytes = self.connection.read_stream(&stream).await?; + received.send(bytes).unwrap(); + Ok(serde_json::json!("/child")) + } + #[cfg(feature = "modules")] + (Behavior::Bound { label, .. }, "Read" | "Release") => Ok(serde_json::json!(label)), + (Behavior::Invalid, "InvalidInput") => Err(Error::MethodFailed { + name: "ai.tinyhumans.tinydocs.Error.InvalidInput".to_string(), + message: "secret document detail".to_string(), + }), + (Behavior::Unavailable, "Unavailable") => Err(Error::MethodFailed { + name: Error::MODULE_UNAVAILABLE.to_string(), + message: "secret unavailable detail".to_string(), + }), + (Behavior::Stall { entered, resume }, "Stall") => { + entered.send(()).unwrap(); + resume.notified().await; + Ok(Value::Null) + } + _ => Err(Error::failed("unexpected test method")), + } + } +} + +struct Fixture { + client: ModuleClient, + connection: Connection, + opens: Arc, + calls: Arc, + refuse_first_write: Arc, + aborts: tokio::sync::mpsc::UnboundedReceiver<()>, + reports: Arc, +} + +async fn fixture(behavior: Behavior) -> Fixture { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + + let service = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + service.request_name(RECORD.bus_name).await.unwrap(); + let opens = Arc::new(AtomicUsize::new(0)); + let calls = Arc::new(AtomicUsize::new(0)); + let refuse_first_write = Arc::new(std::sync::atomic::AtomicBool::new(false)); + service + .serve_at( + ObjectPath::new(RECORD.object_path).unwrap(), + StreamService { + interface_name: RECORD.bus_name, + connection: service.clone(), + behavior: behavior.clone(), + calls: calls.clone(), + events: None, + }, + ) + .await + .unwrap(); + service + .serve_at( + ObjectPath::new("/child").unwrap(), + StreamService { + interface_name: RECORD.bus_name, + connection: service.clone(), + behavior, + calls: calls.clone(), + events: None, + }, + ) + .await + .unwrap(); + + let (aborts_tx, aborts) = tokio::sync::mpsc::unbounded_channel(); + let observed = ObservedTransport { + inner: bus.connect().await.unwrap(), + opens: opens.clone(), + aborts: aborts_tx, + refuse_first_write: refuse_first_write.clone(), + }; + let connection = Connection::connect(Box::new(observed)).await.unwrap(); + let reports = Arc::new(Reports::default()); + let access = StreamAccess { + record: &RECORD, + connection: connection.clone(), + reporter: FailureReporter::new(reports.clone()), + enabled: true, + ready: true, + }; + let proxy = connection + .proxy(RECORD.bus_name, RECORD.object_path, RECORD.bus_name) + .unwrap(); + Fixture { + client: ModuleClient::new(access).with_fixture(proxy), + connection, + opens, + calls, + refuse_first_write, + aborts, + reports, + } +} + +#[tokio::test] +async fn module_calls_stream_empty_and_past_window_payloads() { + let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel(); + let fixture = fixture(Behavior::Consume(received_tx)).await; + let bytes = vec![0x5a; crate::stream::MAX_CHUNK_LEN * 9 + 7]; + + let length: usize = fixture + .client + .call_with_stream_detailed( + RECORD.id, + "Upload", + |stream| serde_json::json!([stream]), + &bytes, + ) + .await + .unwrap(); + assert_eq!(length, bytes.len()); + assert_eq!(received_rx.recv().await.unwrap(), bytes); + + let empty_length: usize = fixture + .client + .call_with_stream_detailed( + RECORD.id, + "Upload", + |stream| serde_json::json!([stream]), + &[], + ) + .await + .unwrap(); + assert_eq!(empty_length, 0); + assert!(received_rx.recv().await.unwrap().is_empty()); + assert_eq!(fixture.opens.load(Ordering::SeqCst), 2); +} + +#[cfg(feature = "modules")] +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn a_bound_stream_call_and_returned_object_stay_on_the_attested_peer() { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let service_a = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let service_b = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let record = bound_stream_record(); + let events_a = Arc::new(Mutex::new(Vec::new())); + let events_b = Arc::new(Mutex::new(Vec::new())); + let (received_a_tx, mut received_a_rx) = tokio::sync::mpsc::unbounded_channel(); + let (received_b_tx, mut received_b_rx) = tokio::sync::mpsc::unbounded_channel(); + + for (service, label, events, received) in [ + (&service_a, "peer-a", events_a.clone(), received_a_tx), + (&service_b, "peer-b", events_b.clone(), received_b_tx), + ] { + service + .serve_at( + ObjectPath::new(record.object_path).unwrap(), + StreamService { + interface_name: record.bus_name, + connection: service.clone(), + behavior: Behavior::Bound { + label, + received: received.clone(), + }, + calls: Arc::new(AtomicUsize::new(0)), + events: Some(events.clone()), + }, + ) + .await + .unwrap(); + service + .serve_at( + ObjectPath::new("/child").unwrap(), + StreamService { + interface_name: record.bus_name, + connection: service.clone(), + behavior: Behavior::Bound { label, received }, + calls: Arc::new(AtomicUsize::new(0)), + events: Some(events), + }, + ) + .await + .unwrap(); + } + service_a.request_name(record.bus_name).await.unwrap(); + broker.attest_module( + &service_a.unique_name().unwrap(), + crate::attest::Attestation { + name: crate::name::BusName::new(record.bus_name).unwrap(), + sha256: record.assets[0].sha256.to_string(), + library_sha256: record.assets[0].library_sha256.map(str::to_string), + }, + ); + let client = ModuleClient::new(StreamAccess { + record, + connection: caller, + reporter: FailureReporter::new(Arc::new(Reports::default())), + enabled: true, + ready: true, + }); + let bound = client.bind_attested(record.id).await.unwrap(); + + let barrier = Arc::new(std::sync::Barrier::new(2)); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let started = Arc::new(Mutex::new(Some(started_tx))); + let call_client = bound.clone(); + let call_barrier = barrier.clone(); + let call_started = started.clone(); + let payload = b"bound stream payload".to_vec(); + let call_payload = payload.clone(); + let generating = tokio::spawn(async move { + call_client + .call_with_stream_detailed::( + "Generate", + move |stream| HandoverStreamArguments { + stream: stream.clone(), + started: call_started, + barrier: call_barrier, + }, + &call_payload, + ) + .await + }); + started_rx.await.unwrap(); + service_a.release_name(record.bus_name).await.unwrap(); + service_b.request_name(record.bus_name).await.unwrap(); + barrier.wait(); + + let child_path = generating.await.unwrap().unwrap(); + assert_eq!(child_path, "/child"); + assert_eq!(received_a_rx.recv().await.unwrap(), payload); + assert!(received_b_rx.try_recv().is_err()); + + let child = bound.object_client(&child_path).unwrap(); + assert_eq!(child.call::("Read", ()).await.unwrap(), "peer-a"); + assert_eq!(child.call::("Release", ()).await.unwrap(), "peer-a"); + + let sanitized_bytes = b"sanitized bound stream"; + assert_eq!( + bound + .call_with_stream::( + "Generate", + |stream| serde_json::json!([stream]), + sanitized_bytes, + ) + .await + .unwrap(), + "/child" + ); + assert_eq!(received_a_rx.recv().await.unwrap(), sanitized_bytes); + assert_eq!( + events_a.lock().unwrap().as_slice(), + &["Generate", "Read", "Release", "Generate"] + ); + assert!(events_b.lock().unwrap().is_empty()); + + service_a.close().await.unwrap(); + assert!( + bound + .call_with_stream::( + "Generate", + |stream| serde_json::json!([stream]), + b"after disconnect", + ) + .await + .is_err() + ); + assert!(events_b.lock().unwrap().is_empty()); + assert!(received_b_rx.try_recv().is_err()); + broker_task.abort(); +} + +#[tokio::test] +async fn invalid_or_unavailable_module_stream_calls_do_not_run_the_builder_or_open_a_stream() { + let (received_tx, _received_rx) = tokio::sync::mpsc::unbounded_channel(); + let fixture = fixture(Behavior::Consume(received_tx)).await; + let builder_calls = Arc::new(AtomicUsize::new(0)); + assert!( + fixture + .client + .call_with_stream_detailed::( + "unknown", + "Upload", + { + let builder_calls = builder_calls.clone(); + move |stream: &StreamRef| { + builder_calls.fetch_add(1, Ordering::SeqCst); + serde_json::json!([stream]) + } + }, + b"x", + ) + .await + .is_err() + ); + + let proxy = fixture.client.fixture_proxy().unwrap(); + let disabled = ModuleClient::new(StreamAccess { + record: &RECORD, + connection: fixture.connection.clone(), + reporter: FailureReporter::new(Arc::new(Reports::default())), + enabled: false, + ready: true, + }) + .with_fixture(proxy.clone()); + assert!( + disabled + .call_with_stream_detailed::( + RECORD.id, + "Upload", + { + let builder_calls = builder_calls.clone(); + move |stream: &StreamRef| { + builder_calls.fetch_add(1, Ordering::SeqCst); + serde_json::json!([stream]) + } + }, + b"x", + ) + .await + .is_err() + ); + + let unready = ModuleClient::new(StreamAccess { + record: &RECORD, + connection: fixture.connection.clone(), + reporter: FailureReporter::new(Arc::new(Reports::default())), + enabled: true, + ready: false, + }); + assert!( + unready + .call_with_stream_detailed::( + RECORD.id, + "Upload", + { + let builder_calls = builder_calls.clone(); + move |stream: &StreamRef| { + builder_calls.fetch_add(1, Ordering::SeqCst); + serde_json::json!([stream]) + } + }, + b"x", + ) + .await + .is_err() + ); + + assert!( + fixture + .client + .call_with_stream_detailed::( + RECORD.id, + "invalid/member", + { + let builder_calls = builder_calls.clone(); + move |stream: &StreamRef| { + builder_calls.fetch_add(1, Ordering::SeqCst); + serde_json::json!([stream]) + } + }, + b"x", + ) + .await + .is_err() + ); + assert_eq!(builder_calls.load(Ordering::SeqCst), 0); + assert_eq!(fixture.opens.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn detailed_stream_failures_preserve_wire_input_errors_and_sanitize_renderings() { + let invalid_fixture = fixture(Behavior::Invalid).await; + let failure = invalid_fixture + .client + .call_with_stream_detailed::( + RECORD.id, + "InvalidInput", + |stream| serde_json::json!([stream]), + b"untrusted input", + ) + .await + .unwrap_err(); + assert_eq!(failure.kind, ModuleCallError::ModuleFault); + assert_eq!( + failure.wire_name, + "ai.tinyhumans.tinydocs.Error.InvalidInput" + ); + assert_eq!(failure.message, "secret document detail"); + for rendered in [format!("{failure:?}"), failure.to_string()] { + assert!(!rendered.contains("InvalidInput")); + assert!(!rendered.contains("secret document detail")); + } + + let object = invalid_fixture + .client + .object_client(RECORD.id, "/child") + .await + .unwrap(); + let object_failure = object + .call_detailed::("InvalidInput", ()) + .await + .unwrap_err(); + assert_eq!(object_failure.wire_name, failure.wire_name); + assert_eq!(object_failure.message, failure.message); + let sanitized_stream = invalid_fixture + .client + .call_with_stream::( + RECORD.id, + "InvalidInput", + |stream| serde_json::json!([stream]), + b"untrusted input", + ) + .await + .unwrap_err(); + assert_eq!(sanitized_stream, ModuleCallError::ModuleFault); + assert!(!format!("{sanitized_stream:?}").contains("secret document detail")); + assert!( + !sanitized_stream + .to_string() + .contains("secret document detail") + ); + let unavailable_fixture = fixture(Behavior::Unavailable).await; + let unavailable_object = unavailable_fixture + .client + .object_client(RECORD.id, "/child") + .await + .unwrap(); + let unavailable = unavailable_object + .call_detailed::("Unavailable", ()) + .await + .unwrap_err(); + assert_eq!(unavailable.kind, ModuleCallError::Unavailable); + assert_eq!(unavailable.wire_name, Error::MODULE_UNAVAILABLE); + assert_eq!(unavailable.message, "secret unavailable detail"); + + let sanitized = object.call::("InvalidInput", ()).await.unwrap_err(); + assert_eq!(sanitized, ModuleCallError::ModuleFault); + assert!(!format!("{sanitized:?}").contains("secret document detail")); + assert!(!sanitized.to_string().contains("secret document detail")); + let reports = invalid_fixture.reports.0.lock().unwrap(); + assert_eq!( + reports.as_slice(), + &[ + Reason::ModuleFault, + Reason::ModuleFault, + Reason::ModuleFault, + Reason::ModuleFault, + ] + ); +} + +struct FailsSerialization; + +impl serde::Serialize for FailsSerialization { + fn serialize(&self, _serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + Err(serde::ser::Error::custom("secret serialization detail")) + } +} + +#[tokio::test] +async fn argument_serialization_failure_aborts_before_delivering_the_business_method() { + let (received_tx, _received_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut fixture = fixture(Behavior::Consume(received_tx)).await; + let builder_calls = Arc::new(AtomicUsize::new(0)); + let called = builder_calls.clone(); + let failure = fixture + .client + .call_with_stream_detailed::( + RECORD.id, + "Upload", + move |_stream| { + called.fetch_add(1, Ordering::SeqCst); + FailsSerialization + }, + b"payload", + ) + .await + .unwrap_err(); + + assert_eq!(failure.kind, ModuleCallError::IncompatibleContract); + assert!(!format!("{failure:?}").contains("secret serialization detail")); + assert!(!failure.to_string().contains("secret serialization detail")); + assert_eq!(builder_calls.load(Ordering::SeqCst), 1); + assert_eq!(fixture.opens.load(Ordering::SeqCst), 1); + assert_eq!(fixture.calls.load(Ordering::SeqCst), 0); + tokio::time::timeout(Duration::from_secs(10), fixture.aborts.recv()) + .await + .expect("failed argument serialization dropped the opened writer") + .expect("the abort observer remained connected"); +} + +#[tokio::test] +async fn module_client_timeout_bounds_a_stalled_method_reply() { + let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel(); + let resume = Arc::new(tokio::sync::Notify::new()); + let mut fixture = fixture(Behavior::Stall { + entered: entered_tx, + resume: resume.clone(), + }) + .await; + let client = fixture + .client + .clone() + .with_timeout(Duration::from_millis(100)); + let bytes = vec![0x22; crate::stream::MAX_CHUNK_LEN * 10]; + let call = tokio::spawn(async move { + client + .call_with_stream_detailed::( + RECORD.id, + "Stall", + |stream| serde_json::json!([stream]), + &bytes, + ) + .await + }); + + tokio::time::timeout(Duration::from_secs(10), entered_rx.recv()) + .await + .expect("stalled method was dispatched") + .expect("the method-entry observer remained connected"); + let failure = tokio::time::timeout(Duration::from_secs(2), call) + .await + .expect("ModuleClient timeout bounded the stalled reply") + .unwrap() + .unwrap_err(); + assert_eq!(failure.kind, ModuleCallError::TransportFailed); + tokio::time::timeout(Duration::from_secs(10), fixture.aborts.recv()) + .await + .expect("timeout dropped the unfinished stream writer") + .expect("the abort observer remained connected"); + resume.notify_one(); +} + +#[tokio::test] +async fn a_refused_stream_write_aborts_the_opened_upload() { + let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel(); + let resume = Arc::new(tokio::sync::Notify::new()); + let mut fixture = fixture(Behavior::Stall { + entered: entered_tx, + resume: resume.clone(), + }) + .await; + fixture + .refuse_first_write + .store(true, std::sync::atomic::Ordering::SeqCst); + let client = fixture.client.clone(); + let task = tokio::spawn(async move { + client + .call_with_stream_detailed::( + RECORD.id, + "Stall", + |stream| serde_json::json!([stream]), + b"write will be refused", + ) + .await + }); + + tokio::time::timeout(Duration::from_secs(10), entered_rx.recv()) + .await + .expect("stalled method was dispatched") + .expect("the method-entry observer remained connected"); + let failure = tokio::time::timeout(Duration::from_secs(2), task) + .await + .expect("the refused chunk failed without waiting for the stalled method") + .unwrap() + .unwrap_err(); + assert_eq!( + failure.kind, + ModuleCallError::TransportFailed, + "wire failure: {} ({})", + failure.wire_name, + failure.message + ); + assert!( + !fixture + .refuse_first_write + .load(std::sync::atomic::Ordering::SeqCst) + ); + tokio::time::timeout(Duration::from_secs(10), fixture.aborts.recv()) + .await + .expect("write failure dropped the unfinished stream writer") + .expect("the abort observer remained connected"); + resume.notify_one(); +} + +#[tokio::test] +async fn method_failure_and_cancellation_abort_a_streamed_upload() { + let failed_fixture = fixture(Behavior::Invalid).await; + let bytes = vec![0x33; crate::stream::MAX_CHUNK_LEN * 12]; + let failure = failed_fixture + .client + .call_with_stream_detailed::( + RECORD.id, + "InvalidInput", + |stream| serde_json::json!([stream]), + &bytes, + ) + .await + .unwrap_err(); + assert_eq!( + failure.wire_name, + "ai.tinyhumans.tinydocs.Error.InvalidInput" + ); + let mut failed_fixture = failed_fixture; + tokio::time::timeout(Duration::from_secs(10), failed_fixture.aborts.recv()) + .await + .expect("method failure dropped the unfinished stream writer") + .expect("the abort observer remained connected"); + + let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel(); + let resume = Arc::new(tokio::sync::Notify::new()); + let mut stalled = fixture(Behavior::Stall { + entered: entered_tx, + resume: resume.clone(), + }) + .await; + let bytes = vec![0x44; crate::stream::MAX_CHUNK_LEN * 12]; + let task = { + let client = stalled.client.clone(); + tokio::spawn(async move { + client + .call_with_stream_detailed::( + RECORD.id, + "Stall", + |stream| serde_json::json!([stream]), + &bytes, + ) + .await + }) + }; + tokio::time::timeout(Duration::from_secs(10), entered_rx.recv()) + .await + .expect("stalled method was dispatched") + .expect("the method-entry observer remained connected"); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + tokio::time::timeout(Duration::from_secs(10), stalled.aborts.recv()) + .await + .expect("cancellation dropped the unfinished stream writer") + .expect("the abort observer remained connected"); + resume.notify_one(); +} diff --git a/crates/tinybus/src/module/client_tests.rs b/crates/tinybus/src/module/client_tests.rs index cdb38196..e8fb167d 100644 --- a/crates/tinybus/src/module/client_tests.rs +++ b/crates/tinybus/src/module/client_tests.rs @@ -1,6 +1,10 @@ use super::*; +use crate::message::{Message, MessageKind}; use crate::module::failure::{FailureReport, FailureReporter, FailureSink}; +use crate::ports::{Listener, Transport}; +use crate::transport::memory::{MemoryBus, MemoryTransport}; use std::sync::{Arc, Mutex}; +use tokio::sync::{Mutex as AsyncMutex, Notify, mpsc}; #[derive(Default)] struct Recording(Mutex>); @@ -60,6 +64,136 @@ impl ModuleAccess for Loaderless { } } +struct ReadyAccess { + connection: crate::Connection, + reporter: FailureReporter, + proxy_failure: Option, + proxy_timeout: Option, +} + +impl ModuleAccess for ReadyAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == RECORD.id).then_some(&RECORD) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _: &'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 { + if let Some(failure) = self.proxy_failure { + return Err(failure); + } + let proxy = self + .connection + .proxy(bus_name, object_path, bus_name) + .map_err(|_| ProxyFailure::Contract)?; + Ok(self + .proxy_timeout + .map_or(proxy.clone(), |timeout| proxy.with_timeout(timeout))) + }) + } +} + +#[derive(Clone)] +struct HeldMetadataBus { + accepted: mpsc::Sender>, + pending: Arc>>>, +} + +impl HeldMetadataBus { + fn new() -> Self { + let (accepted, pending) = mpsc::channel(8); + Self { + accepted, + pending: Arc::new(AsyncMutex::new(pending)), + } + } + + async fn connect(&self, held_member: Option<&'static str>) -> (crate::Connection, Arc) { + let (client, broker) = MemoryTransport::pair(); + let held = Arc::new(Notify::new()); + self.accepted + .send(Box::new(HoldMetadataRequest { + inner: broker, + held_member, + held: held.clone(), + })) + .await + .unwrap(); + ( + crate::Connection::connect(Box::new(client)).await.unwrap(), + held, + ) + } +} + +#[async_trait::async_trait] +impl Listener for HeldMetadataBus { + async fn accept(&self) -> crate::Result>> { + Ok(self.pending.lock().await.recv().await) + } + + fn describe(&self) -> String { + "test-held-metadata".to_string() + } +} + +struct HoldMetadataRequest { + inner: MemoryTransport, + held_member: Option<&'static str>, + held: Arc, +} + +#[async_trait::async_trait] +impl Transport for HoldMetadataRequest { + async fn send(&self, message: Message) -> crate::Result<()> { + self.inner.send_brokered(message).await + } + + async fn recv(&self) -> crate::Result> { + loop { + let Some((message, _)) = self.inner.recv_with_provenance().await? else { + return Ok(None); + }; + let is_held_query = self.held_member.is_some_and(|member| { + message.header.kind == MessageKind::MethodCall + && message + .header + .member + .as_ref() + .is_some_and(|actual| actual.as_str() == member) + }); + if is_held_query { + self.held.notify_one(); + continue; + } + return Ok(Some(message)); + } + } + + async fn close(&self) -> crate::Result<()> { + self.inner.close().await + } + + fn describe(&self) -> String { + self.inner.describe() + } +} + fn resolver(enabled: bool) -> (Arc, Loaderless) { let sink = Arc::new(Recording::default()); let access = Loaderless { @@ -69,6 +203,19 @@ fn resolver(enabled: bool) -> (Arc, Loaderless) { (sink, access) } +#[tokio::test] +async fn lifecycle_defaults_keep_loaderless_access_source_compatible() { + let (_, access) = resolver(true); + assert!(access.prepare_attested(RECORD.id).await.is_err()); + assert_eq!( + access + .reinitialize(RECORD.id, serde_json::Value::Null) + .await, + Err(ReconfigureFailure::Unsupported) + ); + access.mark_faulted(RECORD.id); +} + fn method_failed(name: &str) -> Error { Error::MethodFailed { name: name.to_string(), @@ -98,11 +245,26 @@ fn bus_errors_classify_to_closed_outcomes() { ModuleCallError::IncompatibleContract, Reason::IncompatibleContract, ), + ( + Error::Json(serde_json::from_str::("{").unwrap_err()), + ModuleCallError::IncompatibleContract, + Reason::IncompatibleContract, + ), ( method_failed(Error::NOT_ATTESTED), ModuleCallError::TransportFailed, Reason::TransportFailed, ), + ( + method_failed("ai.tinyhumans.tinybus.Error.Protocol"), + ModuleCallError::TransportFailed, + Reason::TransportFailed, + ), + ( + method_failed("com.example.Protocol"), + ModuleCallError::ModuleFault, + Reason::ModuleFault, + ), ( method_failed("com.example.Whatever"), ModuleCallError::ModuleFault, @@ -119,6 +281,31 @@ fn bus_errors_classify_to_closed_outcomes() { } } +#[test] +fn wire_kind_is_the_closed_classification_not_the_wire_error_name() { + let error = method_failed("com.example.PrivateFailure"); + assert_eq!(wire_kind(&error), ModuleCallError::ModuleFault); +} + +#[test] +fn unavailable_proxy_failures_are_deduplicated_as_terminal_module_state() { + let sink = Arc::new(Recording::default()); + let reporter = FailureReporter::new(sink.clone()); + let error = Error::ModuleUnavailable { + module: RECORD.id.to_owned(), + state: "faulted".to_owned(), + detail: "module is faulted".to_owned(), + }; + assert_eq!( + report_proxy_failure(&reporter, &RECORD, &error), + ModuleCallError::Unavailable + ); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(RECORD.id.to_string(), Reason::ModuleUnavailable)] + ); +} + #[test] fn call_errors_render_the_already_reported_prefix() { for error in [ @@ -193,7 +380,90 @@ async fn a_fixture_proxy_is_classified_and_reported_when_the_call_fails() { assert!(client.fixture_proxy().is_some()); let result: Result = client.call(RECORD.id, "Member", ()).await; assert!(result.is_err()); - assert_eq!(sink.0.lock().unwrap().len(), 1); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(RECORD.id.to_string(), Reason::ModuleFault)] + ); +} + +const REMOTE_DETAIL: &str = "fixture-only credential-like value: not-a-real-credential-token-7391"; + +struct DetailedFailure; + +#[async_trait::async_trait] +impl crate::Interface for DetailedFailure { + fn name(&self) -> crate::InterfaceName { + crate::InterfaceName::new(RECORD.bus_name).unwrap() + } + + fn members(&self) -> Vec { + vec![crate::MemberName::new("Fail").unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Err(Error::MethodFailed { + name: "com.example.PrivateFailure".to_string(), + message: REMOTE_DETAIL.to_string(), + }) + } +} + +#[tokio::test] +async fn detailed_call_preserves_remote_failure_without_rendering_it() { + let sink = Arc::new(Recording::default()); + let transport = crate::transport::memory::MemoryBus::new(); + let _broker = crate::broker::Broker::new().spawn(transport.clone()); + let service = crate::Connection::connect(transport.connect().await.unwrap()) + .await + .unwrap(); + service.request_name(RECORD.bus_name).await.unwrap(); + service + .serve_at( + crate::ObjectPath::new(RECORD.object_path).unwrap(), + DetailedFailure, + ) + .await + .unwrap(); + let caller = crate::Connection::connect(transport.connect().await.unwrap()) + .await + .unwrap(); + let proxy = caller + .proxy(RECORD.bus_name, RECORD.object_path, RECORD.bus_name) + .unwrap(); + let client = ModuleClient::new(ReadyAccess { + connection: caller, + reporter: FailureReporter::new(sink.clone()), + proxy_failure: None, + proxy_timeout: None, + }) + .with_fixture(proxy); + + let failure = client + .call_detailed::(RECORD.id, "Fail", ()) + .await + .unwrap_err(); + assert_eq!(failure.kind, ModuleCallError::ModuleFault); + assert_eq!(failure.wire_name, "com.example.PrivateFailure"); + assert_eq!(failure.message, REMOTE_DETAIL); + for rendered in [format!("{failure:?}"), failure.to_string()] { + assert!(!rendered.contains("com.example.PrivateFailure")); + assert!(!rendered.contains(REMOTE_DETAIL)); + } + + let sanitized = client + .call::(RECORD.id, "Fail", ()) + .await + .unwrap_err(); + assert_eq!(sanitized, ModuleCallError::ModuleFault); + assert!(!format!("{sanitized:?}").contains(REMOTE_DETAIL)); + assert!(!sanitized.to_string().contains(REMOTE_DETAIL)); + let reports = sink.0.lock().unwrap(); + assert_eq!(reports.len(), 2); + assert!(!format!("{reports:?}").contains(REMOTE_DETAIL)); } struct JsonFixture; @@ -218,7 +488,8 @@ impl crate::Interface for JsonFixture { async fn fixture_json_and_object_calls_preserve_deadlines_and_confidential_refusals() { let (sink, resolver) = resolver(true); let bus = crate::transport::memory::MemoryBus::new(); - crate::broker::Broker::new().spawn(bus.clone()); + let _broker = crate::broker::Broker::new(); + let _broker_task = _broker.clone().spawn(bus.clone()); let service = crate::Connection::connect(bus.connect().await.unwrap()) .await .unwrap(); @@ -243,12 +514,54 @@ async fn fixture_json_and_object_calls_preserve_deadlines_and_confidential_refus let object = client.object_client(RECORD.id, "/child").await.unwrap(); let reply: String = object.call("Json", ()).await.unwrap(); assert_eq!(reply, "{\"answer\":42}"); + + let direct_sink = Arc::new(Recording::default()); + let direct = ModuleClient::new(ReadyAccess { + connection: caller.clone(), + reporter: FailureReporter::new(direct_sink.clone()), + proxy_failure: None, + proxy_timeout: None, + }); + let direct_object = direct.object_client(RECORD.id, "/child").await.unwrap(); + let reply: String = direct_object.call("Json", ()).await.unwrap(); + assert_eq!(reply, "{\"answer\":42}"); + + for (failure, expected, reason) in [ + ( + ProxyFailure::Transport, + ModuleCallError::TransportFailed, + Reason::TransportFailed, + ), + ( + ProxyFailure::Contract, + ModuleCallError::IncompatibleContract, + Reason::IncompatibleContract, + ), + ] { + let reporter = Arc::new(Recording::default()); + let failing = ModuleClient::new(ReadyAccess { + connection: caller.clone(), + reporter: FailureReporter::new(reporter.clone()), + proxy_failure: Some(failure), + proxy_timeout: None, + }); + let result: Result = failing.call(RECORD.id, "Json", ()).await; + assert_eq!(result, Err(expected)); + assert_eq!( + reporter.0.lock().unwrap().as_slice(), + &[(RECORD.id.to_string(), reason)] + ); + } assert!( - client + direct .object_client(RECORD.id, "invalid path") .await .is_err() ); + assert_eq!( + direct_sink.0.lock().unwrap().as_slice(), + &[(RECORD.id.to_string(), Reason::IncompatibleContract)] + ); let reply: Result = object.call_confidential("Json", ()).await; assert_eq!(reply, Err(ModuleCallError::TransportFailed)); let reply: Result = client.call_confidential(RECORD.id, "Json", ()).await; @@ -263,6 +576,23 @@ async fn fixture_json_and_object_calls_preserve_deadlines_and_confidential_refus .iter() .all(|(_, reason)| *reason == Reason::TransportFailed) ); + + #[cfg(feature = "modules")] + { + _broker.attest_module( + &service.unique_name().unwrap(), + crate::attest::Attestation { + name: crate::BusName::new(RECORD.bus_name).unwrap(), + sha256: "a".repeat(64), + library_sha256: None, + }, + ); + let reply: serde_json::Value = client + .call_confidential_json(RECORD.id, "Json", ()) + .await + .unwrap(); + assert_eq!(reply, serde_json::json!({ "answer": 42 })); + } } #[tokio::test] @@ -286,6 +616,45 @@ async fn unavailable_loads_and_explicit_reports_use_only_registered_identity() { ); } +#[tokio::test] +async fn unloaded_access_reports_loader_disabled_only_for_known_records() { + let sink = Arc::new(Recording::default()); + let reporter = Arc::new(FailureReporter::new(sink.clone())); + let access = UnloadedAccess::new(vec![&RECORD], true, reporter.clone()); + + assert!(access.enabled()); + assert!(std::ptr::eq(access.find(RECORD.id).unwrap(), &RECORD)); + assert!(access.find("arbitrary-unregistered-id").is_none()); + assert_eq!( + access.ensure_loaded("arbitrary-unregistered-id").await, + Err("module is unavailable: this build has no module loader".into()) + ); + assert!(sink.0.lock().unwrap().is_empty()); + + let error = access.ensure_loaded(RECORD.id).await.unwrap_err(); + assert!(error.contains("no module loader")); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(RECORD.id.to_string(), Reason::LoaderDisabled)] + ); +} + +#[tokio::test] +async fn unloaded_access_preserves_the_disabled_reason_before_loader_disabled() { + let sink = Arc::new(Recording::default()); + let reporter = Arc::new(FailureReporter::new(sink.clone())); + let client = ModuleClient::new(UnloadedAccess::new(vec![&RECORD], false, reporter)); + + let result: Result = client.call(RECORD.id, "Call", ()).await; + assert_eq!(result, Err(ModuleCallError::Unavailable)); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(RECORD.id.to_string(), Reason::Disabled)] + ); +} + +#[path = "client_attested_tests.rs"] +mod attested_tests; struct OwnerFixture(&'static str); #[async_trait::async_trait] impl crate::Interface for OwnerFixture { @@ -309,6 +678,114 @@ impl crate::Interface for OwnerFixture { } } +struct StalledObject(Mutex>>); + +#[async_trait::async_trait] +impl crate::Interface for StalledObject { + fn name(&self) -> crate::InterfaceName { + crate::InterfaceName::new(RECORD.bus_name).unwrap() + } + + fn members(&self) -> Vec { + vec![crate::MemberName::new("Block").unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + let _ = self.0.lock().unwrap().take().unwrap().send(()); + std::future::pending().await + } +} + +#[tokio::test(start_paused = true)] +async fn pin_owner_obeys_the_object_proxy_deadline_for_name_owner_lookup() { + let bus = HeldMetadataBus::new(); + let broker_task = crate::broker::Broker::new().spawn(bus.clone()); + let (connection, held) = bus.connect(Some("GetNameOwner")).await; + let proxy = connection + .proxy(RECORD.bus_name, RECORD.object_path, RECORD.bus_name) + .unwrap(); + let client = ModuleClient::new(ReadyAccess { + connection, + reporter: FailureReporter::new(Arc::new(Recording::default())), + proxy_failure: None, + proxy_timeout: None, + }) + .with_fixture(proxy) + .with_timeout(Duration::from_millis(5)); + let object = client + .object_client(RECORD.id, RECORD.object_path) + .await + .unwrap(); + let mut pin = tokio::spawn(async move { object.pin_owner().await }); + + tokio::time::timeout(Duration::from_millis(100), held.notified()) + .await + .expect("pin_owner should issue GetNameOwner"); + tokio::time::advance(Duration::from_millis(5)).await; + let result = match tokio::time::timeout(Duration::from_millis(100), &mut pin).await { + Ok(joined) => joined.unwrap(), + Err(_) => { + pin.abort(); + panic!("pin_owner should use the object's configured metadata deadline"); + } + }; + + assert!(matches!(result, Err(ModuleCallError::TransportFailed))); + broker_task.abort(); +} + +#[tokio::test(start_paused = true)] +async fn derived_object_keeps_a_custom_module_access_proxy_deadline() { + let bus = MemoryBus::new(); + let _broker = crate::broker::Broker::new().spawn(bus.clone()); + let service = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + service.request_name(RECORD.bus_name).await.unwrap(); + let (started, started_rx) = tokio::sync::oneshot::channel(); + service + .serve_at( + crate::ObjectPath::new("/child").unwrap(), + StalledObject(Mutex::new(Some(started))), + ) + .await + .unwrap(); + let caller = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let access = ReadyAccess { + connection: caller, + reporter: FailureReporter::new(Arc::new(Recording::default())), + proxy_failure: None, + proxy_timeout: Some(Duration::from_millis(5)), + }; + let root = ModuleClient::new(access) + .object_client(RECORD.id, RECORD.object_path) + .await + .unwrap(); + let child = root.object_at("/child").unwrap(); + let mut call = tokio::spawn(async move { child.call::("Block", ()).await }); + + tokio::time::timeout(Duration::from_secs(1), started_rx) + .await + .expect("the child method should reach its service") + .unwrap(); + tokio::time::advance(Duration::from_millis(5)).await; + let result = match tokio::time::timeout(Duration::from_millis(100), &mut call).await { + Ok(joined) => joined.unwrap(), + Err(_) => { + call.abort(); + panic!("a derived child should keep ModuleAccess's proxy deadline"); + } + }; + + assert_eq!(result, Err(ModuleCallError::TransportFailed)); +} + #[tokio::test] async fn pinned_objects_and_derived_children_keep_the_original_peer() { let (_, access) = resolver(true); @@ -412,3 +889,87 @@ async fn derived_objects_keep_the_configured_deadline_and_report_failures() { Err(ModuleCallError::Unavailable) )); } + +#[tokio::test] +async fn pin_owner_reports_an_alias_that_has_lost_its_owner_as_unavailable() { + let sink = Arc::new(Recording::default()); + let bus = crate::transport::memory::MemoryBus::new(); + crate::broker::Broker::new().spawn(bus.clone()); + let service = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + service.request_name(RECORD.bus_name).await.unwrap(); + service + .serve_at( + crate::ObjectPath::new(RECORD.object_path).unwrap(), + OwnerFixture("owner"), + ) + .await + .unwrap(); + let caller = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let access = ReadyAccess { + connection: caller.clone(), + reporter: FailureReporter::new(sink.clone()), + proxy_failure: None, + proxy_timeout: None, + }; + let client = ModuleClient::new(access).with_fixture( + caller + .proxy(RECORD.bus_name, RECORD.object_path, RECORD.bus_name) + .unwrap(), + ); + let object = client + .object_client(RECORD.id, RECORD.object_path) + .await + .unwrap(); + + service.release_name(RECORD.bus_name).await.unwrap(); + assert!(matches!( + object.pin_owner().await, + Err(ModuleCallError::Unavailable) + )); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(RECORD.id.to_string(), Reason::ModuleUnavailable)] + ); +} + +#[tokio::test] +async fn derived_object_rejecting_an_invalid_path_reports_incompatible_contract() { + let sink = Arc::new(Recording::default()); + let bus = crate::transport::memory::MemoryBus::new(); + crate::broker::Broker::new().spawn(bus.clone()); + let service = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + service.request_name(RECORD.bus_name).await.unwrap(); + let caller = crate::Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let access = ReadyAccess { + connection: caller.clone(), + reporter: FailureReporter::new(sink.clone()), + proxy_failure: None, + proxy_timeout: None, + }; + let client = ModuleClient::new(access).with_fixture( + caller + .proxy(RECORD.bus_name, RECORD.object_path, RECORD.bus_name) + .unwrap(), + ); + let object = client + .object_client(RECORD.id, RECORD.object_path) + .await + .unwrap(); + + assert!(matches!( + object.object_at("invalid path"), + Err(ModuleCallError::IncompatibleContract) + )); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(RECORD.id.to_string(), Reason::IncompatibleContract)] + ); +} diff --git a/crates/tinybus/src/module/failure.rs b/crates/tinybus/src/module/failure.rs index 730049ad..b27a3c17 100644 --- a/crates/tinybus/src/module/failure.rs +++ b/crates/tinybus/src/module/failure.rs @@ -121,6 +121,18 @@ impl FailureReporter { self.deliver(module, version, reason, false); } + /// Look up an identifier in the host's registry before reporting it. + /// + /// Unknown identifiers share the anonymous terminal report, so an + /// untrusted caller cannot put arbitrary identity into diagnostics. + pub fn report_for(&self, records: &[super::release::ReleaseRecord], id: &str, reason: Reason) { + if let Some(record) = records.iter().find(|record| record.id == id) { + self.report(record.id, record.version, reason); + } else { + self.report_unknown_module(); + } + } + /// Unknown identifiers never become metadata or deduplication keys. pub fn report_unknown_module(&self) { self.deliver("unregistered", "unknown", Reason::UnknownModule, true); diff --git a/crates/tinybus/src/module/failure_tests.rs b/crates/tinybus/src/module/failure_tests.rs index e95386cc..e4641443 100644 --- a/crates/tinybus/src/module/failure_tests.rs +++ b/crates/tinybus/src/module/failure_tests.rs @@ -60,6 +60,39 @@ fn unknown_modules_share_one_anonymous_key() { assert_eq!(reports[0].2, Reason::UnknownModule); } +#[test] +fn report_for_uses_only_a_matching_registry_record() { + let (sink, reporter) = reporter(); + let records = [crate::module::ReleaseRecord { + id: "registry-module", + bus_name: "ai.tinyhumans.test.RegistryModule", + object_path: "/ai/tinyhumans/test/RegistryModule", + version: "1.2.3", + release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v1.2.3", + assets: &[], + }]; + reporter.report_for(&records, "registry-module", Reason::ModuleFault); + reporter.report_for(&records, "registry-module", Reason::ModuleFault); + reporter.report_for(&records, "attacker-controlled-id", Reason::ModuleFault); + assert_eq!( + sink.reports.lock().unwrap().as_slice(), + &[ + ( + "registry-module".to_string(), + "1.2.3".to_string(), + Reason::ModuleFault, + format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH), + ), + ( + "unregistered".to_string(), + "unknown".to_string(), + Reason::UnknownModule, + format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH), + ), + ] + ); +} + #[test] fn a_sink_that_is_not_ready_does_not_spend_the_key() { let (sink, reporter) = reporter(); diff --git a/crates/tinybus/src/module/first_admitted.rs b/crates/tinybus/src/module/first_admitted.rs index 4a2a8e45..61d390ca 100644 --- a/crates/tinybus/src/module/first_admitted.rs +++ b/crates/tinybus/src/module/first_admitted.rs @@ -19,7 +19,10 @@ //! about its bytes. The release cache then gets the same pinned release, held //! to the same digest, in a directory this user owns. +use std::future::Future; use std::path::Path; +use std::pin::Pin; +use std::sync::Arc; use super::cache::{digest_marker_path, secure_release_cache}; use super::host::is_placement_refusal; @@ -88,11 +91,15 @@ pub fn load_first_admitted( release_url: plan.release_url, asset_name: asset.archive, expected_sha256: Some(asset.sha256), + expected_library_sha256: asset.library_sha256, cache_dir: &cache_dir, allow_download: false, }; host.load_github_release_cached(&release, module_config.clone()) } else { + // The marker records the signed archive, but notarization may + // rewrite its library. The bundle's sealed, rehashed local + // allowlist is the trust source for the final bytes. host.load_bundled_marked( &cache_dir, asset.archive, @@ -140,6 +147,7 @@ pub fn load_first_admitted( release_url: plan.release_url, asset_name: asset.archive, expected_sha256: Some(asset.sha256), + expected_library_sha256: asset.library_sha256, cache_dir: &cache_dir, allow_download: plan.allow_download, }; @@ -166,8 +174,8 @@ pub fn load_first_admitted( "[modules] '{id}' release cache miss with downloads disabled: {last_error}" ); return Err(format!( - "module '{id}' is unavailable: no local artifact is installed and downloads are \ - disabled in configuration" + "module '{id}' is unavailable: no usable local artifact is installed and downloads are \ + disabled in configuration ({last_error})" )); } Err(format!( @@ -176,6 +184,198 @@ pub fn load_first_admitted( )) } +/// Load the first admitted pinned artifact while preparing a fresh callback +/// binding for each candidate. A failed load consumes only its own binding; +/// the successful binding is retained with the exact registry peer. +type PreparedCallbacks = Result< + ( + std::sync::Arc, + serde_json::Value, + ), + String, +>; +type BoxedCallbackPreparer<'a> = + dyn FnMut(usize) -> Pin + Send + 'a>> + Send + 'a; + +pub async fn load_first_admitted_with_host_callbacks( + host: &ModuleHost, + plan: &ReleasePlan<'_>, + mut prepare: F, + expected_bus_name: &str, + expected_object_path: &str, +) -> Result< + ( + ModuleInfo, + std::sync::Arc, + ), + String, +> +where + F: FnMut(usize) -> Fut + Send, + Fut: std::future::Future + Send, +{ + let mut boxed_prepare = |attempt| { + Box::pin(prepare(attempt)) as Pin + Send + '_>> + }; + load_first_admitted_with_host_callbacks_inner( + host, + plan, + &mut boxed_prepare, + expected_bus_name, + expected_object_path, + ) + .await +} + +async fn load_first_admitted_with_host_callbacks_inner<'a>( + host: &ModuleHost, + plan: &ReleasePlan<'_>, + prepare: &'a mut BoxedCallbackPreparer<'a>, + expected_bus_name: &str, + expected_object_path: &str, +) -> Result< + ( + ModuleInfo, + std::sync::Arc, + ), + String, +> { + let id = plan.id; + if plan.assets.is_empty() { + return Err(format!( + "module '{id}' is not available for this platform, so the feature it provides is unavailable in this build" + )); + } + + let mut attempt = 0; + let mut last_error = String::new(); + let mut found_bundled = false; + let mut bundle_refused_for_placement_only = true; + if let Some(bundled_root) = plan.bundled_root { + for asset in plan.assets { + let Some(cache_dir) = artifact_dir(bundled_root, id, plan.version, asset.host_key) + else { + continue; + }; + let has_archive = cache_dir.join(asset.archive).is_file(); + let has_marker = digest_marker_path(&cache_dir, asset.archive).is_file(); + if !has_archive && !has_marker { + continue; + } + found_bundled = true; + let result = if has_archive { + let release = CachedRelease { + release_url: plan.release_url, + asset_name: asset.archive, + expected_sha256: Some(asset.sha256), + expected_library_sha256: asset.library_sha256, + cache_dir: &cache_dir, + allow_download: false, + }; + let (binding, config) = prepare(attempt).await?; + attempt += 1; + host.load_github_release_cached_with_host_callbacks( + &release, + config, + &binding, + expected_bus_name, + expected_object_path, + ) + .await + .map(|info| (info, binding)) + } else { + let (binding, config) = prepare(attempt).await?; + attempt += 1; + host.load_bundled_marked_with_host_callbacks( + &cache_dir, + asset.archive, + asset.sha256, + config, + &binding, + (expected_bus_name, expected_object_path), + ) + .await + .map(|info| (info, binding)) + }; + match result { + Ok((info, binding)) => { + host.retain_host_callback_binding(expected_bus_name, Arc::clone(&binding)) + .map_err(|error| { + format!("module '{id}' callback retention failed: {error}") + })?; + return Ok((info, binding)); + } + Err(error) => { + bundle_refused_for_placement_only &= is_placement_refusal(&error); + last_error = error.to_string(); + tracing::warn!( + "[modules] bundled '{id}' artifact for {} was not admitted: {last_error}", + asset.host_key + ); + } + } + } + } + if found_bundled && !bundle_refused_for_placement_only { + return Err(format!( + "module '{id}' could not be loaded from the installer bundle: {last_error}. Restart the app after repairing the installation" + )); + } + + for asset in plan.assets { + let Some(cache_dir) = artifact_dir(plan.install_root, id, plan.version, asset.host_key) + else { + last_error = + "the module's cache path could not be built from its registry entry".into(); + continue; + }; + secure_release_cache(plan.install_root, &cache_dir); + let release = CachedRelease { + release_url: plan.release_url, + asset_name: asset.archive, + expected_sha256: Some(asset.sha256), + expected_library_sha256: asset.library_sha256, + cache_dir: &cache_dir, + allow_download: plan.allow_download, + }; + let (binding, config) = prepare(attempt).await?; + attempt += 1; + match host + .load_github_release_cached_with_host_callbacks( + &release, + config, + &binding, + expected_bus_name, + expected_object_path, + ) + .await + { + Ok(info) => { + host.retain_host_callback_binding(expected_bus_name, Arc::clone(&binding)) + .map_err(|error| format!("module '{id}' callback retention failed: {error}"))?; + return Ok((info, binding)); + } + Err(error) => { + last_error = error.to_string(); + tracing::warn!( + "[modules] '{id}' artifact for {} was not admitted: {last_error}", + asset.host_key + ); + } + } + } + + if !plan.allow_download { + Err(format!( + "module '{id}' is unavailable: no usable local artifact is installed and downloads are disabled in configuration ({last_error})" + )) + } else { + Err(format!( + "module '{id}' could not be loaded: {last_error}. This is terminal for the running process; restart the app to try again" + )) + } +} + #[cfg(test)] #[path = "first_admitted_tests.rs"] mod tests; diff --git a/crates/tinybus/src/module/first_admitted_tests.rs b/crates/tinybus/src/module/first_admitted_tests.rs index 7c80df2c..5995cd11 100644 --- a/crates/tinybus/src/module/first_admitted_tests.rs +++ b/crates/tinybus/src/module/first_admitted_tests.rs @@ -6,6 +6,7 @@ const ASSET: ReleaseAsset<'static> = ReleaseAsset { host_key: "ubuntu-24.04-x86_64", archive: "demo-ubuntu-24.04-x86_64.tar.gz", sha256: "0000000000000000000000000000000000000000000000000000000000000000", + library_sha256: None, }; fn plan<'a>( @@ -177,6 +178,674 @@ fn a_bundle_marker_is_never_honoured_from_the_download_cache() { assert!(error.contains("downloads are disabled"), "{error}"); } +#[derive(Clone)] +struct TestCallback(crate::BusName); + +#[async_trait::async_trait] +impl crate::Interface for TestCallback { + fn name(&self) -> crate::InterfaceName { + "org.example.Callback".parse().unwrap() + } + + fn members(&self) -> Vec { + vec!["Observe".parse().unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Ok(serde_json::json!(self.0)) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn release_callback_resolution_uses_a_fresh_binding_for_each_rejected_candidate() { + use super::load_first_admitted_with_host_callbacks; + use crate::module::{HostCallbackEndpoint, ModuleRuntime, RuntimeNames}; + use std::sync::{Arc, Mutex}; + + let Some(fixture) = + std::env::var_os("TINYBUS_TEST_HOST_CALLBACK_MODULE").map(std::path::PathBuf::from) + else { + return; + }; + let root = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(root.path()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let runtime = Arc::new( + ModuleRuntime::start( + &RuntimeNames { + thread: "tinybus-callback-release-test".into(), + worker: "tinybus-callback-release-worker".into(), + }, + None, + ) + .unwrap(), + ); + let record = Box::leak(Box::new(crate::module::ReleaseRecord { + id: "tinybus", + bus_name: "org.example.EagerCallback", + object_path: "/org/example/EagerCallback", + version: "0.0.0", + release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v0.0.0", + assets: &[], + })); + + let mut assets = Vec::new(); + for (host_key, archive_name, bytes) in [ + ("candidate-one", "one.tar.gz", b"archive-one".as_slice()), + ("candidate-two", "two.tar.gz", b"archive-two".as_slice()), + ] { + let dir = artifact_dir(&root, record.id, record.version, host_key).unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(archive_name), bytes).unwrap(); + let digest = + crate::module::hash::file_hex(std::fs::File::open(dir.join(archive_name)).unwrap()) + .unwrap(); + let module = dir.join(format!( + "libcandidate.{}", + crate::module::cache::library_extension() + )); + if host_key == "candidate-one" { + std::fs::write(&module, b"not a loadable module").unwrap(); + } else { + std::fs::copy(&fixture, &module).unwrap(); + let library_hash = + crate::module::hash::file_hex(std::fs::File::open(&module).unwrap()).unwrap(); + std::fs::write( + dir.join("modules.toml"), + format!( + "{:?} = {library_hash:?}\n", + module.file_name().unwrap().to_str().unwrap() + ), + ) + .unwrap(); + } + assets.push(ReleaseAsset { + host_key: Box::leak(host_key.to_owned().into_boxed_str()), + archive: Box::leak(archive_name.to_owned().into_boxed_str()), + sha256: Box::leak(digest.into_boxed_str()), + library_sha256: None, + }); + } + + let plan = ReleasePlan { + id: record.id, + version: record.version, + release_url: record.release_url, + assets: &assets, + install_root: &root, + bundled_root: None, + allow_download: false, + }; + let attempts = Arc::new(Mutex::new(Vec::new())); + let factory_runtime = Arc::clone(&runtime); + let factory_attempts = Arc::clone(&attempts); + let prepare = move |_attempt: usize| { + let runtime = Arc::clone(&factory_runtime); + let attempts = Arc::clone(&factory_attempts); + async move { + let binding = Arc::new( + runtime + .host() + .prepare_host_callbacks(vec![HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .map_err(|error| error.to_string())?, + ); + binding + .serve_at( + "/callback".parse().unwrap(), + TestCallback(binding.unique_name().clone()), + ) + .await + .map_err(|error| error.to_string())?; + attempts.lock().unwrap().push(Arc::clone(&binding)); + let config = serde_json::json!({ "host": binding.unique_name().as_str() }); + Ok((binding, config)) + } + }; + + let host = runtime.host(); + let (_info, selected) = load_first_admitted_with_host_callbacks( + host, + &plan, + prepare, + record.bus_name, + record.object_path, + ) + .await + .unwrap(); + let bindings = attempts.lock().unwrap(); + assert_eq!(bindings.len(), 2); + assert!(!host.retains_host_callback_binding(record.bus_name, &bindings[0])); + assert!(host.retains_host_callback_binding(record.bus_name, &selected)); + assert!(Arc::ptr_eq(&bindings[1], &selected)); +} + +#[tokio::test] +async fn callback_release_loader_reports_empty_candidates_without_preparing_authority() { + let runtime = crate::module::ModuleRuntime::start( + &crate::module::RuntimeNames { + thread: "empty-callback-candidate-test".into(), + worker: "empty-callback-candidate-worker".into(), + }, + None, + ) + .unwrap(); + let plan = ReleasePlan { + assets: &[], + ..plan(&[], std::path::Path::new("."), None) + }; + let mut attempts = 0; + let error = super::load_first_admitted_with_host_callbacks( + runtime.host(), + &plan, + |_| { + attempts += 1; + async { Err("must not prepare an empty candidate".into()) } + }, + "org.example.Demo", + "/org/example/Demo", + ) + .await + .err() + .expect("the missing archive cannot be admitted"); + assert!(error.contains("not available for this platform")); + assert_eq!(attempts, 0); +} + +#[tokio::test] +async fn callback_release_loader_skips_unbuildable_bundle_and_cache_paths() { + let runtime = crate::module::ModuleRuntime::start( + &crate::module::RuntimeNames { + thread: "callback-unbuildable-path-test".into(), + worker: "callback-unbuildable-path-worker".into(), + }, + None, + ) + .unwrap(); + let bundle = tempfile::tempdir().unwrap(); + let cache = tempfile::tempdir().unwrap(); + let bad = ReleaseAsset { + host_key: "../escape", + ..ASSET + }; + let assets = [bad]; + let plan = ReleasePlan { + bundled_root: Some(bundle.path()), + ..plan(&assets, cache.path(), Some(bundle.path())) + }; + let mut attempts = 0; + let error = super::load_first_admitted_with_host_callbacks( + runtime.host(), + &plan, + |_| { + attempts += 1; + async { Err("no candidate should prepare authority".into()) } + }, + "org.example.Demo", + "/org/example/Demo", + ) + .await + .err() + .expect("unbuildable candidates should be rejected"); + + assert!(error.contains("downloads are disabled"), "{error}"); + assert_eq!(attempts, 0); + + let mut downloadable = plan; + downloadable.allow_download = true; + let Err(error) = super::load_first_admitted_with_host_callbacks( + runtime.host(), + &downloadable, + |_| async { Err("unbuildable paths must skip the factory".into()) }, + "org.example.Demo", + "/org/example/Demo", + ) + .await + else { + panic!("invalid installer marker should be rejected"); + }; + assert!(error.contains("restart the app"), "{error}"); +} + +#[tokio::test] +async fn callback_preparation_failure_stops_before_trying_another_candidate() { + let runtime = crate::module::ModuleRuntime::start( + &crate::module::RuntimeNames { + thread: "callback-prepare-failure-test".into(), + worker: "callback-prepare-failure-worker".into(), + }, + None, + ) + .unwrap(); + let bundled = tempfile::tempdir().unwrap(); + let cache = tempfile::tempdir().unwrap(); + let assets = [ + ReleaseAsset { + host_key: "candidate-one", + archive: "one.tar.gz", + ..ASSET + }, + ReleaseAsset { + host_key: "candidate-two", + archive: "two.tar.gz", + ..ASSET + }, + ]; + let first_dir = artifact_dir(bundled.path(), "demo", "1.0.0", assets[0].host_key).unwrap(); + std::fs::create_dir_all(&first_dir).unwrap(); + std::fs::write(first_dir.join(assets[0].archive), b"candidate archive").unwrap(); + let plan = ReleasePlan { + bundled_root: Some(bundled.path()), + ..plan(&assets, cache.path(), Some(bundled.path())) + }; + let mut attempts = Vec::new(); + + let Err(error) = super::load_first_admitted_with_host_callbacks( + runtime.host(), + &plan, + |attempt| { + attempts.push(attempt); + async { Err("callback authority could not be prepared".to_owned()) } + }, + "org.example.Demo", + "/org/example/Demo", + ) + .await + else { + panic!("callback preparation failure must stop resolution"); + }; + + assert_eq!(error, "callback authority could not be prepared"); + assert_eq!(attempts, [0]); + assert!(!cache.path().join("demo").exists()); +} + +#[tokio::test] +async fn callback_release_loader_skips_bundle_candidates_without_an_archive_or_marker() { + let runtime = crate::module::ModuleRuntime::start( + &crate::module::RuntimeNames { + thread: "callback-empty-bundle-candidate-test".into(), + worker: "callback-empty-bundle-candidate-worker".into(), + }, + None, + ) + .unwrap(); + let bundle = tempfile::tempdir().unwrap(); + let cache = tempfile::tempdir().unwrap(); + let assets = [ASSET]; + let plan = plan(&assets, cache.path(), Some(bundle.path())); + let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let prepare_attempts = std::sync::Arc::clone(&attempts); + let host = runtime.host(); + let Err(error) = super::load_first_admitted_with_host_callbacks( + host, + &plan, + move |_| { + let attempts = std::sync::Arc::clone(&prepare_attempts); + async move { + attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let binding = std::sync::Arc::new( + host.prepare_host_callbacks(vec![crate::module::HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .unwrap(), + ); + Ok((binding, serde_json::json!({}))) + } + }, + "org.example.Demo", + "/org/example/Demo", + ) + .await + else { + panic!("an uncached candidate should not be admitted"); + }; + + assert!(error.contains("downloads are disabled"), "{error}"); + assert!(!error.contains("installer bundle"), "{error}"); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn callback_release_loader_refuses_a_present_bundle_with_an_invalid_marker() { + use crate::module::{HostCallbackEndpoint, ModuleRuntime, RuntimeNames}; + use std::sync::Arc; + + let runtime = Arc::new( + ModuleRuntime::start( + &RuntimeNames { + thread: "callback-invalid-marker-test".into(), + worker: "callback-invalid-marker-worker".into(), + }, + None, + ) + .unwrap(), + ); + let bundled = tempfile::tempdir().unwrap(); + let cache = tempfile::tempdir().unwrap(); + let directory = artifact_dir(bundled.path(), "demo", "1.0.0", ASSET.host_key).unwrap(); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join(format!("{}.sha256", ASSET.archive)), + format!("{}\n", "f".repeat(64)), + ) + .unwrap(); + let assets = [ASSET]; + let plan = plan(&assets, cache.path(), Some(bundled.path())); + let for_prepare = Arc::clone(&runtime); + let Err(error) = super::load_first_admitted_with_host_callbacks( + runtime.host(), + &plan, + move |_| { + let runtime = Arc::clone(&for_prepare); + async move { + let binding = Arc::new( + runtime + .host() + .prepare_host_callbacks(vec![HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .map_err(|error| error.to_string())?, + ); + Ok((binding, serde_json::json!({}))) + } + }, + "org.example.Demo", + "/org/example/Demo", + ) + .await + else { + panic!("invalid installer marker should be rejected"); + }; + + assert!(error.contains("installer bundle"), "{error}"); + assert!(error.contains("does not match the pin"), "{error}"); + assert!(!cache.path().join("demo").exists()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn callback_release_loader_admits_a_pinned_installer_module_and_retains_its_binding() { + use crate::module::{HostCallbackEndpoint, ModuleRuntime, RuntimeNames}; + use std::sync::Arc; + + let Some(fixture) = + std::env::var_os("TINYBUS_TEST_HOST_CALLBACK_MODULE").map(std::path::PathBuf::from) + else { + return; + }; + let runtime = Arc::new( + ModuleRuntime::start( + &RuntimeNames { + thread: "callback-bundled-admission-test".into(), + worker: "callback-bundled-admission-worker".into(), + }, + None, + ) + .unwrap(), + ); + let root = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(root.path()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let asset = ReleaseAsset { + host_key: ASSET.host_key, + archive: "tinybus-callback.tar.gz", + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + library_sha256: None, + }; + let directory = artifact_dir(&root, "tinybus", "0.0.0", asset.host_key).unwrap(); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join(format!("{}.sha256", asset.archive)), + format!("{}\n", asset.sha256), + ) + .unwrap(); + let module = directory.join(format!( + "libcallback.{}", + crate::module::cache::library_extension() + )); + std::fs::copy(&fixture, &module).unwrap(); + let library_hash = + crate::module::hash::file_hex(std::fs::File::open(&module).unwrap()).unwrap(); + std::fs::write( + directory.join("modules.toml"), + format!( + "{:?} = {library_hash:?}\n", + module.file_name().unwrap().to_str().unwrap() + ), + ) + .unwrap(); + let assets = [asset]; + let plan = ReleasePlan { + id: "tinybus", + version: "0.0.0", + release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v0.0.0", + assets: &assets, + install_root: &root, + bundled_root: Some(&root), + allow_download: false, + }; + let factory_runtime = Arc::clone(&runtime); + let (info, selected) = super::load_first_admitted_with_host_callbacks( + runtime.host(), + &plan, + move |_| { + let runtime = Arc::clone(&factory_runtime); + async move { + let binding = Arc::new( + runtime + .host() + .prepare_host_callbacks(vec![HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .map_err(|error| error.to_string())?, + ); + binding + .serve_at( + "/callback".parse().unwrap(), + TestCallback(binding.unique_name().clone()), + ) + .await + .map_err(|error| error.to_string())?; + let config = serde_json::json!({ "host": binding.unique_name().as_str() }); + Ok((binding, config)) + } + }, + "org.example.EagerCallback", + "/org/example/EagerCallback", + ) + .await + .unwrap(); + + assert_eq!(info.manifest.bus_name.as_str(), "org.example.EagerCallback"); + assert!( + runtime + .host() + .retains_host_callback_binding("org.example.EagerCallback", &selected) + ); +} + +#[tokio::test] +async fn callback_release_loader_reports_uncached_candidate_when_downloads_are_disabled() { + use crate::module::{HostCallbackEndpoint, ModuleRuntime, RuntimeNames}; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + #[derive(Clone)] + struct Callback; + #[async_trait::async_trait] + impl crate::Interface for Callback { + fn name(&self) -> crate::InterfaceName { + "org.example.Callback".parse().unwrap() + } + fn members(&self) -> Vec { + vec!["Observe".parse().unwrap()] + } + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Ok(serde_json::Value::Null) + } + } + + let runtime = Arc::new( + ModuleRuntime::start( + &RuntimeNames { + thread: "uncached-callback-candidate-test".into(), + worker: "uncached-callback-candidate-worker".into(), + }, + None, + ) + .unwrap(), + ); + let root = tempfile::tempdir().unwrap(); + let assets = [ASSET]; + let plan = plan(&assets, root.path(), None); + let attempts = Arc::new(AtomicUsize::new(0)); + let runtime_for_prepare = Arc::clone(&runtime); + let attempts_for_prepare = Arc::clone(&attempts); + let error = super::load_first_admitted_with_host_callbacks( + runtime.host(), + &plan, + move |_| { + let runtime = Arc::clone(&runtime_for_prepare); + attempts_for_prepare.fetch_add(1, Ordering::SeqCst); + async move { + let binding = Arc::new( + runtime + .host() + .prepare_host_callbacks(vec![HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .map_err(|error| error.to_string())?, + ); + binding + .serve_at("/callback".parse().unwrap(), Callback) + .await + .map_err(|error| error.to_string())?; + Ok((binding, serde_json::json!({}))) + } + }, + "org.example.Demo", + "/org/example/Demo", + ) + .await + .err() + .expect("a corrupt installer archive cannot be admitted"); + + assert!(error.contains("downloads are disabled"), "{error}"); + assert_eq!(attempts.load(Ordering::SeqCst), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn callback_release_loader_rejects_a_present_invalid_bundle_without_using_user_cache() { + use crate::module::{HostCallbackEndpoint, ModuleRuntime, RuntimeNames}; + use std::sync::Arc; + + #[derive(Clone)] + struct Callback; + #[async_trait::async_trait] + impl crate::Interface for Callback { + fn name(&self) -> crate::InterfaceName { + "org.example.Callback".parse().unwrap() + } + fn members(&self) -> Vec { + vec!["Observe".parse().unwrap()] + } + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Ok(serde_json::Value::Null) + } + } + + let runtime = Arc::new( + ModuleRuntime::start( + &RuntimeNames { + thread: "invalid-bundle-callback-test".into(), + worker: "invalid-bundle-callback-worker".into(), + }, + None, + ) + .unwrap(), + ); + let bundled = tempfile::tempdir().unwrap(); + let user_cache = tempfile::tempdir().unwrap(); + let dir = artifact_dir(bundled.path(), "demo", "1.0.0", ASSET.host_key).unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(ASSET.archive), b"not a module archive").unwrap(); + let assets = [ASSET]; + let plan = plan(&assets, user_cache.path(), Some(bundled.path())); + let runtime_for_prepare = Arc::clone(&runtime); + let error = super::load_first_admitted_with_host_callbacks( + runtime.host(), + &plan, + move |_| { + let runtime = Arc::clone(&runtime_for_prepare); + async move { + let binding = Arc::new( + runtime + .host() + .prepare_host_callbacks(vec![HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .map_err(|error| error.to_string())?, + ); + binding + .serve_at("/callback".parse().unwrap(), Callback) + .await + .map_err(|error| error.to_string())?; + Ok((binding, serde_json::json!({}))) + } + }, + "org.example.Demo", + "/org/example/Demo", + ) + .await + .err() + .expect("a corrupt installer archive cannot be admitted"); + + assert!(error.contains("installer bundle"), "{error}"); + assert!(!user_cache.path().join("demo").exists()); +} + /// A marker-style bundle entry whose library and allowlist agree, so nothing /// about its bytes is refused before the loader reaches the directory gate. #[cfg(unix)] diff --git a/crates/tinybus/src/module/fixtures/archive_collision.zip b/crates/tinybus/src/module/fixtures/archive_collision.zip new file mode 100644 index 00000000..9c3c4498 Binary files /dev/null and b/crates/tinybus/src/module/fixtures/archive_collision.zip differ diff --git a/crates/tinybus/src/module/fixtures/canonical_leaf_collision.zip b/crates/tinybus/src/module/fixtures/canonical_leaf_collision.zip new file mode 100644 index 00000000..c627a16f Binary files /dev/null and b/crates/tinybus/src/module/fixtures/canonical_leaf_collision.zip differ diff --git a/crates/tinybus/src/module/fixtures/canonical_parent_collision.zip b/crates/tinybus/src/module/fixtures/canonical_parent_collision.zip new file mode 100644 index 00000000..bcef59cf Binary files /dev/null and b/crates/tinybus/src/module/fixtures/canonical_parent_collision.zip differ diff --git a/crates/tinybus/src/module/fixtures/case_collision.zip b/crates/tinybus/src/module/fixtures/case_collision.zip new file mode 100644 index 00000000..c608eaf2 Binary files /dev/null and b/crates/tinybus/src/module/fixtures/case_collision.zip differ diff --git a/crates/tinybus/src/module/fixtures/casefold_collision.zip b/crates/tinybus/src/module/fixtures/casefold_collision.zip new file mode 100644 index 00000000..af047f59 Binary files /dev/null and b/crates/tinybus/src/module/fixtures/casefold_collision.zip differ diff --git a/crates/tinybus/src/module/fixtures/compat_deflate_descriptor.zip b/crates/tinybus/src/module/fixtures/compat_deflate_descriptor.zip new file mode 100644 index 00000000..53b1ee3b Binary files /dev/null and b/crates/tinybus/src/module/fixtures/compat_deflate_descriptor.zip differ diff --git a/crates/tinybus/src/module/fixtures/compat_deflate_descriptor_zip64_local.zip b/crates/tinybus/src/module/fixtures/compat_deflate_descriptor_zip64_local.zip new file mode 100644 index 00000000..6410bc27 Binary files /dev/null and b/crates/tinybus/src/module/fixtures/compat_deflate_descriptor_zip64_local.zip differ diff --git a/crates/tinybus/src/module/fixtures/compat_store.zip b/crates/tinybus/src/module/fixtures/compat_store.zip new file mode 100644 index 00000000..43abda09 Binary files /dev/null and b/crates/tinybus/src/module/fixtures/compat_store.zip differ diff --git a/crates/tinybus/src/module/fixtures/compat_zip64_local.zip b/crates/tinybus/src/module/fixtures/compat_zip64_local.zip new file mode 100644 index 00000000..161595ed Binary files /dev/null and b/crates/tinybus/src/module/fixtures/compat_zip64_local.zip differ diff --git a/crates/tinybus/src/module/fixtures/deep_path.zip b/crates/tinybus/src/module/fixtures/deep_path.zip new file mode 100644 index 00000000..f1f9f43f Binary files /dev/null and b/crates/tinybus/src/module/fixtures/deep_path.zip differ diff --git a/crates/tinybus/src/module/fixtures/duplicate.zip b/crates/tinybus/src/module/fixtures/duplicate.zip new file mode 100644 index 00000000..683bab50 Binary files /dev/null and b/crates/tinybus/src/module/fixtures/duplicate.zip differ diff --git a/crates/tinybus/src/module/fixtures/encrypted.zip b/crates/tinybus/src/module/fixtures/encrypted.zip new file mode 100644 index 00000000..072ab067 Binary files /dev/null and b/crates/tinybus/src/module/fixtures/encrypted.zip differ diff --git a/crates/tinybus/src/module/fixtures/file_directory_collision.zip b/crates/tinybus/src/module/fixtures/file_directory_collision.zip new file mode 100644 index 00000000..aead8ec3 Binary files /dev/null and b/crates/tinybus/src/module/fixtures/file_directory_collision.zip differ diff --git a/crates/tinybus/src/module/fixtures/generate.py b/crates/tinybus/src/module/fixtures/generate.py new file mode 100644 index 00000000..63b4d4c2 --- /dev/null +++ b/crates/tinybus/src/module/fixtures/generate.py @@ -0,0 +1,154 @@ +"""Regenerate the independent ZIP fixtures used by zip_extract_tests.rs. + +Run from the TinyBus component root with Python 3's standard library only: + + python3 crates/tinybus/src/module/fixtures/generate.py +""" + +from __future__ import annotations + +import io +import pathlib +import stat +import struct +import warnings +import zipfile + +ROOT = pathlib.Path(__file__).parent + + +class NonSeekable(io.BytesIO): + def seekable(self) -> bool: + return False + + def seek(self, *args: object) -> int: + raise io.UnsupportedOperation("fixture is intentionally streaming") + + +def archive(rows: list[tuple[str, bytes, int | None, int]], *, streaming: bool = False) -> bytes: + output: io.BytesIO = NonSeekable() if streaming else io.BytesIO() + with zipfile.ZipFile(output, "w") as writer: + for name, body, mode, compression in rows: + info = zipfile.ZipInfo(name) + info.compress_type = compression + if mode is not None: + info.external_attr = mode << 16 + writer.writestr(info, body) + return output.getvalue() + + +def write(name: str, body: bytes) -> bytearray: + data = bytearray(body) + (ROOT / name).write_bytes(data) + return data + + +def central_records(data: bytes | bytearray) -> list[int]: + offsets: list[int] = [] + offset = 0 + while (offset := data.find(b"PK\x01\x02", offset)) >= 0: + offsets.append(offset) + offset += 4 + return offsets + + +def stored(rows: list[tuple[str, bytes, int | None]]) -> bytes: + return archive([(name, body, mode, zipfile.ZIP_STORED) for name, body, mode in rows]) + + +write( + "compat_store.zip", + stored( + [ + ("folder/", b"", None), + ("folder/module.so", b"module-payload", None), + ("empty", b"", None), + ] + ), +) +write( + "compat_deflate_descriptor.zip", + archive( + [("folder/module.so", b"deflated-module-payload" * 16, None, zipfile.ZIP_DEFLATED)], + streaming=True, + ), +) +write("unsafe_parent.zip", stored([("../outside.txt", b"outside", None)])) +write("windows_drive.zip", stored([("C:/outside.txt", b"outside", None)])) +write("windows_backslash.zip", stored([("..\\outside.txt", b"outside", None)])) +write("unix_link_mode.zip", stored([("link", b"../outside.txt", stat.S_IFLNK | 0o777)])) +write( + "suid_mode.zip", + stored([("module.so", b"module-payload", stat.S_IFREG | stat.S_ISUID | stat.S_ISGID | 0o777)]), +) +with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + write("duplicate.zip", stored([("module.so", b"first", None), ("module.so", b"second", None)])) +write("archive_collision.zip", stored([("fixture.zip", b"must not replace the archive", None)])) +write("case_collision.zip", stored([("foo/module.so", b"first", None), ("FOO/MODULE.SO", b"second", None)])) +write("parent_case_collision.zip", stored([("A/one", b"first", None), ("a/two", b"second", None)])) +write("file_directory_collision.zip", stored([("a", b"file", None), ("a/b", b"child", None)])) +write( + "canonical_parent_collision.zip", + stored([("é/one", b"first", None), ("e\u0301/two", b"second", None)]), +) +write( + "canonical_leaf_collision.zip", + stored([("dir/café", b"first", None), ("dir/cafe\u0301", b"second", None)]), +) +write( + "casefold_collision.zip", + stored([("dir/Straße", b"first", None), ("dir/STRASSE", b"second", None)]), +) +write( + "unicode_paths.zip", + stored([("café/module.so", b"accented", None), ("数据/module.so", b"cjk", None)]), +) + +zip64_local = io.BytesIO() +with zipfile.ZipFile(zip64_local, "w") as writer: + info = zipfile.ZipInfo("module.so") + info.compress_type = zipfile.ZIP_STORED + with writer.open(info, "w", force_zip64=True) as entry: + entry.write(b"zip64-local-module") +write("compat_zip64_local.zip", zip64_local.getvalue()) + +zip64_streaming = NonSeekable() +with zipfile.ZipFile(zip64_streaming, "w") as writer: + info = zipfile.ZipInfo("module.so") + info.compress_type = zipfile.ZIP_DEFLATED + with writer.open(info, "w", force_zip64=True) as entry: + entry.write(b"zip64 streaming descriptor") +write("compat_deflate_descriptor_zip64_local.zip", zip64_streaming.getvalue()) + +depth_name = "/".join(["a"] * 130) + "/module.so" +write("deep_path.zip", stored([(depth_name, b"", None)])) +write("one_module.zip", stored([("module.so", b"payload", None)])) + +encrypted = write("encrypted.zip", stored([("module.so", b"module", None)])) +local = encrypted.find(b"PK\x03\x04") +central = central_records(encrypted)[0] +struct.pack_into(" { /// The archive digest compiled into the host, if it pins one. Checked /// against the release's own manifest and against the bytes. pub expected_sha256: Option<&'a str>, + /// The expected extracted-library digest, correlated with the archive in + /// the release checksum manifest. + pub expected_library_sha256: Option<&'a str>, /// Where the verified archive and its extraction live between launches. /// One directory per module version; the host names it. pub cache_dir: &'a Path, @@ -93,6 +97,8 @@ struct ReleaseAsset { struct Checksums { #[serde(default)] sha256: HashMap, + #[serde(default)] + library_sha256: HashMap, } /// Where one release's manifest and archive were found. @@ -146,6 +152,7 @@ pub(crate) fn acquire_cached(release: &CachedRelease<'_>) -> Result<(PathBuf, St release.asset_name, release.expected_sha256, ) { + verify_library_digest(&hit.module, release.expected_library_sha256)?; info!( asset = release.asset_name, "release loaded from the local cache" @@ -159,6 +166,11 @@ pub(crate) fn acquire_cached(release: &CachedRelease<'_>) -> Result<(PathBuf, St let agent = agent(); let located = locate(&agent, owner, repo, tag, release.asset_name)?; let manifest_sha = manifest_digest(&located, release.asset_name, release.expected_sha256)?; + let _manifest_library_sha = manifest_library_digest( + &located, + release.asset_name, + release.expected_library_sha256, + )?; let staging = cache::stage(release.cache_dir)?; restrict_extraction_directory(staging.path())?; @@ -167,6 +179,7 @@ pub(crate) fn acquire_cached(release: &CachedRelease<'_>) -> Result<(PathBuf, St verify_archive(&archive_path, &manifest_sha)?; extract_private(release.asset_name, &archive_path, staging.path())?; let module = cache::find_module(staging.path())?; + verify_library_digest(&module, release.expected_library_sha256)?; let relative = module .strip_prefix(staging.path()) .map_err(|_| refused("release module is outside its staging directory"))? @@ -196,6 +209,7 @@ fn commit_or_reuse( ) }; if let Some(hit) = concurrent_fill() { + verify_library_digest(&hit.module, release.expected_library_sha256)?; info!( asset = release.asset_name, "release cache: a concurrent fill already verifies; keeping it" @@ -206,6 +220,7 @@ fn commit_or_reuse( // Another process may have committed the same release first; its files // are as good as ours if they verify. Otherwise the failure stands. if let Some(hit) = concurrent_fill() { + verify_library_digest(&hit.module, release.expected_library_sha256)?; warn!( asset = release.asset_name, "release cache: commit lost a race; using the concurrent fill" @@ -222,6 +237,22 @@ fn commit_or_reuse( Ok((module, manifest_sha)) } +fn verify_library_digest(path: &Path, expected: Option<&str>) -> Result<()> { + let actual = file_hex( + std::fs::File::open(path).map_err(|_| refused("release library could not be read"))?, + ) + .map_err(|_| refused("release library hash could not be read"))?; + if let Some(expected) = expected { + validate_hash(expected)?; + if !actual.eq_ignore_ascii_case(expected) { + return Err(refused( + "extracted library hash does not match its release checksum", + )); + } + } + Ok(()) +} + /// One agent per acquisition, so the three requests share a connection pool /// and every one of them carries the same budgets. fn agent() -> ureq::Agent { @@ -316,6 +347,7 @@ fn manifest_digest( ) -> Result { let checksums = parse_checksums(&located.manifest_name, &located.manifest)?; let manifest_sha = checksums + .sha256 .get(asset_name) .ok_or_else(|| refused("release asset is absent from its checksum manifest"))?; validate_hash(manifest_sha)?; @@ -328,6 +360,34 @@ fn manifest_digest( Ok(manifest_sha.to_ascii_lowercase()) } +/// The optional extracted-library digest correlated to the same release asset. +fn manifest_library_digest( + located: &Located, + asset_name: &str, + expected: Option<&str>, +) -> Result> { + let checksums = parse_checksums(&located.manifest_name, &located.manifest)?; + let Some(digest) = checksums.library_sha256.get(asset_name) else { + return if expected.is_some() { + Err(refused( + "release asset has no library digest in its checksum manifest", + )) + } else { + Ok(None) + }; + }; + validate_hash(digest)?; + if let Some(expected) = expected { + validate_hash(expected)?; + if !digest.eq_ignore_ascii_case(expected) { + return Err(refused( + "host library checksum disagrees with release checksum", + )); + } + } + Ok(Some(digest.to_ascii_lowercase())) +} + fn send( agent: &ureq::Agent, url: &str, @@ -419,18 +479,24 @@ fn parse_release_url(url: &str) -> Result<(&str, &str, &str)> { } } -fn parse_checksums(name: &str, bytes: &[u8]) -> Result> { +fn parse_checksums(name: &str, bytes: &[u8]) -> Result { if name.ends_with(".toml") { let source = std::str::from_utf8(bytes).map_err(|_| refused("checksum.toml is invalid"))?; - let parsed: Checksums = - toml::from_str(source).map_err(|_| refused("checksum.toml is invalid"))?; - return Ok(parsed.sha256); + return toml::from_str(source).map_err(|_| refused("checksum.toml is invalid")); } let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|_| refused("checksum.json is invalid"))?; - let object = value.get("sha256").unwrap_or(&value); - serde_json::from_value(object.clone()) - .map_err(|_| refused("checksum.json has an invalid shape")) + let sha256 = value.get("sha256").unwrap_or(&value).clone(); + let library_sha256 = value + .get("library_sha256") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + Ok(Checksums { + sha256: serde_json::from_value(sha256) + .map_err(|_| refused("checksum.json has an invalid shape"))?, + library_sha256: serde_json::from_value(library_sha256) + .map_err(|_| refused("checksum.json has an invalid library digest shape"))?, + }) } fn validate_hash(hash: &str) -> Result<()> { @@ -482,39 +548,7 @@ fn extract(name: &str, archive: &Path, destination: &Path) -> Result<()> { std::fs::File::open(archive).map_err(|_| refused("release archive is unreadable"))?; unpack_tar(file, destination)?; } else if name.ends_with(".zip") { - let file = - std::fs::File::open(archive).map_err(|_| refused("release archive is unreadable"))?; - let mut zip = - zip::ZipArchive::new(file).map_err(|_| refused("release archive is invalid"))?; - for index in 0..zip.len() { - let mut entry = zip - .by_index(index) - .map_err(|_| refused("release archive is invalid"))?; - let Some(relative) = entry.enclosed_name() else { - return Err(refused("release archive contains an unsafe path")); - }; - let unix_file_type = entry.unix_mode().unwrap_or_default() & 0o170000; - let allowed_file_type = if entry.is_dir() { 0o040000 } else { 0o100000 }; - if unix_file_type != 0 && unix_file_type != allowed_file_type { - return Err(refused( - "release archive contains an unsupported entry type", - )); - } - let target = destination.join(relative); - if entry.is_dir() { - std::fs::create_dir_all(&target) - .map_err(|_| refused("release archive extraction failed"))?; - } else { - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent) - .map_err(|_| refused("release archive extraction failed"))?; - } - let mut output = std::fs::File::create(&target) - .map_err(|_| refused("release archive extraction failed"))?; - std::io::copy(&mut entry, &mut output) - .map_err(|_| refused("release archive extraction failed"))?; - } - } + super::zip_extract::extract(archive, destination)?; } else { return Err(refused("release asset is not a supported archive")); } @@ -541,10 +575,6 @@ fn check_asset_name(asset_name: &str) -> Result<()> { } } -fn refused(reason: impl Into) -> Error { - Error::module_refused(Path::new("github-release"), reason) -} - fn private_extraction_tempdir() -> Result { let temp = { #[cfg(unix)] diff --git a/crates/tinybus/src/module/github_tests.rs b/crates/tinybus/src/module/github_tests.rs index fc6cfe40..1183c3ef 100644 --- a/crates/tinybus/src/module/github_tests.rs +++ b/crates/tinybus/src/module/github_tests.rs @@ -17,7 +17,9 @@ fn checksum_manifests_accept_the_documented_toml_and_json_shapes() { let expected = "a".repeat(64); let toml = format!("[sha256]\n\"module.tar.gz\" = \"{expected}\"\n"); assert_eq!( - parse_checksums("checksum.toml", toml.as_bytes()).unwrap()["module.tar.gz"], + parse_checksums("checksum.toml", toml.as_bytes()) + .unwrap() + .sha256["module.tar.gz"], expected ); let json = format!( @@ -25,7 +27,9 @@ fn checksum_manifests_accept_the_documented_toml_and_json_shapes() { "b".repeat(64) ); assert_eq!( - parse_checksums("checksum.json", json.as_bytes()).unwrap()["module.tar.gz"], + parse_checksums("checksum.json", json.as_bytes()) + .unwrap() + .sha256["module.tar.gz"], "b".repeat(64) ); } @@ -68,6 +72,27 @@ fn the_manifest_digest_must_agree_with_the_host_pin() { assert!(manifest_digest(&located, "m.tar.gz", Some("short")).is_err()); } +#[test] +fn a_library_digest_is_correlated_to_its_archive_and_host_pin() { + let archive = "a".repeat(64); + let library = "b".repeat(64); + let located = Located { + manifest_name: "checksum.toml".to_string(), + manifest: format!( + "[sha256]\n\"module.tar.gz\" = \"{archive}\"\n\ + [library_sha256]\n\"module.tar.gz\" = \"{library}\"\n" + ) + .into_bytes(), + archive_url: String::new(), + }; + assert_eq!( + manifest_library_digest(&located, "module.tar.gz", Some(&library)).unwrap(), + Some(library.clone()) + ); + assert!(manifest_library_digest(&located, "module.tar.gz", Some(&"c".repeat(64))).is_err()); + assert!(manifest_library_digest(&located, "missing.tar.gz", Some(&library)).is_err()); +} + #[test] fn a_cache_miss_with_downloads_disabled_is_refused_without_the_network() { let directory = tempfile::tempdir().unwrap(); @@ -75,6 +100,7 @@ fn a_cache_miss_with_downloads_disabled_is_refused_without_the_network() { release_url: "https://github.com/tinyhumansai/demo/releases/tag/v1.0.0", asset_name: "demo-1.0.0.tar.gz", expected_sha256: None, + expected_library_sha256: None, cache_dir: &directory.path().join("demo").join("1.0.0"), allow_download: false, }) @@ -105,6 +131,7 @@ fn an_asset_name_that_leaves_the_cache_directory_is_refused() { release_url: "https://github.com/tinyhumansai/demo/releases/tag/v1.0.0", asset_name: "../escape.tar.gz", expected_sha256: Some(&digest), + expected_library_sha256: None, cache_dir: &cache_dir, allow_download: false, }) @@ -136,6 +163,7 @@ fn a_concurrent_fill_that_verifies_is_kept_rather_than_replaced() { release_url: "https://github.com/tinyhumansai/demo/releases/tag/v1.0.0", asset_name: "demo-1.0.0.tar.gz", expected_sha256: Some(&digest), + expected_library_sha256: None, cache_dir: &cache_dir, allow_download: true, }; @@ -159,11 +187,13 @@ fn a_verified_cache_answers_without_the_network() { let module = cache_dir.join(format!("libdemo_module.{}", cache::library_extension())); std::fs::write(&module, b"library").unwrap(); let digest = crate::module::sha256_file(cache_dir.join("demo-1.0.0.tar.gz")).unwrap(); + let library_digest = crate::module::sha256_file(&module).unwrap(); let (found, sha256) = acquire_cached(&CachedRelease { release_url: "https://github.com/tinyhumansai/demo/releases/tag/v1.0.0", asset_name: "demo-1.0.0.tar.gz", expected_sha256: Some(&digest), + expected_library_sha256: Some(&library_digest), cache_dir: &cache_dir, // Downloads are off, so a hit is the only way this can succeed. allow_download: false, @@ -174,7 +204,31 @@ fn a_verified_cache_answers_without_the_network() { } #[test] +fn a_cached_archive_cannot_vouch_for_a_different_extracted_library() { + let directory = tempfile::tempdir().unwrap(); + let cache_dir = directory.path().join("demo").join("1.0.0"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let archive = cache_dir.join("demo-1.0.0.tar.gz"); + std::fs::write(&archive, b"archive").unwrap(); + let module = cache_dir.join(format!("libdemo_module.{}", cache::library_extension())); + std::fs::write(&module, b"library").unwrap(); + let archive_digest = crate::module::sha256_file(&archive).unwrap(); + let wrong_library_digest = "c".repeat(64); + + let error = acquire_cached(&CachedRelease { + release_url: "https://github.com/tinyhumansai/demo/releases/tag/v1.0.0", + asset_name: "demo-1.0.0.tar.gz", + expected_sha256: Some(&archive_digest), + expected_library_sha256: Some(&wrong_library_digest), + cache_dir: &cache_dir, + allow_download: false, + }) + .unwrap_err(); + assert!(error.to_string().contains("extracted library hash")); +} + /// Extracts a valid archive while preserving its directory structure and bytes. +#[test] fn zip_archives_extract_only_enclosed_regular_files_and_directories() { let directory = tempfile::tempdir().unwrap(); let archive_path = directory.path().join("release.zip"); @@ -249,8 +303,8 @@ fn tar_root_entry_cannot_widen_release_extraction_directory() { ); } -#[test] /// Rejects traversal entries without writing outside the extraction directory. +#[test] fn zip_archives_refuse_paths_outside_the_destination() { let directory = tempfile::tempdir().unwrap(); let archive_path = directory.path().join("release.zip"); @@ -270,9 +324,9 @@ fn zip_archives_refuse_paths_outside_the_destination() { assert!(!directory.path().join("outside").exists()); } +/// Rejects symlink entries without creating a link or following its target. #[cfg(unix)] #[test] -/// Rejects symlink entries without creating a link or following its target. fn zip_archives_refuse_symlink_entries() { let directory = tempfile::tempdir().unwrap(); let archive_path = directory.path().join("release.zip"); @@ -299,9 +353,9 @@ fn zip_archives_refuse_symlink_entries() { assert!(!destination.join("module.so").exists()); } +/// Rejects Unix special-file entries instead of materializing them. #[cfg(unix)] #[test] -/// Rejects Unix special-file entries instead of materializing them. fn zip_archives_refuse_special_file_entries() { let directory = tempfile::tempdir().unwrap(); let archive_path = directory.path().join("release.zip"); @@ -327,9 +381,9 @@ fn zip_archives_refuse_special_file_entries() { assert!(!destination.join("module.so").exists()); } +/// Extracts regular files without applying archived executable or setuid bits. #[cfg(unix)] #[test] -/// Extracts regular files without applying archived executable or setuid bits. fn zip_archives_do_not_apply_setuid_or_executable_permissions() { use std::os::unix::fs::PermissionsExt; @@ -515,6 +569,7 @@ fn a_download_that_disagrees_with_its_manifest_is_refused_and_cleaned_up() { release_url: "https://github.com/o/r/releases/tag/v1", asset_name: "m.tar.gz", expected_sha256: Some(&pinned), + expected_library_sha256: None, cache_dir: &cache_dir, allow_download: true, }) diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs index 0b71c7f8..86f0d2aa 100644 --- a/crates/tinybus/src/module/host.rs +++ b/crates/tinybus/src/module/host.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak}; use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -14,16 +14,18 @@ use crate::build_info; use crate::error::{Error, Result, sanitize_untrusted}; use crate::module::abi::{TB_OK, TbAbiDescriptor, TbModuleInit, TbModuleVtable, field_bytes}; use crate::module::github::CachedRelease; +use crate::module::host::callbacks::HostCallbackBinding; use crate::module::loader::{self, LoadedArtifact}; use crate::module::manifest::{MANIFEST_SCHEMA, ModuleIdentity, ModuleManifest, PanicPolicy}; use crate::module::transport::{ModuleTransport, StopError}; -use crate::module::{HostCallbackBinding, LinkedModule}; -use crate::name::{BusName, ObjectPath}; +use crate::module::{LinkedModule, LinkedModuleInitWithSetup, LinkedSetup, ModuleId}; +use crate::name::{BusName, InterfaceName, MemberName, ObjectPath}; use crate::ports::Transport; use crate::version::Version; const LAZY_MANIFEST_SUFFIX: &str = ".manifest.json"; const LAZY_MANIFEST_MAX_LEN: u64 = 1024 * 1024; +const MAX_PRE_READY_CONFIDENTIAL_ROUTES: usize = 64; /// Current lifecycle state of a discovered module. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -91,6 +93,7 @@ pub struct ModuleInfo { struct LoadedModule { info: ModuleInfo, + module_id: ModuleId, transport: Arc, unique_name: BusName, transition_from: Option, @@ -99,8 +102,8 @@ struct LoadedModule { callback_config: Option<[u8; 32]>, // Keep operation accounting after routing revocation removes the binding. callback_state: Option>, - // Keep the owning capability alive for as long as the admitted module can - // issue callbacks. The binding points back to this host weakly. + // Keep the owning capability alive for as long as this admitted module can + // issue callbacks. The binding holds only a weak reference back to the host. _callback_binding: Option>, } @@ -161,6 +164,7 @@ struct Activation { /// records the pinned archive digest; linked code records the host /// executable digest. `None` for raw attachments and ordinary local files. pinned_sha256: Option, + pinned_library_sha256: Option, } impl PendingModule { @@ -228,6 +232,10 @@ pub struct ModuleHost { struct ModuleHostInner { broker: Broker, + // Callback bindings identify their owning host through this separate + // token. Keeping the back-reference out of `ModuleHostInner` avoids a + // recursive auto-trait proof through `loaded` bindings. + callback_owner: Arc<()>, strict: AtomicBool, // Admission spans duplicate validation through final insertion; a narrower // lock lets two concurrent loads both pass the name check. @@ -240,6 +248,127 @@ struct ModuleHostInner { // resolve sibling files for the lifetime of its mapped library. artifacts: Mutex>, warned: AtomicBool, + search_path_config: RwLock>, + pre_ready_confidential_routes: RwLock>, + #[cfg(test)] + after_loaded_publish: Mutex>, +} + +/// One exact method route that a selected module controller may call +/// confidentially while an admitted module is initializing. +/// +/// The list is empty by default and can only be configured before the host +/// admits a module. This grants no authority to ordinary module clients. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct PreReadyConfidentialRoute { + /// Object path of the host method. + pub path: ObjectPath, + /// Interface containing the host method. + pub interface: InterfaceName, + /// Method member name. + pub member: MemberName, +} + +#[cfg(test)] +struct LoadedPublishBarrier { + reached: std::sync::mpsc::SyncSender<()>, + release: std::sync::mpsc::Receiver<()>, +} + +/// Owned, read-only access to a host's admitted unique-peer lookup. +/// +/// The handle does not keep the host alive. It can be captured by a host +/// callback that must outlive the borrow used to create it; after the host is +/// dropped, lookups fail closed. It performs no broker request and grants no +/// module or attestation authority. +#[derive(Clone)] +pub struct ModuleSenderLookup { + inner: Weak, +} + +impl ModuleSenderLookup { + /// Resolve a broker-authenticated unique peer to its exact admitted module + /// identifier, or return `None` if the host is gone or the peer is no + /// longer admitted. + #[must_use] + pub fn module_for_sender(&self, sender: &BusName) -> Option { + self.inner + .upgrade() + .and_then(|inner| module_for_sender(&inner, sender)) + } + + /// Resolve a canonical module ID to its current unique peer, if admitted. + #[must_use] + pub fn sender_for_module(&self, module_id: &str) -> Option { + self.inner + .upgrade() + .and_then(|inner| sender_for_module(&inner, module_id)) + } +} + +fn module_for_sender(inner: &ModuleHostInner, sender: &BusName) -> Option { + if !sender.is_unique() { + return None; + } + let loaded = inner.loaded.lock().expect("module list lock"); + let module = loaded.iter().find(|module| &module.unique_name == sender)?; + if module.transport.is_faulted() + || module.transport.init_failed() + || module.transport.is_stopped() + || module.transport.admission_closed() + { + return None; + } + matches!( + module.snapshot().state, + ModuleState::Resolved + | ModuleState::Initializing + | ModuleState::Ready + | ModuleState::Serving + ) + .then(|| module.module_id.clone()) +} + +fn sender_for_module(inner: &ModuleHostInner, module_id: &str) -> Option { + if module_id.is_empty() { + return None; + } + let loaded = inner.loaded.lock().expect("module list lock"); + let mut candidates = loaded.iter().filter(|module| { + module.module_id.as_str() == module_id + && !module.transport.is_faulted() + && !module.transport.init_failed() + && !module.transport.is_stopped() + && !module.transport.admission_closed() + && matches!( + module.snapshot().state, + ModuleState::Initializing | ModuleState::Ready | ModuleState::Serving + ) + }); + let sender = candidates.next()?.unique_name.clone(); + candidates.next().is_none().then_some(sender) +} + +/// Product configuration for directories that `ModuleHost` scans for modules. +/// TinyBus has no product-specific default; hosts opt in by naming the +/// environment variable and application directory they own. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchPathConfig { + /// Environment variable containing path-list entries searched first. + pub env_var: String, + /// App-specific suffix beneath each platform data directory. + pub app_dir: PathBuf, +} + +impl SearchPathConfig { + /// Configure module search paths for a host. + #[must_use] + pub fn new(env_var: impl Into, app_dir: impl Into) -> Self { + Self { + env_var: env_var.into(), + app_dir: app_dir.into(), + } + } } /// Clears a module's host-side lifecycle reservation when its operation ends. @@ -253,6 +382,30 @@ struct LifecycleBusyGuard<'a> { stop_transport: Option>, } +/// Keeps a linked setup admission owned until the module publishes readiness. +/// Dropping or timing out the async attach request revokes the broker peer and +/// lets the existing native lifecycle path finish teardown; it never drops a +/// module-owned future from the caller's runtime. +struct LinkedSetupAdmissionGuard(Option>); + +impl LinkedSetupAdmissionGuard { + fn new(transport: Arc) -> Self { + Self(Some(transport)) + } + + fn ready(&mut self) { + self.0.take(); + } +} + +impl Drop for LinkedSetupAdmissionGuard { + fn drop(&mut self) { + if let Some(transport) = self.0.take() { + transport.request_terminal(true); + } + } +} + impl<'a> LifecycleBusyGuard<'a> { fn for_reinitialize(host: &'a ModuleHostInner, name: &str) -> Self { Self { @@ -335,6 +488,7 @@ impl ModuleHost { pub fn new(broker: Broker) -> Self { let inner = Arc::new(ModuleHostInner { broker: broker.clone(), + callback_owner: Arc::new(()), strict: AtomicBool::new(false), admission: Mutex::new(()), loaded: Mutex::new(Vec::new()), @@ -343,12 +497,94 @@ impl ModuleHost { configs: Mutex::new(HashMap::new()), artifacts: Mutex::new(Vec::new()), warned: AtomicBool::new(false), + search_path_config: RwLock::new(None), + pre_ready_confidential_routes: RwLock::new(Arc::from([])), + #[cfg(test)] + after_loaded_publish: Mutex::new(None), }); let control: Arc = inner.clone(); broker.set_module_control(Arc::downgrade(&control)); Self { inner } } + /// Builder form of [`Self::set_search_path_config`]. + #[must_use] + pub fn with_search_path_config(self, config: SearchPathConfig) -> Self { + self.set_search_path_config(Some(config)); + self + } + + /// Set or clear this host's module search paths. + pub fn set_search_path_config(&self, config: Option) { + *self + .inner + .search_path_config + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = config; + } + + /// Configure exact confidential host methods that the selected module + /// controller may call while a module is initializing. + /// + /// The default is an empty list. Configuration is accepted only before + /// any module admission or rejection, and at most 64 distinct routes may + /// be configured. Calls outside this exact list retain normal readiness + /// behavior. The routes do not broaden the authority of module peers. + pub fn set_pre_ready_confidential_routes( + &self, + routes: Vec, + ) -> Result<()> { + if routes.len() > MAX_PRE_READY_CONFIDENTIAL_ROUTES { + return Err(Error::protocol("too many pre-ready confidential routes")); + } + let mut unique = HashSet::with_capacity(routes.len()); + if routes.iter().any(|route| !unique.insert(route)) { + return Err(Error::protocol("duplicate pre-ready confidential route")); + } + + let _admission = self.inner.admission.lock().expect("module admission lock"); + if !self + .inner + .loaded + .lock() + .expect("module list lock") + .is_empty() + || !self + .inner + .rejected + .lock() + .expect("rejected module list lock") + .is_empty() + { + return Err(Error::protocol( + "pre-ready confidential routes must be set before module admission", + )); + } + *self + .inner + .pre_ready_confidential_routes + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Arc::from(routes); + Ok(()) + } + + /// Create an owned weak handle for resolving authenticated module peers. + /// + /// The returned handle can be stored by runtime setup callbacks without a + /// back-reference to the [`ModuleHost`] or a global runtime slot. It uses + /// the same local admission table and terminal-state checks as + /// [`Self::module_for_sender`]. + #[must_use] + pub fn sender_lookup(&self) -> ModuleSenderLookup { + ModuleSenderLookup { + inner: Arc::downgrade(&self.inner), + } + } + + pub(crate) fn configure_search_path_config(&self, config: Option) { + self.set_search_path_config(config); + } + /// Refuse modules built by a different rustc release. #[must_use] pub fn strict(self, strict: bool) -> Self { @@ -378,6 +614,9 @@ impl ModuleHost { /// connection. The module remains available while its setup function /// applies the new values; a rejected configuration leaves the runtime /// running with whatever state its setup function had already committed. + /// If a lazy module is still resolved and has not started initialization, + /// the replacement becomes its initialization configuration; its first + /// method call then starts the module with those values. pub async fn reinitialize( &self, name: impl AsRef, @@ -407,6 +646,79 @@ impl ModuleHost { modules } + /// Resolve a broker-authenticated unique peer to its exact admitted module + /// identifier. + /// + /// Well-known names are routing aliases and never identify a module + /// sender. This is a local lookup over the admission table; it performs no + /// broker request and accepts initialization-time callbacks while the + /// admitted transport remains live. + #[must_use] + pub fn module_for_sender(&self, sender: &BusName) -> Option { + module_for_sender(&self.inner, sender) + } + + /// Resolve a canonical module ID to its current unique peer, if admitted. + #[must_use] + pub fn sender_for_module(&self, module_id: &str) -> Option { + sender_for_module(&self.inner, module_id) + } + + /// Initialize a loaded module and wait for its host lifecycle readiness. + /// + /// This host-owned preparation carries no business message. It is the + /// readiness barrier used before a caller asks the broker to attest a + /// lazily registered recipient. + /// + /// # Errors + /// + /// Returns a closed lifecycle error when the name is not a loaded module, + /// initialization fails, or the module does not reach `READY`. + pub async fn ensure_ready(&self, bus_name: &str) -> Result<()> { + self.ensure_ready_within(bus_name, Duration::from_secs(5)) + .await + } + + pub(crate) async fn ensure_ready_within( + &self, + bus_name: &str, + deadline: Duration, + ) -> Result<()> { + let transport = self + .inner + .loaded + .lock() + .expect("module list lock") + .iter() + .find(|module| module.info.manifest.bus_name.as_str() == bus_name) + .map(|module| Arc::clone(&module.transport)) + .ok_or_else(|| Error::ModuleUnavailable { + module: "registered module".to_string(), + state: "unavailable".to_string(), + detail: "module is not loaded".to_string(), + })?; + transport.ensure_initialized().await?; + if tokio::time::timeout(deadline, transport.wait_ready()) + .await + .is_err() + { + return Err(Error::ModuleUnavailable { + module: "registered module".to_string(), + state: "initializing".to_string(), + detail: "module did not announce READY before the deadline".to_string(), + }); + } + if transport.is_ready() { + Ok(()) + } else { + Err(Error::ModuleUnavailable { + module: "registered module".to_string(), + state: "unavailable".to_string(), + detail: "module did not become ready".to_string(), + }) + } + } + /// Wait until the module that owns `bus_name` has finished setup, then /// snapshot it. /// @@ -516,6 +828,7 @@ impl ModuleHost { &module, config, expected_sha256.map(str::to_ascii_lowercase), + None, )?; self.inner .artifacts @@ -534,10 +847,8 @@ impl ModuleHost { /// maps the library without touching the network, which is what makes a /// module cheap to load at every launch rather than once per process. /// - /// Attestation is the same digest the uncached path records: the archive's, - /// checked against the pin before anything is mapped. The extracted library - /// is held to the release's own `modules.toml` by the ordinary allowlist - /// gate on every load. + /// Attestation preserves the archive digest and carries the separately + /// verified extracted-library digest when the release pins one. /// /// Nothing is retained here. The directory outlives the process by design, /// so the sibling files a mapped library may resolve stay where they are. @@ -553,7 +864,12 @@ impl ModuleHost { config: serde_json::Value, ) -> Result { let (module, sha256) = crate::module::github::acquire_cached(release)?; - self.load_file_pinned(&module, config, Some(sha256)) + self.load_file_pinned( + &module, + config, + Some(sha256), + release.expected_library_sha256.map(str::to_ascii_lowercase), + ) } /// Load an installer-bundle entry whose archive was replaced by its digest @@ -561,10 +877,11 @@ impl ModuleHost { /// crate-private so the only caller stays [`super::load_first_admitted`], /// which reaches it for the installer-shipped directory alone. /// - /// The pin is *not* recorded as the attestation: nothing on this path - /// hashed bytes that match it, only a marker that claims so. Attestation - /// comes from the `modules.toml` beside the library, which this path - /// requires and activation re-hashes against the mapped file. + /// The marker proves the archive pin only. It is not a library digest + /// proof: notarization may have rewritten the extracted library after the + /// archive was built. Attestation therefore comes only from the sealed + /// `modules.toml` beside the library, which this path requires and + /// activation re-hashes against the mapped file. /// /// # Errors /// @@ -583,7 +900,7 @@ impl ModuleHost { "bundled digest marker does not match the pin, or the extraction is unusable", ) })?; - self.load_file_pinned(&hit.module, config, None) + self.load_file_pinned(&hit.module, config, None, None) } /// Admit and initialize an already-resolved module without calling the @@ -630,11 +947,12 @@ impl ModuleHost { descriptor, manifest, init, + library_sha256: None, }; // No pin: the caller handed over an already-resolved artifact rather // than bytes this host read and hashed, so there is nothing to vouch // for. Attestation, if any, comes from an allowlist beside the file. - self.activate(file.as_ref(), artifact, config, None) + self.activate(file.as_ref(), artifact, config, None, None) } /// Admit a module whose code is linked into the host executable. @@ -652,6 +970,73 @@ impl ModuleHost { &self, module: LinkedModule, config: serde_json::Value, + ) -> Result { + self.attach_linked(module, config, None) + } + + /// Admit linked code and replace its export-time setup with state owned by + /// this one attachment. + /// + /// The generated static export supplies a Rust-only initializer hook; the + /// hook receives this callback in place of its default setup function. Its + /// module connection remains the admitted, broker-authenticated peer, and + /// the ordinary descriptor, dependency, executable-digest and readiness + /// paths still apply. Dynamic modules cannot use this API. + /// + /// # Errors + /// Returns an error if the export has no linked setup hook, normal admission + /// fails, setup fails, or the module does not become ready before its + /// initialization deadline. + pub async fn attach_linked_with_setup( + &self, + module: LinkedModule, + config: serde_json::Value, + setup: F, + ) -> Result + where + F: FnOnce(crate::Connection) -> Fut + Send + 'static, + Fut: std::future::Future> + Send + 'static, + { + let init = module + .init_with_setup + .ok_or_else(|| Error::failed("linked export has no stateful setup entrypoint"))?; + let setup: LinkedSetup = Box::new(move |connection| Box::pin(setup(connection))); + let name = sanitize_untrusted(&module.manifest.module.name); + let info = self.attach_linked(module, config, Some((init, setup)))?; + let transport = self + .inner + .loaded + .lock() + .expect("module list lock") + .iter() + .find(|loaded| loaded.info.name == info.name) + .map(|loaded| loaded.transport.clone()) + .ok_or_else(|| Error::failed("linked module did not retain its admitted transport"))?; + let mut setup_admission = LinkedSetupAdmissionGuard::new(transport.clone()); + transport.ensure_initialized().await?; + if tokio::time::timeout(Duration::from_secs(5), transport.wait_ready()) + .await + .is_err() + || !transport.is_ready() + { + return Err(Error::module_refused( + Path::new(&format!("linked-{name}")), + "linked module setup did not become ready", + )); + } + setup_admission.ready(); + Ok(self + .list() + .into_iter() + .find(|module| module.name == info.name) + .unwrap_or(info)) + } + + fn attach_linked( + &self, + module: LinkedModule, + config: serde_json::Value, + linked_setup: Option<(LinkedModuleInitWithSetup, LinkedSetup)>, ) -> Result { static EXECUTABLE_DIGEST: OnceLock = OnceLock::new(); let digest = match EXECUTABLE_DIGEST.get() { @@ -674,9 +1059,11 @@ impl ModuleHost { descriptor: module.descriptor, manifest: module.manifest, init: module.init, + library_sha256: None, }, config, Some(digest), + linked_setup, ) } @@ -689,7 +1076,7 @@ impl ModuleHost { path: impl AsRef, config: serde_json::Value, ) -> Result { - self.load_file_pinned(path, config, None) + self.load_file_pinned(path, config, None, None) } /// Load an artifact only if its actual manifest satisfies host requirements. @@ -725,7 +1112,16 @@ impl ModuleHost { "required activation refuses lazy sidecars", )); } - let artifact = loader::load(path, self.inner.strict.load(Ordering::Acquire))?; + let allowlisted = allowlisted_hash( + path, + std::fs::File::open(path) + .map_err(|_| Error::module_refused(path, "module library could not be opened"))?, + )?; + let artifact = loader::load_verified( + path, + self.inner.strict.load(Ordering::Acquire), + allowlisted.as_deref(), + )?; self.activate_required(path, artifact, config, requirements) } @@ -739,13 +1135,13 @@ impl ModuleHost { requirements.check(&artifact.manifest).map_err(|_| { Error::module_refused(path, "artifact does not satisfy required module surface") })?; - self.activate(path, artifact, config, None) + self.activate(path, artifact, config, None, None) } /// [`ModuleHost::load_file_with_config`], carrying a digest the caller has /// already verified for an artifact that has no `modules.toml` beside it. /// - /// `pinned_sha256` is recorded as the attestation without being re-checked + /// `pinned_sha256` is recorded as the archive attestation without being re-checked /// here, because there is nothing left on disk to re-check it against — the /// bytes it names are the archive, which was verified and then extracted. /// The verification therefore lives entirely in the caller, and this stays @@ -760,6 +1156,7 @@ impl ModuleHost { path: impl AsRef, config: serde_json::Value, pinned_sha256: Option, + pinned_library_sha256: Option, ) -> Result { let path = path.as_ref(); let result = (|| { @@ -771,17 +1168,44 @@ impl ModuleHost { })?; } check_file(path)?; + let allowlisted = allowlisted_hash( + path, + std::fs::File::open(path).map_err(|_| { + Error::module_refused(path, "module library could not be opened") + })?, + )?; + if pinned_library_sha256 + .as_ref() + .zip(allowlisted.as_ref()) + .is_some_and(|(pinned, local)| !pinned.eq_ignore_ascii_case(local)) + { + return Err(Error::module_refused( + path, + "release library pin disagrees with the local module allowlist", + )); + } + let library_pin = pinned_library_sha256.clone().or(allowlisted); if let Some(manifest) = read_lazy_manifest(path)? { self.ensure_dependencies(&manifest, path)?; - return self.register_lazy(path, manifest, config, pinned_sha256.clone()); + return self.register_lazy( + path, + manifest, + config, + pinned_sha256.clone(), + library_pin, + ); } - let artifact = loader::load(path, self.inner.strict.load(Ordering::Acquire))?; + let artifact = loader::load_verified( + path, + self.inner.strict.load(Ordering::Acquire), + library_pin.as_deref(), + )?; let rejected_manifest = artifact.manifest.clone(); if let Err(error) = self.ensure_dependencies(&artifact.manifest, path) { self.record_manifest_rejection(&error, rejected_manifest, RefusalClass::Unresolved); return Err(error); } - self.activate(path, artifact, config, pinned_sha256) + self.activate(path, artifact, config, pinned_sha256, None) })(); if let Err(error) = &result { self.record_rejection(error); @@ -821,7 +1245,12 @@ impl ModuleHost { self.ensure_dependencies(&manifest, path)?; // No pin: a caller naming a path on disk vouches for it with the // `modules.toml` beside it, if at all. - self.register_lazy(path, manifest, config, None) + let library_sha256 = allowlisted_hash( + path, + std::fs::File::open(path) + .map_err(|_| Error::module_refused(path, "module library could not be opened"))?, + )?; + self.register_lazy(path, manifest, config, None, library_sha256) } /// Discover and load every platform library in a private directory. @@ -891,8 +1320,14 @@ impl ModuleHost { if let Some(manifest) = read_lazy_manifest(&path)? { Ok(PendingModule::Lazy(Box::new(manifest))) } else { - loader::load(&path, self.inner.strict.load(Ordering::Acquire)) - .map(|artifact| PendingModule::Loaded(Box::new(artifact))) + let library_pin = + allowlisted_hash(&path, std::fs::File::open(&path).map_err(Error::from)?)?; + loader::load_verified( + &path, + self.inner.strict.load(Ordering::Acquire), + library_pin.as_deref(), + ) + .map(|artifact| PendingModule::Loaded(Box::new(artifact))) } }); match inspected { @@ -950,10 +1385,13 @@ impl ModuleHost { // `modules.toml` in it is the operator's statement about them. .and_then(|()| match module { PendingModule::Loaded(artifact) => { - self.activate(&path, *artifact, config, None) + self.activate(&path, *artifact, config, None, None) } PendingModule::Lazy(manifest) => { - self.register_lazy(&path, *manifest, config, None) + let library_sha256 = std::fs::File::open(&path) + .map_err(Error::from) + .and_then(|file| allowlisted_hash(&path, file))?; + self.register_lazy(&path, *manifest, config, None, library_sha256) } }); outcomes.push(result); @@ -964,25 +1402,39 @@ impl ModuleHost { Ok(outcomes) } - /// Search paths in precedence order for the current platform. - pub fn search_paths() -> Vec { - let mut paths = std::env::var_os("OPENHUMAN_MODULE_PATH") + /// Search paths in precedence order for this host. An unconfigured host + /// has no implicit search paths. + pub fn search_paths(&self) -> Vec { + let config = self + .inner + .search_path_config + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let Some(config) = config else { + return Vec::new(); + }; + let mut paths = std::env::var_os(&config.env_var) .map(|value| std::env::split_paths(&value).collect::>()) .unwrap_or_default(); #[cfg(target_os = "linux")] { if let Some(path) = std::env::var_os("XDG_DATA_HOME") { - paths.push(PathBuf::from(path).join("openhuman/modules")); + paths.push(PathBuf::from(path).join(&config.app_dir)); } else if let Some(path) = std::env::var_os("HOME") { - paths.push(PathBuf::from(path).join(".local/share/openhuman/modules")); + paths.push( + PathBuf::from(path) + .join(".local/share") + .join(&config.app_dir), + ); } - paths.push(PathBuf::from("/usr/lib/openhuman/modules")); + paths.push(PathBuf::from("/usr/lib").join(&config.app_dir)); } #[cfg(target_os = "macos")] - paths.push(PathBuf::from("/usr/local/lib/openhuman/modules")); + paths.push(PathBuf::from("/usr/local/lib").join(&config.app_dir)); #[cfg(windows)] if let Some(path) = std::env::var_os("LOCALAPPDATA") { - paths.push(PathBuf::from(path).join("openhuman/modules")); + paths.push(PathBuf::from(path).join(&config.app_dir)); } paths } @@ -990,10 +1442,7 @@ impl ModuleHost { /// Load every existing directory in [`ModuleHost::search_paths`] order. pub fn load_search_paths(&self) -> Vec> { let mut outcomes = Vec::new(); - for path in Self::search_paths() - .into_iter() - .filter(|path| path.is_dir()) - { + for path in self.search_paths().into_iter().filter(|path| path.is_dir()) { match self.load_dir(path) { Ok(results) => outcomes.extend(results), Err(error) => outcomes.push(Err(error)), @@ -1010,10 +1459,7 @@ impl ModuleHost { /// [`Self::load_dir`] loads them. pub fn load_search_paths_except(&self, skip: &[String]) -> Vec> { let mut outcomes = Vec::new(); - for directory in Self::search_paths() - .into_iter() - .filter(|path| path.is_dir()) - { + for directory in self.search_paths().into_iter().filter(|path| path.is_dir()) { match self.load_dir_inner(&directory, None, skip) { Ok(results) => outcomes.extend(results), Err(error) => outcomes.push(Err(error)), @@ -1039,7 +1485,13 @@ impl ModuleHost { let info = provisional_info(&path, &manifest)?; Ok((PendingModule::Lazy(Box::new(manifest)), info)) } else { - let artifact = loader::load(&path, self.inner.strict.load(Ordering::Acquire))?; + let library_pin = + allowlisted_hash(&path, std::fs::File::open(&path).map_err(Error::from)?)?; + let artifact = loader::load_verified( + &path, + self.inner.strict.load(Ordering::Acquire), + library_pin.as_deref(), + )?; let info = self.validate(&path, &artifact.descriptor, &artifact.manifest)?; Ok((PendingModule::Loaded(Box::new(artifact)), info)) } @@ -1091,7 +1543,9 @@ impl ModuleHost { artifact: LoadedArtifact, config: serde_json::Value, pinned_sha256: Option, + linked_setup: Option<(LinkedModuleInitWithSetup, LinkedSetup)>, ) -> Result { + let pinned_library_sha256 = artifact.library_sha256.clone(); let _admission = self.inner.admission.lock().expect("module admission lock"); let manifest = artifact.manifest.clone(); let mut admitted = self @@ -1117,21 +1571,48 @@ impl ModuleHost { self.record_manifest_rejection(&error, manifest.clone(), RefusalClass::Rejected); error })?; - let (transport, host_vtable) = ModuleTransport::new(admitted.name.clone(), config); - if artifact.manifest.lazy_init { - admitted.state = ModuleState::Resolved; - transport.defer_initialize(artifact.init, host_vtable); - } else { - admitted.state = ModuleState::Initializing; - if let Err(failure) = transport.eager_initialize(artifact.init, host_vtable) { - let reason = if failure.to_string().contains("vtable") { - "module returned an invalid vtable" - } else { - "module initialization failed" - }; - let error = Error::module_refused(path, reason); - self.record_manifest_rejection(&error, manifest, RefusalClass::Failed); - return Err(error); + let trusted_host = self.inner.broker.restricted_module_controller_binding(); + let pre_ready_routes = self + .inner + .pre_ready_confidential_routes + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let (transport, host_vtable) = ModuleTransport::new_with_controller_binding( + admitted.name.clone(), + config, + trusted_host, + pre_ready_routes, + ); + match linked_setup { + Some((init, setup)) => { + admitted.state = ModuleState::Resolved; + transport.defer_initializer(host_vtable, move |host| { + let mut module = TbModuleVtable::default(); + let code = unsafe { init(&host, &mut module, setup) }; + if code == TB_OK { + Ok(module) + } else { + Err("linked module initialization failed".to_string()) + } + }); + } + None if artifact.manifest.lazy_init => { + admitted.state = ModuleState::Resolved; + transport.defer_initialize(artifact.init, host_vtable); + } + None => { + admitted.state = ModuleState::Initializing; + if let Err(failure) = transport.eager_initialize(artifact.init, host_vtable) { + let reason = if failure.to_string().contains("vtable") { + "module returned an invalid vtable" + } else { + "module initialization failed" + }; + let error = Error::module_refused(path, reason); + self.record_manifest_rejection(&error, manifest, RefusalClass::Failed); + return Err(error); + } } } @@ -1145,6 +1626,7 @@ impl ModuleHost { lazy_load: false, descriptor_info: None, pinned_sha256, + pinned_library_sha256, }, ) } @@ -1155,6 +1637,7 @@ impl ModuleHost { manifest: ModuleManifest, config: serde_json::Value, pinned_sha256: Option, + pinned_library_sha256: Option, ) -> Result { let _admission = self.inner.admission.lock().expect("module admission lock"); let admitted = provisional_info(path, &manifest).inspect_err(|error| { @@ -1177,10 +1660,23 @@ impl ModuleHost { self.record_manifest_rejection(&error, manifest.clone(), RefusalClass::Rejected); error })?; - let (transport, host_vtable) = ModuleTransport::new(admitted.name.clone(), config); + let trusted_host = self.inner.broker.restricted_module_controller_binding(); + let pre_ready_routes = self + .inner + .pre_ready_confidential_routes + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let (transport, host_vtable) = ModuleTransport::new_with_controller_binding( + admitted.name.clone(), + config, + trusted_host, + pre_ready_routes, + ); let expected = manifest.clone(); let artifact_path = path.to_path_buf(); let strict = self.inner.strict.load(Ordering::Acquire); + let expected_library = pinned_library_sha256.clone(); let descriptor_info = Arc::new(Mutex::new(None)); let loaded_descriptor_info = descriptor_info.clone(); transport.defer_initializer(host_vtable, move |host| { @@ -1189,8 +1685,9 @@ impl ModuleHost { // replaced after discovery cannot inherit the old hash attestation. check_file(&artifact_path) .map_err(|_| "module library no longer passes admission".to_string())?; - let artifact = loader::load(&artifact_path, strict) - .map_err(|_| "module library could not be loaded".to_string())?; + let artifact = + loader::load_verified(&artifact_path, strict, expected_library.as_deref()) + .map_err(|_| "module library could not be loaded".to_string())?; let rustc = sanitized_field(&artifact.descriptor.rustc_version); if artifact.manifest != expected || loaded_identity(&artifact.descriptor, &artifact.manifest).is_none() @@ -1223,6 +1720,7 @@ impl ModuleHost { lazy_load: true, descriptor_info: Some(descriptor_info), pinned_sha256, + pinned_library_sha256, }, ) } @@ -1236,7 +1734,8 @@ impl ModuleHost { activation: Activation, ) -> Result { let transport_for_broker: Arc = transport.clone(); - let unique = self.inner.broker.attach(transport_for_broker); + let staged = self.inner.broker.attach_module_staged(transport_for_broker); + let unique = staged.unique_name().clone(); let reserved_change = match self .inner .broker @@ -1245,12 +1744,6 @@ impl ModuleHost { Ok(change) => change, Err(_) => { transport.commit_terminal(true); - self.inner - .broker - .router - .lock() - .expect("router lock") - .detach_unique(&unique); transport.request_terminal(true); let error = Error::module_refused(path, "module bus name is already owned"); self.record_manifest_rejection(&error, manifest, RefusalClass::Unresolved); @@ -1273,6 +1766,7 @@ impl ModuleHost { // value is carried down instead. Linked code carries the executable // digest through the same field. Without one of these sources there is // no attestation. + let archive_is_pinned = activation.pinned_sha256.is_some(); let vouched = match activation.pinned_sha256 { Some(pinned) => Some(pinned), None => std::fs::File::open(path) @@ -1281,23 +1775,70 @@ impl ModuleHost { .ok() .flatten(), }; - if let Some(sha256) = vouched { + let library_sha256 = activation.pinned_library_sha256; + let library_matches_vouch = archive_is_pinned + || library_sha256 + .as_ref() + .is_none_or(|library| vouched.as_ref() == Some(library)); + if let (Some(sha256), true) = (vouched, library_matches_vouch) { self.inner.broker.attest_module( &unique, crate::attest::Attestation { name: admitted.manifest.bus_name.clone(), sha256, + library_sha256, }, ); } - let broker = self.inner.broker.clone(); - let ready_transport = transport.clone(); - let module_name = admitted.name.clone(); - let previous_state = state_name(&admitted.state); let lazy_init = activation.lazy_init; let lazy_load = activation.lazy_load; let descriptor_info = activation.descriptor_info; + if !self.inner.warned.swap(true, Ordering::AcqRel) { + tracing::warn!( + modules = 1, + "in-process modules are inside the host trust boundary" + ); + } + if lazy_load { + tracing::info!(module = %admitted.name, "module registered for lazy loading"); + } else { + tracing::info!(module = %admitted.name, "module loaded"); + } + let ready_transport = transport.clone(); + let module_name = admitted.name.clone(); + let previous_state = state_name(&admitted.state); + self.inner + .loaded + .lock() + .expect("module list lock") + .push(LoadedModule { + info: admitted.clone(), + module_id: ModuleId::new(manifest.module.name.clone()), + transport, + unique_name: unique.clone(), + transition_from: None, + descriptor_info, + lifecycle_busy: false, + callback_config: None, + callback_state: None, + _callback_binding: None, + }); + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| staged.start())).is_err() { + let mut loaded = self.inner.loaded.lock().expect("module list lock"); + if let Some(index) = loaded.iter().position(|module| { + module.unique_name == unique && Arc::ptr_eq(&module.transport, &ready_transport) + }) { + loaded.remove(index); + } + drop(loaded); + ready_transport.commit_terminal(true); + ready_transport.request_terminal(true); + let error = Error::module_refused(path, "module transport could not start"); + self.record_manifest_rejection(&error, manifest, RefusalClass::Failed); + return Err(error); + } + let broker = self.inner.broker.clone(); tokio::spawn(async move { if lazy_init { ready_transport.wait_initializing().await; @@ -1326,32 +1867,23 @@ impl ModuleHost { .await; } }); - if !self.inner.warned.swap(true, Ordering::AcqRel) { - tracing::warn!( - modules = 1, - "in-process modules are inside the host trust boundary" - ); - } - if lazy_load { - tracing::info!(module = %admitted.name, "module registered for lazy loading"); - } else { - tracing::info!(module = %admitted.name, "module loaded"); - } - self.inner - .loaded + #[cfg(test)] + if let Some(barrier) = self + .inner + .after_loaded_publish .lock() - .expect("module list lock") - .push(LoadedModule { - info: admitted.clone(), - transport, - unique_name: unique, - transition_from: None, - descriptor_info, - lifecycle_busy: false, - callback_config: None, - callback_state: None, - _callback_binding: None, - }); + .expect("loaded publication barrier lock") + .take() + { + barrier + .reached + .send(()) + .expect("test observes loaded publication barrier"); + barrier + .release + .recv() + .expect("test releases loaded publication barrier"); + } Ok(admitted) } @@ -1372,12 +1904,15 @@ impl ModuleHost { tracing::warn!(module = %sanitize_untrusted(&manifest.module.name), "module rustc differs from host"); } - let name = sanitized_field(&descriptor.module_name) - .ok_or_else(|| refuse("descriptor identity is invalid"))?; + let descriptor_name = std::str::from_utf8(field_bytes(&descriptor.module_name)) + .map_err(|_| refuse("descriptor identity is invalid"))?; + if !canonical_module_id(&manifest.module.name) || descriptor_name != manifest.module.name { + return Err(refuse("manifest identity does not match descriptor")); + } + let name = sanitize_untrusted(descriptor_name); let version = sanitized_field(&descriptor.module_version) .ok_or_else(|| refuse("descriptor identity is invalid"))?; if manifest.schema != MANIFEST_SCHEMA - || sanitize_untrusted(&manifest.module.name) != name || sanitize_untrusted(&manifest.module.version.to_string()) != version || manifest.module.name.len() > 64 || manifest.module.version.to_string().len() > 32 @@ -1612,16 +2147,17 @@ impl ModuleControl for ModuleHostInner { (module.transport.clone(), module.callback_state.clone()) }; let _lifecycle_busy = LifecycleBusyGuard::for_stop(self, name, transport.clone()); - if let Some(callbacks) = callbacks - && let Err(error) = callbacks + if let Some(callbacks) = callbacks { + if let Err(error) = callbacks .drain(deadline.saturating_sub(started.elapsed())) .await - { - let mut loaded = self.loaded.lock().expect("module list lock"); - if let Some(module) = loaded.iter_mut().find(|module| module.info.name == name) { - module.transition_from = None; + { + let mut loaded = self.loaded.lock().expect("module list lock"); + if let Some(module) = loaded.iter_mut().find(|module| module.info.name == name) { + module.transition_from = None; + } + return Err(error); } - return Err(error); } let stopped = transport .stop(deadline.saturating_sub(started.elapsed())) @@ -1683,7 +2219,11 @@ impl ModuleControl for ModuleHostInner { "module lifecycle operation is already running", )); } - if !matches!(state, ModuleState::Ready | ModuleState::Serving) { + let deferred_initialization = matches!(state, ModuleState::Resolved) + && module.transport.has_deferred_initializer(); + if !matches!(state, ModuleState::Ready | ModuleState::Serving) + && !deferred_initialization + { return Err(Error::ModuleUnavailable { module: module.info.name.clone(), state: state_name(&state).to_string(), @@ -1757,10 +2297,13 @@ impl ModuleControl for ModuleHostInner { .expect("module directory lock") .clone(); if configured.is_empty() { - ModuleHost::search_paths() - .into_iter() - .filter(|path| path.is_dir()) - .collect() + ModuleHost { + inner: Arc::clone(&self), + } + .search_paths() + .into_iter() + .filter(|path| path.is_dir()) + .collect() } else { configured } @@ -1804,11 +2347,11 @@ impl ModuleControl for ModuleHostInner { let module = loaded .iter_mut() .find(|module| &module.unique_name == unique_name)?; - if !module.transport.is_faulted() - && let Some(old) = module.transition_from.take() - { - module.info.state = ModuleState::Stopped; - return Some((module.info.name.clone(), old, ModuleState::Stopped)); + if !module.transport.is_faulted() { + if let Some(old) = module.transition_from.take() { + module.info.state = ModuleState::Stopped; + return Some((module.info.name.clone(), old, ModuleState::Stopped)); + } } if !module.transport.is_faulted() || matches!( @@ -1957,8 +2500,7 @@ fn provisional_info(path: &Path, manifest: &ModuleManifest) -> Result 64 || manifest.module.version.to_string().len() > 32 @@ -1986,16 +2528,29 @@ fn loaded_identity( descriptor: &TbAbiDescriptor, manifest: &ModuleManifest, ) -> Option<(String, String)> { - let name = sanitized_field(&descriptor.module_name)?; + let descriptor_name = std::str::from_utf8(field_bytes(&descriptor.module_name)).ok()?; + let name = sanitize_untrusted(descriptor_name); let version = sanitized_field(&descriptor.module_version)?; (manifest.schema == MANIFEST_SCHEMA - && sanitize_untrusted(&manifest.module.name) == name + && canonical_module_id(&manifest.module.name) + && descriptor_name == manifest.module.name && sanitize_untrusted(&manifest.module.version.to_string()) == version && manifest.module.name.len() <= 64 && manifest.module.version.to_string().len() <= 32) .then_some((name, version)) } +/// Module identifiers are package names, not log-safe display labels. Keep the +/// full validated identifier for authorization while allowing the summary name +/// to remain capped by `sanitize_untrusted`. +fn canonical_module_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'+' | b'-')) +} + fn rejection_info(error: &Error) -> ModuleInfo { let (file, reason) = match error { Error::ModuleRefused { file, reason } => (file.clone(), reason.clone()), diff --git a/crates/tinybus/src/module/host_admission_tests.rs b/crates/tinybus/src/module/host_admission_tests.rs index 06da4cff..164056fc 100644 --- a/crates/tinybus/src/module/host_admission_tests.rs +++ b/crates/tinybus/src/module/host_admission_tests.rs @@ -87,6 +87,7 @@ async fn a_bundle_others_can_write_falls_back_to_the_verified_release_cache() { host_key: "test-host", archive: "clock-test-host.tar.gz", sha256: &pin, + library_sha256: None, }; // The bundle: a marker equal to the pin and an admissible extraction, in diff --git a/crates/tinybus/src/module/host_callbacks.rs b/crates/tinybus/src/module/host_callbacks.rs index a4323439..e8caf463 100644 --- a/crates/tinybus/src/module/host_callbacks.rs +++ b/crates/tinybus/src/module/host_callbacks.rs @@ -318,7 +318,7 @@ impl HostCallbackGuard { /// connection, custom transport, well-known alias, or remote recipient. Drop /// revokes immediately; explicit close also closes the owned local connection. pub struct HostCallbackBinding { - owner: std::sync::Weak, + owner: std::sync::Weak<()>, broker: Broker, connection: Connection, state: Arc, @@ -714,11 +714,7 @@ impl Drop for HostCallbackBinding { // Drop cannot await. Closing the sender synchronously detaches the // owned peer without spawning a task that might never be polled. self.transport.close_outbound(); - self.broker - .router - .lock() - .expect("router lock") - .detach_unique(&self.state.destination); + self.broker.detach_unique(&self.state.destination); } } @@ -825,7 +821,7 @@ impl ModuleHost { || !binding .owner .upgrade() - .is_some_and(|owner| Arc::ptr_eq(&owner, &self.inner)) + .is_some_and(|owner| Arc::ptr_eq(&owner, &self.inner.callback_owner)) { return Err(Error::failed( "callback binding belongs to another host owner", @@ -847,23 +843,63 @@ impl ModuleHost { Ok(()) } - /// Whether this exact capability was retained by a successfully loaded module. - pub(crate) fn retains_host_callback_binding(&self, binding: &Arc) -> bool { + /// Whether this exact callback capability is retained by the named module. + pub(crate) fn retains_host_callback_binding( + &self, + bus_name: &str, + binding: &Arc, + ) -> bool { + let peer = { + let state = binding + .state + .lifetime + .lock() + .expect("callback lifetime lock"); + if !state.active || state.revoked || !binding.state.module_live() { + return false; + } + state.module.clone() + }; + let Some(peer) = peer else { + return false; + }; self.inner .loaded .lock() .expect("module list lock") .iter() .any(|module| { - module - ._callback_binding - .as_ref() - .is_some_and(|retained| Arc::ptr_eq(retained, binding)) + module.info.manifest.bus_name.as_str() == bus_name + && module.unique_name == peer + && module + ._callback_binding + .as_ref() + .is_some_and(|retained| Arc::ptr_eq(retained, binding)) }) } - /// Retain the resolver-owned callback capability with the admitted module. - /// The module host is the lifetime owner because it owns the loaded library. + /// Return the live host callback capability retained by the exact loaded + /// bus peer, if it was admitted with one. + pub(crate) fn retained_host_callback_binding( + &self, + bus_name: &str, + ) -> Option> { + let binding = self + .inner + .loaded + .lock() + .expect("module list lock") + .iter() + .find(|module| module.info.manifest.bus_name.as_str() == bus_name)? + ._callback_binding + .as_ref()? + .clone(); + self.retains_host_callback_binding(bus_name, &binding) + .then_some(binding) + } + + /// Retain the resolver-owned callback capability with its exact admitted + /// module, after verifying that the module was initialized through it. pub(crate) fn retain_host_callback_binding( &self, bus_name: &str, @@ -873,7 +909,7 @@ impl ModuleHost { || !binding .owner .upgrade() - .is_some_and(|owner| Arc::ptr_eq(&owner, &self.inner)) + .is_some_and(|owner| Arc::ptr_eq(&owner, &self.inner.callback_owner)) { return Err(Error::failed( "callback binding belongs to another host owner", @@ -1053,7 +1089,7 @@ impl ModuleHost { .ok_or_else(|| Error::failed("callback connection has no unique name"))?; rollback.committed = true; Ok(HostCallbackBinding { - owner: Arc::downgrade(&self.inner), + owner: Arc::downgrade(&self.inner.callback_owner), broker: self.inner.broker.clone(), connection, transport, @@ -1102,7 +1138,7 @@ impl ModuleHost { config: serde_json::Value, binding: &HostCallbackBinding, ) -> Result { - self.load_file_with_host_callbacks_matching(path, config, binding, None) + self.load_file_with_host_callbacks_checked(path, config, binding, None, None, None) .await } @@ -1118,27 +1154,82 @@ impl ModuleHost { expected_bus_name: &str, expected_object_path: &str, ) -> Result { - self.load_file_with_host_callbacks_matching( + self.load_file_with_host_callbacks_checked( path, config, binding, Some((expected_bus_name, expected_object_path)), + None, + None, + ) + .await + } + + /// Load a checksum-verified cached release with callbacks bound to the + /// exact registry identity before initialization. + pub(crate) async fn load_github_release_cached_with_host_callbacks( + &self, + release: &super::CachedRelease<'_>, + config: serde_json::Value, + binding: &Arc, + expected_bus_name: &str, + expected_object_path: &str, + ) -> Result { + let (module, archive_sha256) = crate::module::github::acquire_cached(release)?; + self.load_file_with_host_callbacks_checked( + module, + config, + binding, + Some((expected_bus_name, expected_object_path)), + Some(archive_sha256), + release.expected_library_sha256.map(str::to_ascii_lowercase), ) .await } - async fn load_file_with_host_callbacks_matching( + /// Load an installer-shipped digest marker with callbacks bound to the + /// exact registry identity before initialization. + pub(crate) async fn load_bundled_marked_with_host_callbacks( + &self, + directory: &Path, + asset_name: &str, + pin: &str, + config: serde_json::Value, + binding: &Arc, + expected_identity: (&str, &str), + ) -> Result { + let hit = + crate::module::cache::find_marked(directory, asset_name, pin).ok_or_else(|| { + Error::module_refused( + directory, + "bundled digest marker does not match the pin, or the extraction is unusable", + ) + })?; + self.load_file_with_host_callbacks_checked( + hit.module, + config, + binding, + Some(expected_identity), + None, + None, + ) + .await + } + + async fn load_file_with_host_callbacks_checked( &self, path: impl AsRef, config: serde_json::Value, binding: &HostCallbackBinding, expected_identity: Option<(&str, &str)>, + pinned_sha256: Option, + pinned_library_sha256: Option, ) -> Result { if !Arc::ptr_eq(&self.inner.broker.router, &binding.broker.router) || !binding .owner .upgrade() - .is_some_and(|owner| Arc::ptr_eq(&owner, &self.inner)) + .is_some_and(|owner| Arc::ptr_eq(&owner, &self.inner.callback_owner)) { return Err(Error::failed( "callback binding belongs to another host owner", @@ -1172,21 +1263,36 @@ impl ModuleHost { check_directory(parent)?; } check_file(path)?; - let hash = allowlisted_hash(path, std::fs::File::open(path)?)?.ok_or_else(|| { - Error::module_refused(path, "scoped callbacks require a verified artifact") - })?; - let artifact = loader::load(path, self.inner.strict.load(Ordering::Acquire))?; - if expected_identity.is_some_and(|(bus_name, object_path)| { - artifact.manifest.bus_name.as_str() != bus_name - || artifact.manifest.object_path.as_str() != object_path - }) { + let allowlisted = allowlisted_hash(path, std::fs::File::open(path)?)?; + let hash = match (allowlisted, pinned_sha256.is_some()) { + (Some(hash), _) => hash, + (None, true) => crate::module::hash::file_hex(std::fs::File::open(path)?)?, + (None, false) => { + return Err(Error::module_refused( + path, + "scoped callbacks require a verified artifact", + )); + } + }; + if pinned_library_sha256 + .as_ref() + .is_some_and(|pinned| !pinned.eq_ignore_ascii_case(&hash)) + { return Err(Error::module_refused( path, - "verified module identity does not match the resolver record", + "release library pin disagrees with the local module allowlist", )); } + let library_pin = pinned_library_sha256.unwrap_or_else(|| hash.clone()); + let artifact = loader::load_verified( + path, + self.inner.strict.load(Ordering::Acquire), + Some(&library_pin), + )?; self.ensure_dependencies(&artifact.manifest, path)?; - if allowlisted_hash(path, std::fs::File::open(path)?)?.as_ref() != Some(&hash) { + let current_library = allowlisted_hash(path, std::fs::File::open(path)?)? + .or_else(|| crate::module::hash::file_hex(std::fs::File::open(path).ok()?).ok()); + if current_library.as_ref() != Some(&library_pin) { return Err(Error::module_refused( path, "verified artifact changed during loading", @@ -1195,6 +1301,15 @@ impl ModuleHost { let transport = { let _admission = self.inner.admission.lock().expect("module admission lock"); let admitted = self.validate(path, &artifact.descriptor, &artifact.manifest)?; + if expected_identity.is_some_and(|(bus_name, object_path)| { + admitted.manifest.bus_name.as_str() != bus_name + || admitted.manifest.object_path.as_str() != object_path + }) { + return Err(Error::module_refused( + path, + "artifact identity does not match the requested module", + )); + } if self .inner .loaded @@ -1211,7 +1326,19 @@ impl ModuleHost { .map_err(|_| Error::failed("module configuration is invalid"))?; let fingerprint = callback_config_fingerprint(&bytes); drop(config); - let (transport, host) = ModuleTransport::new(admitted.name.clone(), bytes); + let trusted_host = self.inner.broker.restricted_module_controller_binding(); + let pre_ready_routes = self + .inner + .pre_ready_confidential_routes + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let (transport, host) = ModuleTransport::new_with_controller_binding( + admitted.name.clone(), + bytes, + trusted_host, + pre_ready_routes, + ); transport.bind_host_callbacks(binding.state.clone())?; #[cfg(test)] { @@ -1239,7 +1366,8 @@ impl ModuleHost { lazy_init: true, lazy_load: false, descriptor_info: None, - pinned_sha256: Some(hash), + pinned_sha256: Some(pinned_sha256.unwrap_or_else(|| hash.clone())), + pinned_library_sha256: Some(library_pin), }, )?; rollback.transport = Some(transport.clone()); @@ -1385,9 +1513,6 @@ impl Drop for LoadRollback<'_> { self.binding.transport.close_outbound(); self.binding .broker - .router - .lock() - .expect("router lock") .detach_unique(&self.binding.state.destination); if let Some(transport) = &self.transport { transport.release_initialization(false); @@ -1402,13 +1527,7 @@ impl Drop for LoadRollback<'_> { .map(|module| module.unique_name.clone()) }); if let Some(peer) = peer { - self.host - .inner - .broker - .router - .lock() - .expect("router lock") - .detach_unique(&peer); + self.host.inner.broker.detach_unique(&peer); } // A timed-out initializer may still run. Stop is best effort; // revocation/detach removes authority without claiming cessation. @@ -1455,11 +1574,7 @@ impl Drop for PeerRollback { fn drop(&mut self) { if !self.committed { self.transport.close_outbound(); - self.broker - .router - .lock() - .expect("router lock") - .detach_unique(&self.peer); + self.broker.detach_unique(&self.peer); } } } diff --git a/crates/tinybus/src/module/host_callbacks_tests.rs b/crates/tinybus/src/module/host_callbacks_tests.rs index e3bcaa62..ba043281 100644 --- a/crates/tinybus/src/module/host_callbacks_tests.rs +++ b/crates/tinybus/src/module/host_callbacks_tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::message::Message; use crate::router::{MatchRule, Router}; -use std::sync::atomic::AtomicUsize; +use std::sync::{Arc, atomic::AtomicUsize}; async fn active_stream_binding() -> ( Arc, @@ -52,6 +52,77 @@ fn signal_declaration() -> HostCallbackSignal { members: vec!["WatchChanged".parse().unwrap()], } } + +#[tokio::test] +async fn bundled_callback_loader_rejects_missing_or_wrong_digest_marker() { + let host = ModuleHost::new(Broker::new()); + let binding = Arc::new(host.prepare_host_callbacks(vec![endpoint()]).await.unwrap()); + let directory = tempfile::tempdir().unwrap(); + + let error = host + .load_bundled_marked_with_host_callbacks( + directory.path(), + "demo.tar.gz", + &"a".repeat(64), + serde_json::json!({}), + &binding, + ("org.example.Demo", "/org/example/Demo"), + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("digest marker"), "{error}"); +} + +#[derive(Clone)] +struct StreamCallback; + +#[async_trait] +impl Interface for StreamCallback { + fn name(&self) -> InterfaceName { + "org.example.Callback".parse().unwrap() + } + + fn members(&self) -> Vec { + vec!["Transfer".parse().unwrap()] + } + + async fn call( + &self, + _: &MemberName, + args: serde_json::Value, + ) -> crate::Result { + Ok(args) + } +} + +#[test] +fn required_endpoint_count_deduplicates_a_shared_confidential_and_stream_interface() { + let mut state = state( + "org.example.Module".parse().unwrap(), + "org.example.Host".parse().unwrap(), + ); + Arc::get_mut(&mut state).unwrap().stream_endpoints = vec![stream_endpoint()]; + assert_eq!(state.required_endpoint_count(), 1); +} + +#[test] +fn directed_signal_requires_a_live_module_recipient() { + let state = state( + "org.example.Module".parse().unwrap(), + "org.example.Host".parse().unwrap(), + ); + state.lifetime.lock().unwrap().module = None; + assert!( + state + .signal_destination( + &"/callback".parse().unwrap(), + &"org.example.Callback".parse().unwrap(), + &"WatchChanged".parse().unwrap(), + ) + .is_err() + ); +} fn state(source: BusName, destination: BusName) -> Arc { Arc::new(CallbackState { destination, @@ -100,6 +171,7 @@ async fn reset_requests_receive_distinct_revocable_host_callback_bindings() { crate::attest::Attestation { name: "org.example.ResetTest".parse().unwrap(), sha256: "a".repeat(64), + library_sha256: None, }, ); let context = CallContext::brokered(Some(&module_peer), true); @@ -145,6 +217,7 @@ async fn draining_a_binding_waits_for_running_grandchild_callbacks() { crate::attest::Attestation { name: "org.example.NestedDrainTest".parse().unwrap(), sha256: "a".repeat(64), + library_sha256: None, }, ); let (module_transport, _) = @@ -197,6 +270,7 @@ async fn root_drain_retains_ancestry_for_running_grandchild_after_child_drop() { crate::attest::Attestation { name: "org.example.DescendantAncestryTest".parse().unwrap(), sha256: "a".repeat(64), + library_sha256: None, }, ); let (module_transport, _) = @@ -255,6 +329,7 @@ async fn child_creation_prunes_expired_parent_entries() { crate::attest::Attestation { name: "org.example.ExpiredChildTest".parse().unwrap(), sha256: "a".repeat(64), + library_sha256: None, }, ); let (module_transport, _) = @@ -382,6 +457,7 @@ fn directed_signals_target_only_the_live_bound_module_and_declared_member() { crate::attest::Attestation { name: "org.example.Module".parse().unwrap(), sha256: "a".repeat(64), + library_sha256: None, }, ); let capability = state(module_peer.clone(), binding_peer.clone()); @@ -458,6 +534,7 @@ fn kernel_fixture() -> (Router, Arc, u64, u64) { crate::attest::Attestation { name: "org.example.Module".parse().unwrap(), sha256: "a".repeat(64), + library_sha256: None, }, ); let state = state(source, destination); @@ -500,6 +577,7 @@ fn a_host_alias_and_a_second_verified_module_inherit_no_authority() { crate::attest::Attestation { name: "org.example.Other".parse().unwrap(), sha256: "b".repeat(64), + library_sha256: None, }, ); let mut message = call(&state); @@ -544,6 +622,7 @@ fn unverified_sources_cannot_be_enrolled_and_scopes_are_bounded() { crate::attest::Attestation { name: "org.example.Module".parse().unwrap(), sha256: "a".repeat(64), + library_sha256: None, }, ); router.add_host_callbacks(state.clone()).unwrap(); @@ -556,6 +635,7 @@ fn unverified_sources_cannot_be_enrolled_and_scopes_are_bounded() { crate::attest::Attestation { name: "org.example.Module".parse().unwrap(), sha256: "a".repeat(64), + library_sha256: None, }, ); router @@ -569,6 +649,7 @@ fn unverified_sources_cannot_be_enrolled_and_scopes_are_bounded() { crate::attest::Attestation { name: "org.example.Module".parse().unwrap(), sha256: "a".repeat(64), + library_sha256: None, }, ); let extra = super::tests::state(source, destination); @@ -1203,6 +1284,7 @@ async fn rollback_after_attach_releases_the_actual_initialization_observer() { lazy_load: false, descriptor_info: None, pinned_sha256: None, + pinned_library_sha256: None, }, ) .unwrap(); @@ -2136,3 +2218,84 @@ async fn completing_provider_future_is_destroyed_before_its_running_account() { assert_eq!(observed.load(Ordering::Acquire), 1); assert_eq!(HostCallbackGuard(account).running(), 0); } + +#[tokio::test] +async fn host_stream_callbacks_are_bound_to_the_declared_module_peer() { + let broker = Broker::new(); + let bus = crate::transport::memory::MemoryBus::new(); + let broker_task = broker.spawn(bus.clone()); + let host = ModuleHost::new(broker); + let binding = host + .prepare_host_callbacks_with_stream_endpoints(vec![], vec![stream_endpoint()]) + .await + .unwrap(); + let module = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let module_peer = module.unique_name().unwrap(); + binding + .serve_at("/callback".parse().unwrap(), StreamCallback) + .await + .unwrap(); + { + let mut state = binding.state.lifetime.lock().unwrap(); + state.active = true; + state.module = Some(module_peer.clone()); + } + let context = CallContext::brokered(Some(&module_peer), false); + assert!( + binding + .authorized_stream_module(&CallContext::brokered(Some(&module_peer), true)) + .is_err() + ); + assert!( + binding + .authorized_stream_module(&CallContext::brokered( + Some(&"org.example.Outsider".parse().unwrap()), + false, + )) + .is_err() + ); + let proxy = module + .proxy(binding.unique_name(), "/callback", "org.example.Callback") + .unwrap(); + assert_eq!( + proxy + .call::("Transfer", serde_json::json!({ "ok": true })) + .await + .unwrap(), + serde_json::json!([{ "ok": true }]) + ); + + let mut module_to_host = module + .open_stream(binding.unique_name(), StreamDescriptor::with_len(5)) + .await + .unwrap(); + let mut host_reader = binding + .accept_stream_from_module(&context, &module_to_host.stream_ref()) + .unwrap(); + let send = tokio::spawn(async move { + module_to_host.write(b"hello").await.unwrap(); + module_to_host.finish().await.unwrap(); + }); + assert_eq!(host_reader.read_to_end_capped(5).await.unwrap(), b"hello"); + send.await.unwrap(); + + let mut host_to_module = binding + .open_stream_to_module( + &context, + StreamDescriptor::with_len(5), + Duration::from_secs(2), + ) + .await + .unwrap(); + let mut module_reader = module + .accept_stream_from(&host_to_module.stream_ref(), binding.unique_name()) + .unwrap(); + host_to_module.write(b"world").await.unwrap(); + host_to_module.finish().await.unwrap(); + assert_eq!(module_reader.read_to_end_capped(5).await.unwrap(), b"world"); + + binding.close().await.unwrap(); + broker_task.abort(); +} diff --git a/crates/tinybus/src/module/host_reload_tests.rs b/crates/tinybus/src/module/host_reload_tests.rs index a065e4c2..9f6d69ce 100644 --- a/crates/tinybus/src/module/host_reload_tests.rs +++ b/crates/tinybus/src/module/host_reload_tests.rs @@ -113,6 +113,7 @@ fn observed_native(host: &ModuleHost, path: &Path, prefix: &str) -> usize { lazy_load: false, descriptor_info: None, pinned_sha256: None, + pinned_library_sha256: None, }, ) .unwrap(); @@ -350,6 +351,7 @@ async fn compiled_native_host_stop_and_fault_deny_factory_after_entered_init_bar lazy_load: false, descriptor_info: None, pinned_sha256: None, + pinned_library_sha256: None, }, ) .unwrap(); diff --git a/crates/tinybus/src/module/host_required_tests.rs b/crates/tinybus/src/module/host_required_tests.rs index df86d144..00bdceee 100644 --- a/crates/tinybus/src/module/host_required_tests.rs +++ b/crates/tinybus/src/module/host_required_tests.rs @@ -42,6 +42,7 @@ fn an_unsupported_actual_manifest_is_refused_before_setup() { descriptor: TbAbiDescriptor::current("clock", "0.1.0"), manifest: super::tests::manifest(), init: counted_setup, + library_sha256: None, }; assert!( host.activate_required( @@ -115,6 +116,7 @@ fn the_matching_loaded_artifact_reaches_its_original_initializer() { descriptor: TbAbiDescriptor::current("clock", "0.1.0"), manifest: matching_manifest(), init: counted_setup, + library_sha256: None, }; let error = host .activate_required( @@ -206,10 +208,9 @@ async fn a_real_required_artifact_is_refused_then_admitted_and_serves_confidenti let host = ModuleHost::new(broker); let address = BusName::new("ai.tinyhumans.openhuman.Clock").unwrap(); let path = ObjectPath::new("/ai/tinyhumans/openhuman/Clock").unwrap(); - // The fixture module advertises the crate's own version; follow it so a - // version bump does not invalidate the requirement range. - let crate_major: u64 = env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap(); - let crate_minor: u64 = env!("CARGO_PKG_VERSION_MINOR").parse().unwrap(); + let accepted_interface_versions = Version::parse(env!("CARGO_PKG_VERSION")) + .expect("package version is a valid interface version") + .compatible_series(); let required_for = |method| { ModuleRequirements::new( "tinybus", @@ -218,10 +219,7 @@ async fn a_real_required_artifact_is_refused_then_admitted_and_serves_confidenti vec![ RequiredInterface::new( InterfaceName::new(address.as_str()).unwrap(), - VersionRange::new( - Version::new(crate_major, crate_minor, 0), - Some(Version::new(crate_major, crate_minor + 1, 0)), - ), + accepted_interface_versions.clone(), vec![MemberName::new(method).unwrap()], ) .unwrap(), diff --git a/crates/tinybus/src/module/host_tests.rs b/crates/tinybus/src/module/host_tests.rs index 860b25b8..79fd7c52 100644 --- a/crates/tinybus/src/module/host_tests.rs +++ b/crates/tinybus/src/module/host_tests.rs @@ -3,7 +3,8 @@ use super::*; use crate::Connection; use crate::module::abi::TbAbiDescriptor; -use crate::transport::memory::MemoryBus; +use crate::module::runtime::{ModuleRuntime, RuntimeNames}; +use crate::transport::memory::{MemoryBus, MemoryTransport}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Mutex as StdMutex, OnceLock}; @@ -19,6 +20,13 @@ static BLOCKING_REINIT_RECEIVER: OnceLock() {} + + assert_send_sync::(); +} + #[cfg(unix)] fn make_private_tree(root: &Path, path: &Path) { use std::os::unix::fs::PermissionsExt; @@ -185,6 +193,47 @@ pub(super) fn manifest() -> ModuleManifest { named_manifest("clock", "Clock") } +#[tokio::test] +async fn pre_ready_route_policy_is_bounded_unique_and_fixed_before_admission() { + let host = ModuleHost::new(Broker::new()); + let route = PreReadyConfidentialRoute { + path: ObjectPath::new("/ai/tinyhumans/storage").unwrap(), + interface: crate::InterfaceName::new("ai.tinyhumans.Storage").unwrap(), + member: crate::MemberName::new("GetSchema").unwrap(), + }; + + host.set_pre_ready_confidential_routes(vec![route.clone()]) + .expect("one exact route is valid"); + assert!( + host.set_pre_ready_confidential_routes(vec![route.clone(), route.clone()]) + .is_err() + ); + assert!( + host.set_pre_ready_confidential_routes( + (0..=MAX_PRE_READY_CONFIDENTIAL_ROUTES) + .map(|index| PreReadyConfidentialRoute { + path: ObjectPath::new(format!("/ai/tinyhumans/storage/item{index}")).unwrap(), + interface: crate::InterfaceName::new("ai.tinyhumans.Storage").unwrap(), + member: crate::MemberName::new("GetSchema").unwrap(), + }) + .collect(), + ) + .is_err() + ); + + host.attach_linked_with_config( + LinkedModule { + descriptor: TbAbiDescriptor::current("clock", "0.1.0"), + manifest: manifest(), + init: lazy_echo_init, + init_with_setup: None, + }, + serde_json::json!({}), + ) + .expect("fixture module is admitted"); + assert!(host.set_pre_ready_confidential_routes(Vec::new()).is_err()); +} + fn named_manifest(module_name: &str, surface_name: &str) -> ModuleManifest { ModuleManifest { schema: MANIFEST_SCHEMA, @@ -221,12 +270,39 @@ async fn linked_module_is_admitted_without_a_library_file_and_attested_as_host_c descriptor: TbAbiDescriptor::current("clock", "0.1.0"), manifest: module_manifest, init: lazy_echo_init, + init_with_setup: None, }, serde_json::json!({}), ) .unwrap(); assert_eq!(info.state, ModuleState::Resolved); + // Identity comes from the module's broker-assigned unique peer. A + // well-known alias is only a routing name and must never resolve as a + // module sender. + let unique = host.inner.loaded.lock().unwrap()[0].unique_name.clone(); + let lookup = host.sender_lookup(); + let module_id = host + .module_for_sender(&unique) + .expect("an admitted module resolves from its unique peer"); + assert_eq!(module_id.as_str(), "clock"); + assert!(host.sender_for_module("clock").is_none()); + assert!(lookup.sender_for_module("clock").is_none()); + assert_eq!(host.list()[0].state, ModuleState::Resolved); + assert_eq!( + lookup + .module_for_sender(&unique) + .as_ref() + .map(ModuleId::as_str), + Some("clock") + ); + assert!(host.module_for_sender(&info.manifest.bus_name).is_none()); + assert!( + host.sender_for_module("ai.tinyhumans.module.Clock") + .is_none() + ); + assert!(lookup.module_for_sender(&info.manifest.bus_name).is_none()); + let connection = Connection::connect(bus.connect().await.unwrap()) .await .unwrap(); @@ -240,9 +316,56 @@ async fn linked_module_is_admitted_without_a_library_file_and_attested_as_host_c attestation.sha256, crate::module::sha256_file(executable).unwrap() ); + + let alias: crate::BusName = "ai.tinyhumans.test.ModuleAliasHandover".parse().unwrap(); + let first_owner = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + first_owner.request_name(alias.as_str()).await.unwrap(); + assert!(host.module_for_sender(&alias).is_none()); + drop(first_owner); + let replacement_owner = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + replacement_owner + .request_name(alias.as_str()) + .await + .unwrap(); + assert!(host.module_for_sender(&alias).is_none()); + assert_eq!( + host.module_for_sender(&unique) + .expect("alias handover does not change module identity") + .as_str(), + "clock" + ); + + host.inner + .stop(&info.name, Duration::from_secs(1)) + .await + .unwrap(); + assert!(host.module_for_sender(&unique).is_none()); + assert!(host.sender_for_module("clock").is_none()); + assert!(lookup.module_for_sender(&unique).is_none()); broker_task.abort(); } +#[test] +fn an_owned_sender_lookup_does_not_keep_its_host_alive() { + let host = ModuleHost::new(Broker::new()); + let observed_lifetime = std::sync::Arc::downgrade(&host.inner); + let lookup = host.sender_lookup(); + let peer: crate::BusName = ":1.4242".parse().unwrap(); + + assert_eq!(observed_lifetime.strong_count(), 1); + assert!(lookup.module_for_sender(&peer).is_none()); + assert!(lookup.sender_for_module("clock").is_none()); + drop(host); + + assert_eq!(observed_lifetime.strong_count(), 0); + assert!(lookup.module_for_sender(&peer).is_none()); + assert!(lookup.sender_for_module("clock").is_none()); +} + #[test] fn linked_module_with_an_unmet_required_interface_is_refused_before_init() { let host = ModuleHost::new(Broker::new()); @@ -263,6 +386,7 @@ fn linked_module_with_an_unmet_required_interface_is_refused_before_init() { descriptor: TbAbiDescriptor::current("clock", "0.1.0"), manifest: module_manifest, init: init_that_must_not_run, + init_with_setup: None, }, serde_json::json!({}), ) @@ -565,7 +689,7 @@ async fn a_lazy_manifest_registers_an_unmapped_library_and_the_first_call_loads_ let discovered = read_lazy_manifest(&artifact).unwrap().unwrap(); // No pin: this stands in for a directory scan, which vouches for an // artifact with the `modules.toml` beside it or not at all. - host.register_lazy(&artifact, discovered, serde_json::json!({}), None) + host.register_lazy(&artifact, discovered, serde_json::json!({}), None, None) .unwrap() }; assert_eq!(info.state, ModuleState::Resolved); @@ -594,15 +718,19 @@ async fn a_lazy_manifest_registers_an_unmapped_library_and_the_first_call_loads_ /// real library, as above, so admission runs without mapping anything. #[cfg(not(windows))] #[tokio::test] -async fn a_bundle_marker_equal_to_the_pin_admits_the_extraction_without_its_archive() { +async fn a_bundle_marker_uses_the_post_sign_allowlist_when_the_release_library_pin_differs() { use crate::module::{ReleaseAsset, ReleasePlan, artifact_dir, load_first_admitted}; let bundled = tempfile::tempdir().unwrap(); let user_cache = tempfile::tempdir().unwrap(); + // Code signing can rewrite the bundled library after the release archive + // was hashed. The sealed local allowlist carries the final library digest. + let release_library_sha256 = "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"; let asset = ReleaseAsset { host_key: "macos-15-arm64", archive: "clock-macos-15-arm64.tar.gz", sha256: "abababababababababababababababababababababababababababababababab", + library_sha256: Some(release_library_sha256), }; let dir = artifact_dir(bundled.path(), "clock", "0.1.0", asset.host_key).unwrap(); std::fs::create_dir_all(&dir).unwrap(); @@ -663,11 +791,14 @@ async fn a_bundle_marker_equal_to_the_pin_admits_the_extraction_without_its_arch .await .unwrap() .expect("the allowlisted extraction is attested"); + let signed_library_sha256 = crate::module::sha256_file(&artifact).unwrap(); + assert_eq!(attestation.sha256, signed_library_sha256); assert_eq!( - attestation.sha256, - crate::module::sha256_file(&artifact).unwrap() + attestation.library_sha256.as_deref(), + Some(signed_library_sha256.as_str()) ); assert_ne!(attestation.sha256, asset.sha256); + assert_ne!(attestation.library_sha256.as_deref(), asset.library_sha256); broker_task.abort(); } @@ -682,6 +813,7 @@ async fn a_bundle_marker_without_an_allowlist_is_refused_rather_than_loaded_unha host_key: "macos-15-arm64", archive: "clock-macos-15-arm64.tar.gz", sha256: "abababababababababababababababababababababababababababababababab", + library_sha256: None, }; let dir = artifact_dir(bundled.path(), "clock", "0.1.0", asset.host_key).unwrap(); std::fs::create_dir_all(&dir).unwrap(); @@ -1942,20 +2074,6 @@ fn module_host_helpers_preserve_safe_names_states_and_allowlist_decisions() { }))); assert!(!has_library_extension(Path::new("clock.txt"))); - #[cfg(windows)] - let _local_app_data = { - let directory = tempfile::tempdir().unwrap(); - unsafe { std::env::set_var("LOCALAPPDATA", directory.path()) }; - directory - }; - let search_paths = ModuleHost::search_paths(); - assert!( - search_paths - .iter() - .any(|path| path.ends_with("openhuman/modules")) - ); - let _ = ModuleHost::new(Broker::new()).load_search_paths(); - let root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); let directory = root.path().join("private"); // Protect the artifact parent before files inherit the checkout's DACL. @@ -2397,7 +2515,7 @@ async fn a_module_from_a_pinned_release_becomes_an_attested_recipient_without_an broker.spawn(bus.clone()); let host = ModuleHost::new(broker.clone()); let info = host - .load_file_pinned(&staged, serde_json::json!({}), Some(pinned.clone())) + .load_file_pinned(&staged, serde_json::json!({}), Some(pinned.clone()), None) .unwrap(); let client = Connection::connect(bus.connect().await.unwrap()) @@ -2489,12 +2607,407 @@ async fn a_real_cdylib_authenticates_incoming_calls_and_its_host_callback() { authenticates_native_context("TINYBUS_TEST_CONTEXT_MODULE").await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires TINYBUS_TEST_CONFIGURED_CONTEXT_MODULE to point at the built cdylib"] +async fn a_configured_context_cdylib_preserves_binding_through_reinitialize() { + let artifact = + PathBuf::from(std::env::var_os("TINYBUS_TEST_CONFIGURED_CONTEXT_MODULE").unwrap()); + let hash = crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); + let (_dir, artifact) = staged_module(&artifact, &hash); + + let broker = Broker::new_restricted_module_control(); + let (broker_transport, controller_transport) = MemoryTransport::pair(); + let selected_controller = broker + .attach_module_controller(Arc::new(broker_transport)) + .unwrap(); + let controller = Connection::connect(Box::new(controller_transport)) + .await + .unwrap(); + assert_eq!( + controller.unique_name().as_ref(), + Some(&selected_controller) + ); + + let modules = ModuleHost::new(broker.clone()); + let info = modules.load_file(&artifact).unwrap(); + modules + .ensure_ready(info.manifest.bus_name.as_str()) + .await + .expect("configured setup receives the selected controller context"); + let proxy = controller + .proxy( + info.manifest.bus_name.as_str(), + "/context", + "org.example.Context", + ) + .unwrap(); + assert!(proxy.attestation().await.unwrap().is_some()); + assert_eq!( + proxy.call::("Configuration", ()).await.unwrap(), + "initial" + ); + let initial: serde_json::Value = proxy.call_confidential("TrustedContext", ()).await.unwrap(); + assert_eq!(initial["authorized"], true); + assert_eq!(initial["previousAuthorized"], false); + + modules + .reinitialize( + info.manifest.module.name.as_str(), + serde_json::json!({"marker": "reconfigured"}), + ) + .await + .expect("reconfigure receives a fresh context with the same binding"); + assert_eq!( + proxy.call::("Configuration", ()).await.unwrap(), + "reconfigured" + ); + let after_reinitialize: serde_json::Value = + proxy.call_confidential("TrustedContext", ()).await.unwrap(); + assert_eq!(after_reinitialize["authorized"], true); + + controller.close().await.unwrap(); + let (other_server, other_transport) = MemoryTransport::pair(); + broker.attach(Arc::new(other_server)); + let other = Connection::connect(Box::new(other_transport)) + .await + .unwrap(); + let other_proxy = other + .proxy( + info.manifest.bus_name.as_str(), + "/context", + "org.example.Context", + ) + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let authorization: serde_json::Value = other_proxy + .call_confidential("TrustedContext", ()) + .await + .expect("the attested module remains reachable by another peer"); + if authorization["previousAuthorized"] == false { + assert_eq!(authorization["authorized"], false); + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("the previously selected controller's binding is revoked on disconnect"); + + // Exercise the same context-bearing artifact through TinyBus's public + // process-lifetime runtime, not only through a hand-built ModuleHost. + let runtime = ModuleRuntime::start(&RuntimeNames::default(), None).unwrap(); + let (_runtime_dir, runtime_artifact) = staged_module(&artifact, &hash); + let runtime_info = runtime.host().load_file(&runtime_artifact).unwrap(); + runtime + .host() + .ensure_ready(runtime_info.manifest.bus_name.as_str()) + .await + .expect("public ModuleRuntime provides the selected trusted controller"); + let runtime_proxy = runtime + .connection() + .proxy( + runtime_info.manifest.bus_name.as_str(), + "/context", + "org.example.Context", + ) + .unwrap(); + assert!(runtime_proxy.attestation().await.unwrap().is_some()); + let initial: serde_json::Value = runtime_proxy + .call_confidential("TrustedContext", ()) + .await + .unwrap(); + assert_eq!(initial["authorized"], true); + assert_eq!(initial["previousAuthorized"], false); + runtime + .host() + .reinitialize( + runtime_info.manifest.module.name.as_str(), + serde_json::json!({"marker": "runtime-reconfigured"}), + ) + .await + .expect("public runtime reinitialization retains the selected host binding"); + let after_reinitialize: serde_json::Value = runtime_proxy + .call_confidential("TrustedContext", ()) + .await + .unwrap(); + assert_eq!(after_reinitialize["authorized"], true); + assert_eq!(after_reinitialize["previousAuthorized"], false); + + // An ordinary linked module still starts through the same public runtime. + let mut ordinary_manifest = manifest(); + ordinary_manifest.lazy_init = true; + let ordinary_info = runtime + .host() + .attach_linked_with_config( + crate::module::LinkedModule { + descriptor: TbAbiDescriptor::current("clock", "0.1.0"), + manifest: ordinary_manifest, + init: lazy_echo_init, + init_with_setup: None, + }, + serde_json::json!({}), + ) + .unwrap(); + runtime + .host() + .ensure_ready(ordinary_info.manifest.bus_name.as_str()) + .await + .expect("ordinary setup remains compatible with a selected runtime host"); + let ordinary_proxy = runtime + .connection() + .proxy( + ordinary_info.manifest.bus_name.as_str(), + ordinary_info.manifest.object_path.as_str(), + ordinary_info.manifest.bus_name.as_str(), + ) + .unwrap(); + let echoed: String = ordinary_proxy + .call("Echo", ("ordinary-runtime".to_owned(),)) + .await + .unwrap(); + assert_eq!(echoed, "ordinary-runtime"); +} + +async fn contextual_factory_cdylib_instance( + artifact: &Path, + restricted: bool, +) -> (ModuleHost, Broker, Connection, crate::Proxy, String) { + let broker = Broker::new_restricted_module_control(); + let (broker_transport, controller_transport) = MemoryTransport::pair(); + let selected_controller = broker + .attach_module_controller(Arc::new(broker_transport)) + .unwrap(); + let controller = Connection::connect(Box::new(controller_transport)) + .await + .unwrap(); + assert_eq!( + controller.unique_name().as_ref(), + Some(&selected_controller) + ); + + let modules = ModuleHost::new(broker.clone()); + let info = modules + .load_file_with_config(artifact, serde_json::json!({"restricted":restricted})) + .unwrap(); + modules + .ensure_ready(info.manifest.bus_name.as_str()) + .await + .expect("compiled context factory must receive the selected controller binding"); + let proxy = controller + .proxy( + info.manifest.bus_name.as_str(), + "/context_factory", + "org.example.ContextFactory", + ) + .unwrap(); + ( + modules, + broker, + controller, + proxy, + info.manifest.module.name, + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires TINYBUS_TEST_CONTEXT_FACTORY_MODULE to point at the built cdylib"] +async fn a_context_factory_cdylib_keeps_instance_state_and_refreshes_trusted_context() { + let artifact = PathBuf::from( + std::env::var_os("TINYBUS_TEST_CONTEXT_FACTORY_MODULE") + .expect("TINYBUS_TEST_CONTEXT_FACTORY_MODULE"), + ); + let hash = crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); + let (_staging, staged) = staged_module(&artifact, &hash); + + let ( + (first, broker, controller, first_proxy, module_name), + (_second, _, _, second_proxy, second_module_name), + ) = tokio::join!( + contextual_factory_cdylib_instance(&staged, true), + contextual_factory_cdylib_instance(&staged, false), + ); + assert_eq!(module_name, second_module_name); + assert!(first_proxy.attestation().await.unwrap().is_some()); + assert!( + first_proxy + .call::("Inspect", ()) + .await + .is_err() + ); + let first_initial: serde_json::Value = + first_proxy.call_confidential("Inspect", ()).await.unwrap(); + assert_eq!(first_initial["restricted"], true); + assert_eq!(first_initial["generation"], 1); + assert!(first_initial["factory_id"].as_u64().is_some()); + + let second_initial: serde_json::Value = + second_proxy.call_confidential("Inspect", ()).await.unwrap(); + assert_eq!(second_initial["restricted"], false); + assert_eq!(second_initial["generation"], 1); + + let (outsider_server, outsider_client) = MemoryTransport::pair(); + broker.attach(Arc::new(outsider_server)); + let outsider = Connection::connect(Box::new(outsider_client)) + .await + .unwrap(); + let outsider_proxy = outsider + .proxy( + first_proxy.destination().as_str(), + "/context_factory", + "org.example.ContextFactory", + ) + .unwrap(); + let denied = outsider_proxy + .call_confidential::("Inspect", serde_json::json!("malformed")) + .await + .unwrap_err(); + assert!( + denied + .to_string() + .contains("factory context authority unavailable") + ); + let selected_malformed = first_proxy + .call_confidential::("Inspect", serde_json::json!("malformed")) + .await + .unwrap_err(); + assert!( + selected_malformed + .to_string() + .contains("expected no arguments") + ); + + first + .reinitialize(&module_name, serde_json::json!({"restricted":true})) + .await + .expect("same-mode reinitialization should use a fresh trusted context"); + let after_reinitialize: serde_json::Value = + first_proxy.call_confidential("Inspect", ()).await.unwrap(); + assert_eq!(after_reinitialize["restricted"], true); + assert_eq!(after_reinitialize["generation"], 2); + assert_eq!( + after_reinitialize["factory_id"], + first_initial["factory_id"] + ); + + assert!( + first + .reinitialize(&module_name, serde_json::json!({"restricted":false})) + .await + .is_err() + ); + let after_refusal: serde_json::Value = + first_proxy.call_confidential("Inspect", ()).await.unwrap(); + assert_eq!(after_refusal, after_reinitialize); + let second_still_isolated: serde_json::Value = + second_proxy.call_confidential("Inspect", ()).await.unwrap(); + assert_eq!(second_still_isolated["restricted"], false); + assert_eq!(second_still_isolated["generation"], 1); + controller.close().await.unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires TINYBUS_TEST_CONTEXT_EAGER_MODULE to point at the built cdylib"] async fn an_eager_cdylib_authenticates_after_its_worker_runs_before_admission() { authenticates_native_context("TINYBUS_TEST_CONTEXT_EAGER_MODULE").await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires TINYBUS_TEST_MIGRATION_MODULE to point at the built cdylib"] +async fn a_compiled_migration_module_negotiates_before_ordinary_lease_registration() { + let artifact = PathBuf::from( + std::env::var_os("TINYBUS_TEST_MIGRATION_MODULE").expect("TINYBUS_TEST_MIGRATION_MODULE"), + ); + let hash = crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); + let (_dir, artifact) = staged_module(&artifact, &hash); + + let broker = Broker::new_restricted_module_control(); + let (broker_transport, controller_transport) = MemoryTransport::pair(); + let selected_controller = broker + .attach_module_controller(Arc::new(broker_transport)) + .unwrap(); + let controller = Connection::connect(Box::new(controller_transport)) + .await + .unwrap(); + assert_eq!( + controller.unique_name().as_ref(), + Some(&selected_controller) + ); + let modules = ModuleHost::new(broker.clone()); + let info = modules.load_file(artifact).unwrap(); + + modules + .ensure_ready(info.manifest.bus_name.as_str()) + .await + .expect("compiled migration setup must receive the selected controller binding"); + let proxy = controller + .proxy( + info.manifest.bus_name.as_str(), + "/migration", + "org.example.TrustedMigration", + ) + .unwrap(); + assert!(proxy.attestation().await.unwrap().is_some()); + + let (other_server, other_client) = MemoryTransport::pair(); + broker.attach(Arc::new(other_server)); + let other = Connection::connect(Box::new(other_client)).await.unwrap(); + let other_proxy = other + .proxy( + info.manifest.bus_name.as_str(), + "/migration", + "org.example.TrustedMigration", + ) + .unwrap(); + assert!(other_proxy.attestation().await.unwrap().is_some()); + assert!(proxy.call::("Schema", ()).await.is_err()); + assert!( + other_proxy + .call_confidential::("Schema", ()) + .await + .is_err(), + "recipient attestation alone must not authorize a different sender" + ); + assert!( + other_proxy + .call_confidential::("Transform", ("attacker-id",)) + .await + .is_err(), + "a denied caller cannot alter the migration transcript state" + ); + assert!( + other_proxy + .call_confidential::("RegisterOrdinaryLease", ()) + .await + .is_err(), + "a denied caller cannot register the ordinary lease" + ); + let forged_identity = serde_json::json!({ + "authenticated_sender": controller.unique_name(), + "is_confidential": true + }); + assert!( + other_proxy + .call_confidential::("Schema", (forged_identity,)) + .await + .is_err(), + "JSON claims cannot replace the broker-stamped sender" + ); + let schema: serde_json::Value = proxy.call_confidential("Schema", ()).await.unwrap(); + assert_eq!(schema["required"], serde_json::json!(["id"])); + let transformed: serde_json::Value = proxy + .call_confidential("Transform", ("legacy-id",)) + .await + .unwrap(); + assert_eq!(transformed["id"], "record-legacy-id"); + assert_eq!(transformed["ordinary_lease_registered"], false); + let lease_registered: bool = proxy + .call_confidential("RegisterOrdinaryLease", ()) + .await + .unwrap(); + assert!(lease_registered); +} + async fn authenticates_native_context(variable: &str) { let artifact = PathBuf::from(std::env::var_os(variable).expect(variable)); let hash = crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); @@ -2523,7 +3036,8 @@ async fn authenticates_native_context(variable: &str) { proxy.attestation().await.unwrap(), Some(crate::attest::Attestation { name: info.manifest.bus_name.clone(), - sha256: hash + sha256: hash.clone(), + library_sha256: Some(hash), }) ); let sender: crate::BusName = proxy.call_confidential("Inspect", ()).await.unwrap(); @@ -2579,6 +3093,7 @@ async fn fixed_unique_module_calls_are_refused_in_terminal_states_before_detach( crate::attest::Attestation { name: manifest().bus_name, sha256: "a".repeat(64), + library_sha256: None, }, ); let client = Connection::connect(bus.connect().await.unwrap()) @@ -2837,12 +3352,14 @@ async fn eager_native_setup_callbacks_are_bound_before_ready_and_failures_rollba .unwrap(); let calls = Arc::new(AtomicUsize::new(0)); let guard = binding.guard(); + let sender_lookup = modules.sender_lookup(); binding .serve_at( "/callback".parse().unwrap(), BoundNativeCallback { calls: calls.clone(), guard: guard.clone(), + sender_lookup, }, ) .await @@ -2920,9 +3437,263 @@ async fn eager_native_setup_callbacks_are_bound_before_ready_and_failures_rollba } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires TINYBUS_TEST_HOST_CALLBACK_MODULE to point at the built cdylib"] +async fn an_eager_setup_callback_resolves_its_admitted_module_before_ready() { + let artifact = PathBuf::from(std::env::var_os("TINYBUS_TEST_HOST_CALLBACK_MODULE").unwrap()); + let hash = crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); + let (_dir, artifact) = staged_module(&artifact, &hash); + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let modules = ModuleHost::new(broker); + let callback = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let (observed_tx, observed_rx) = tokio::sync::oneshot::channel(); + callback + .serve_at( + "/callback".parse().unwrap(), + EagerSetupSenderLookup { + lookup: modules.sender_lookup(), + observed: std::sync::Mutex::new(Some(observed_tx)), + }, + ) + .await + .unwrap(); + + let (reached_tx, reached_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + *modules + .inner + .after_loaded_publish + .lock() + .expect("loaded publication barrier lock") = Some(LoadedPublishBarrier { + reached: reached_tx, + release: release_rx, + }); + let config = serde_json::json!({ + "host": callback.unique_name(), + "fail": false, + "confidential": false + }); + let loading_modules = ModuleHost { + inner: modules.inner.clone(), + }; + let mut loading = tokio::task::spawn_blocking(move || { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap() + .block_on(async { loading_modules.load_file_with_config(artifact, config) }) + }); + + let reached = tokio::task::spawn_blocking(move || { + reached_rx + .recv_timeout(Duration::from_secs(30)) + .expect("attach did not reach the pre-publication barrier"); + }); + tokio::select! { + result = &mut loading => panic!("load completed before reaching the publication barrier: {result:?}"), + result = reached => result.expect("publication barrier observer panicked"), + } + let observation = tokio::select! { + result = &mut loading => panic!("load completed before its eager callback was observed: {result:?}"), + result = tokio::time::timeout(Duration::from_secs(30), observed_rx) => result, + }; + release_tx + .send(()) + .expect("release the attachment after observing callback identity"); + let observed = observation + .expect("eager setup did not call the authenticated host callback") + .expect("host callback dropped its observation"); + loading.await.unwrap().expect("eager module loads"); + + assert_eq!( + observed.as_ref().map(crate::module::ModuleId::as_str), + Some("tinybus"), + "the authenticated callback must resolve the exact admitted module before READY" + ); + broker_task.abort(); +} + +struct PanickingStagedStartSubscriber { + host: Arc, + peer: Arc>>, +} + +impl tracing::Subscriber for PanickingStagedStartSubscriber { + fn enabled(&self, _: &tracing::Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + + fn event(&self, event: &tracing::Event<'_>) { + if *event.metadata().level() == tracing::Level::DEBUG + && event.metadata().target().ends_with("broker") + { + let peer = self + .host + .loaded + .lock() + .unwrap() + .last() + .expect("staged module row is published before broker start") + .unique_name + .clone(); + *self.peer.lock().unwrap() = Some(peer); + panic!("test subscriber panics while staged dispatch starts"); + } + } + + fn enter(&self, _: &tracing::span::Id) {} + + fn exit(&self, _: &tracing::span::Id) {} +} + +#[test] +fn a_staged_start_unwind_removes_its_exact_published_peer() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "module::host::tests::staged_start_unwind_child_process", + "--test-threads=1", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("1 passed;"), + "isolated staged-start unwind check failed or was not selected: {}{}", + stdout, + String::from_utf8_lossy(&output.stderr), + ); +} + +#[test] +fn staged_start_unwind_child_process() { + if !std::env::args() + .any(|argument| argument == "module::host::tests::staged_start_unwind_child_process") + { + return; + } + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(staged_start_unwind_case()); +} + +async fn staged_start_unwind_case() { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = ModuleHost::new(broker); + let observer = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let peer = Arc::new(StdMutex::new(None)); + let mut manifest = named_manifest("staged-start-panic", "StagedStartPanic"); + manifest.lazy_init = true; + + let result = tracing::subscriber::with_default( + PanickingStagedStartSubscriber { + host: host.inner.clone(), + peer: peer.clone(), + }, + || { + host.attach_linked_with_config( + LinkedModule { + descriptor: TbAbiDescriptor::current("staged-start-panic", "0.1.0"), + manifest, + init: lazy_echo_init, + init_with_setup: None, + }, + serde_json::json!({}), + ) + }, + ); + assert!( + result.is_err(), + "a failed dispatch start must refuse attachment" + ); + let unique = peer + .lock() + .unwrap() + .clone() + .expect("peer observed at panic"); + assert!( + host.inner.loaded.lock().unwrap().is_empty(), + "failed start removes the published row" + ); + assert!( + host.module_for_sender(&unique).is_none(), + "failed start revokes lookup authority for the exact peer" + ); + assert_eq!( + observer + .name_owner("ai.tinyhumans.module.StagedStartPanic") + .await + .unwrap(), + None, + "failed start detaches its reserved bus name" + ); + broker_task.abort(); +} + +struct EagerSetupSenderLookup { + lookup: crate::module::ModuleSenderLookup, + observed: + std::sync::Mutex>>>, +} + +#[async_trait::async_trait] +impl crate::Interface for EagerSetupSenderLookup { + fn name(&self) -> crate::InterfaceName { + "org.example.Callback".parse().unwrap() + } + + fn members(&self) -> Vec { + vec!["Observe".parse().unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Err(Error::failed("callback requires authenticated dispatch")) + } + + async fn call_with_context( + &self, + _: &crate::MemberName, + _: serde_json::Value, + context: &crate::CallContext, + ) -> crate::Result { + let sender = context + .authenticated_sender() + .ok_or_else(|| Error::failed("callback sender is unauthenticated"))?; + let module = self.lookup.module_for_sender(sender); + if let Some(observed) = self.observed.lock().unwrap().take() { + let _ = observed.send(module); + } + Ok(serde_json::json!(sender)) + } +} + struct BoundNativeCallback { calls: Arc, guard: crate::module::HostCallbackGuard, + sender_lookup: crate::module::ModuleSenderLookup, } #[async_trait::async_trait] impl crate::Interface for BoundNativeCallback { @@ -2953,6 +3724,16 @@ impl crate::Interface for BoundNativeCallback { self.guard.authorizes(context), "peer must be pinned before eager callback" ); + assert!( + self.sender_lookup + .module_for_sender( + context + .authenticated_sender() + .expect("authenticated sender") + ) + .is_some(), + "the admitted module identity must be published before its eager setup callback" + ); self.calls.fetch_add(1, Ordering::SeqCst); Ok(serde_json::json!(context.authenticated_sender().unwrap())) } @@ -3273,6 +4054,7 @@ fn attach_controlled_native( lazy_load: false, descriptor_info: None, pinned_sha256: None, + pinned_library_sha256: None, }, ) } @@ -3447,3 +4229,97 @@ fn search_skip_matches_file_stems_only() { )); assert!(!is_search_skipped(Path::new("/m/libclock.so"), &[])); } + +#[test] +fn an_explicit_search_path_config_preserves_env_first_order_without_product_defaults() { + let root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let env_dir = root.path().join("operator-modules"); + std::fs::create_dir(&env_dir).unwrap(); + let env_var = "TINYBUS_TEST_MODULE_SEARCH_PATHS"; + unsafe { std::env::set_var(env_var, &env_dir) }; + let app_dir = PathBuf::from("tinydemo/modules"); + + let host = ModuleHost::new(Broker::new()) + .with_search_path_config(SearchPathConfig::new(env_var, app_dir.clone())); + let paths = host.search_paths(); + unsafe { std::env::remove_var(env_var) }; + + let mut expected = vec![env_dir]; + #[cfg(target_os = "linux")] + { + if let Some(path) = std::env::var_os("XDG_DATA_HOME") { + expected.push(PathBuf::from(path).join(&app_dir)); + } else if let Some(path) = std::env::var_os("HOME") { + expected.push(PathBuf::from(path).join(".local/share").join(&app_dir)); + } + expected.push(PathBuf::from("/usr/lib").join(&app_dir)); + } + #[cfg(target_os = "macos")] + expected.push(PathBuf::from("/usr/local/lib").join(&app_dir)); + #[cfg(windows)] + if let Some(path) = std::env::var_os("LOCALAPPDATA") { + expected.push(PathBuf::from(path).join(&app_dir)); + } + + assert_eq!(paths, expected); + assert!(!paths.iter().any(|path| path.ends_with("openhuman/modules"))); +} + +#[test] +fn a_host_without_search_path_config_has_no_implicit_search_paths() { + let host = ModuleHost::new(Broker::new()); + + assert!(host.search_paths().is_empty()); +} + +#[test] +fn rescan_with_no_explicit_paths_uses_the_host_search_path_config() { + let root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let directory = root.path().join("rescan-modules"); + super::super::windows_acl::create_private_dir_all(&directory).unwrap(); + let file_name = if cfg!(windows) { + "rescan-invalid.dll" + } else if cfg!(target_os = "macos") { + "rescan-invalid.dylib" + } else { + "rescan-invalid.so" + }; + std::fs::write(directory.join(file_name), b"not a shared library").unwrap(); + let env_var = "TINYBUS_TEST_RESCAN_SEARCH_PATH"; + unsafe { std::env::set_var(env_var, &directory) }; + let host = ModuleHost::new(Broker::new()) + .with_search_path_config(SearchPathConfig::new(env_var, "tinybus-rescan/modules")); + + let (modules, transitions) = host.inner.clone().rescan(Vec::new(), true).unwrap(); + unsafe { std::env::remove_var(env_var) }; + + assert!(transitions.is_empty()); + assert!( + matches!(modules.as_slice(), [module] if matches!(module.state, ModuleState::Rejected { .. })) + ); +} + +#[test] +fn load_search_paths_except_skips_an_artifact_before_opening_it() { + let root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let directory = root.path().join("private-modules"); + super::super::windows_acl::create_private_dir_all(&directory).unwrap(); + let file_name = if cfg!(windows) { + "skip-before-open.dll" + } else if cfg!(target_os = "macos") { + "skip-before-open.dylib" + } else { + "skip-before-open.so" + }; + // Invalid bytes make admission fail if the skip filter opens the artifact. + std::fs::write(directory.join(file_name), b"not a shared library").unwrap(); + let env_var = "TINYBUS_TEST_SKIP_SEARCH_PATH"; + unsafe { std::env::set_var(env_var, &directory) }; + let host = ModuleHost::new(Broker::new()) + .with_search_path_config(SearchPathConfig::new(env_var, "tinybus-test/modules")); + + let outcomes = host.load_search_paths_except(&["skip-before-open".to_string()]); + unsafe { std::env::remove_var(env_var) }; + + assert!(outcomes.is_empty(), "the skipped file must never be opened"); +} diff --git a/crates/tinybus/src/module/lease.rs b/crates/tinybus/src/module/lease.rs new file mode 100644 index 00000000..9780e7d9 --- /dev/null +++ b/crates/tinybus/src/module/lease.rs @@ -0,0 +1,899 @@ +//! A module reservation and host callback whose cleanup waits for STOP acknowledgement. + +use std::{error::Error as StdError, sync::Arc, time::Duration}; + +#[cfg(feature = "modules")] +use super::runtime::ModuleRuntime; +#[cfg(feature = "modules")] +use std::{ + any::Any, + future::Future, + panic::{AssertUnwindSafe, catch_unwind}, + pin::Pin, + sync::{ + Mutex, + atomic::{AtomicUsize, Ordering}, + }, +}; +#[cfg(feature = "modules")] +use tokio::{ + runtime::Handle, + sync::{oneshot, watch}, +}; + +/// The lifecycle operation that failed. Values deliberately contain no resource identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeasePhase { + /// The module did not provide a reservation. + Reserve, + /// The reservation failed host-side validation. + Validate, + /// The module object could not be constructed. + Object, + /// The host callback factory returned an error or panicked. + Callback, + /// The driver could not produce a callback address. + Address, + /// The callback could not be registered on the module runtime. + Export, + /// Starting the reserved module failed. + Start, + /// A stop attempt failed or was not acknowledged. + Stop, + /// The dedicated runtime could no longer accept cleanup work. + Runtime, +} + +/// Content-free outcome metadata sent to the caller-provided reporter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeaseReportOutcome { + /// A STOP attempt acknowledged shutdown. + Acknowledged, + /// A STOP attempt returned an error. + Failed, + /// A STOP attempt exceeded its configured timeout. + TimedOut, + /// The driver reported that the reservation had faulted. + Faulted, + /// The finite retry sequence ended without acknowledgement. + Exhausted, + /// The driver could not prove whether the operation settled. + Unacknowledged, +} + +/// Result of a driver STOP operation, with retry safety stated explicitly. +pub enum StopOutcome { + /// Native STOP acknowledged completion. + Acknowledged, + /// The operation is known to have settled without acknowledgement and can safely be retried. + RetryableFailure(E), + /// The operation may still be active or its completion is unknown; it must not be retried. + Unacknowledged(E), +} + +impl std::fmt::Debug for StopOutcome { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let variant = match self { + Self::Acknowledged => "Acknowledged", + Self::RetryableFailure(_) => "RetryableFailure()", + Self::Unacknowledged(_) => "Unacknowledged()", + }; + formatter.write_str(variant) + } +} + +/// Result of a driver START operation, with remote settlement stated explicitly. +pub enum StartOutcome { + /// The driver has authoritative evidence that START completed successfully. + Started, + /// START completed without starting work, so rollback STOP is safe. + SettledFailure(E), + /// START may still be active or its completion is unknown; do not issue STOP. + Unacknowledged(E), +} + +impl std::fmt::Debug for StartOutcome { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let variant = match self { + Self::Started => "Started", + Self::SettledFailure(_) => "SettledFailure()", + Self::Unacknowledged(_) => "Unacknowledged()", + }; + formatter.write_str(variant) + } +} + +/// Content-free lifecycle metadata; it never includes a reservation, object, or driver error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LeaseReport { + /// The lifecycle phase being reported. + pub phase: LeasePhase, + /// One-based STOP attempt number, when applicable. + pub attempt: Option, + /// The outcome of this event. + pub outcome: LeaseReportOutcome, +} + +/// Bounded STOP retry timing. +/// +/// The first attempt begins immediately. Each delay starts one additional attempt, so the total +/// number of attempts is exactly `retry_delays.len() + 1`. +#[derive(Debug, Clone)] +pub struct RetryPolicy { + per_attempt_timeout: Duration, + retry_delays: Vec, +} + +impl RetryPolicy { + /// Create a policy with a timeout for every STOP call and a finite backoff sequence. + /// + /// # Errors + /// Returns an error when a STOP attempt has no positive timeout. + pub fn new( + per_attempt_timeout: Duration, + retry_delays: Vec, + ) -> Result { + if per_attempt_timeout.is_zero() { + return Err(RetryPolicyError); + } + Ok(Self { + per_attempt_timeout, + retry_delays, + }) + } + + /// Maximum duration of one STOP call. + #[must_use] + pub fn per_attempt_timeout(&self) -> Duration { + self.per_attempt_timeout + } + + /// The finite delays between STOP attempts. + #[must_use] + pub fn retry_delays(&self) -> &[Duration] { + &self.retry_delays + } +} + +/// A retry policy was missing a positive per-attempt timeout. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RetryPolicyError; + +impl std::fmt::Display for RetryPolicyError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("STOP attempt timeout must be positive") + } +} + +impl StdError for RetryPolicyError {} + +/// A redacted lifecycle error retaining the original typed driver error for explicit inspection. +pub struct LeaseError { + phase: LeasePhase, + attempt: Option, + acknowledged: bool, + driver_error: Option>, +} + +impl LeaseError { + #[cfg(feature = "modules")] + fn new( + phase: LeasePhase, + attempt: Option, + acknowledged: bool, + driver_error: Option, + ) -> Self { + Self { + phase, + attempt, + acknowledged, + driver_error: driver_error.map(Arc::new), + } + } + + /// Lifecycle stage that failed. + #[must_use] + pub fn phase(&self) -> LeasePhase { + self.phase + } + + /// STOP attempt number associated with the error, if one occurred. + #[must_use] + pub fn attempt(&self) -> Option { + self.attempt + } + + /// Whether the native STOP operation was acknowledged. + #[must_use] + pub fn acknowledged(&self) -> bool { + self.acknowledged + } + + /// The original typed driver error, when a driver operation returned one. + #[must_use] + pub fn driver_error(&self) -> Option<&E> { + self.driver_error.as_deref() + } +} + +impl std::fmt::Debug for LeaseError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("LeaseError") + .field("phase", &self.phase) + .field("attempt", &self.attempt) + .field("acknowledged", &self.acknowledged) + .field( + "driver_error", + &self.driver_error.as_ref().map(|_| ""), + ) + .finish() + } +} + +impl std::fmt::Display for LeaseError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.attempt { + Some(attempt) => write!( + formatter, + "module lease {:?} phase failed (attempt {attempt}, acknowledged={})", + self.phase, self.acknowledged + ), + None => write!( + formatter, + "module lease {:?} phase failed (acknowledged={})", + self.phase, self.acknowledged + ), + } + } +} + +impl StdError for LeaseError {} + +/// Host-side driver for one module's reservation protocol. +/// +/// The implementation must bind every lifecycle operation to the peer that created the +/// reservation; a well-known-name replacement must never receive an old reservation handle. +#[async_trait::async_trait] +pub trait RuntimeLeaseDriver: Send + Sync + 'static { + /// Opaque module reservation token. + type Reservation: Send + Sync + 'static; + /// Object or handle retained by the public lease. + type Object: Send + Sync + 'static; + /// Typed driver error, available through [`LeaseError::driver_error`]. + type Error: StdError + Send + Sync + 'static; + + /// Reserve module-side resources. + async fn reserve(&self) -> Result; + /// Validate the reservation before any object construction or callback export. + fn validate(&self, reservation: &Self::Reservation) -> Result<(), Self::Error>; + /// Return the exact callback name and path for this reservation and object. + fn callback_address( + &self, + reservation: &Self::Reservation, + object: &Self::Object, + ) -> Result<(crate::BusName, crate::ObjectPath), Self::Error>; + /// Construct the host-side object or handle after validation. + async fn object(&self, reservation: &Self::Reservation) -> Result; + /// Start work associated with this reservation. + /// + /// Only return [`StartOutcome::SettledFailure`] when the driver can prove + /// that no START work remains in flight. Timeouts, lost replies, + /// cancellation, panics, malformed responses, and opaque transport errors + /// must be unacknowledged unless separate evidence proves settlement. + async fn start(&self, reservation: &Self::Reservation) -> StartOutcome; + /// Stop work associated with this reservation. + /// + /// STOP must be idempotent for this reservation so a proven-settled failure can be retried. + /// `RetryableFailure` is valid only when the driver has authoritative evidence that this + /// operation settled and a later STOP cannot overlap it. Timeouts, lost replies, malformed + /// responses, and faulted peers must be `Unacknowledged` unless separate evidence proves + /// settlement. `Acknowledged` is the only outcome that permits callback withdrawal. + async fn stop(&self, reservation: &Self::Reservation) -> StopOutcome; + /// Whether this reservation has reached a terminal fault that forbids another STOP attempt. + fn failed(&self, reservation: &Self::Reservation) -> bool; +} + +#[cfg(feature = "modules")] +type WorkerFuture = Pin + Send + 'static>>; +#[cfg(feature = "modules")] +type StartWorker = + Box>) -> WorkerFuture + Send + 'static>; + +#[cfg(feature = "modules")] +enum CloseState { + Open, + Running, + Acknowledged, + Unacknowledged(Arc>), +} + +#[cfg(feature = "modules")] +impl Clone for CloseState { + fn clone(&self) -> Self { + match self { + Self::Open => Self::Open, + Self::Running => Self::Running, + Self::Acknowledged => Self::Acknowledged, + Self::Unacknowledged(error) => Self::Unacknowledged(error.clone()), + } + } +} + +#[cfg(feature = "modules")] +struct LeaseControl { + runtime: Handle, + users: AtomicUsize, + state: watch::Sender>, + starter: Mutex>>, +} + +#[cfg(feature = "modules")] +impl LeaseControl { + fn new(runtime: Handle, starter: StartWorker) -> Self { + let (state, _) = watch::channel(CloseState::Open); + Self { + runtime, + users: AtomicUsize::new(1), + state, + starter: Mutex::new(Some(starter)), + } + } + + fn start(&self) { + let starter = self + .starter + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(starter) = starter { + self.state.send_replace(CloseState::Running); + let sender = self.state.clone(); + let worker = starter(sender); + self.runtime.spawn(worker); + } + } + + fn park(self: &Arc, retained: T) { + let control = self.clone(); + self.runtime.spawn(async move { + let _control = control; + let _retained = retained; + std::future::pending::<()>().await; + }); + } + + async fn wait(&self) -> Result<(), Arc>> { + let mut state = self.state.subscribe(); + self.start(); + loop { + match state.borrow().clone() { + CloseState::Acknowledged => return Ok(()), + CloseState::Unacknowledged(error) => return Err(error), + CloseState::Open | CloseState::Running => {} + } + if state.changed().await.is_err() { + return Err(Arc::new(LeaseError::new( + LeasePhase::Runtime, + None, + false, + None, + ))); + } + } + } +} + +#[cfg(feature = "modules")] +struct ActivationRollback { + control: Arc>, + retained_object: Option>, + armed: bool, + start_in_flight: bool, +} + +#[cfg(feature = "modules")] +impl ActivationRollback { + fn retain_object(&mut self, object: Arc) { + self.retained_object = Some(object); + } + + fn mark_start_in_flight(&mut self) { + self.start_in_flight = true; + } + + fn mark_start_settled(&mut self) { + self.start_in_flight = false; + } + + fn park(&mut self) { + self.armed = false; + match self.retained_object.take() { + Some(object) => self.control.park(object), + None => self.control.park(()), + } + } +} + +#[cfg(feature = "modules")] +impl Drop for ActivationRollback { + fn drop(&mut self) { + if self.armed { + if self.start_in_flight { + self.park(); + } else { + self.control.start(); + } + } + } +} + +/// A cloneable owner of a started module reservation and its callback object. +#[cfg(feature = "modules")] +pub struct RuntimeLease { + object: Arc, + control: Arc>, +} + +#[cfg(feature = "modules")] +impl RuntimeLease { + /// The object or host handle created for this reservation. + #[must_use] + pub fn object(&self) -> &T { + &self.object + } + + /// Wait for the one shared cleanup worker. Cancelling this future never cancels that worker. + pub async fn close(&self) -> Result<(), Arc>> + where + E: Send + Sync + 'static, + { + self.control.wait().await + } +} + +#[cfg(feature = "modules")] +impl Clone for RuntimeLease { + fn clone(&self) -> Self { + self.control.users.fetch_add(1, Ordering::Relaxed); + Self { + object: self.object.clone(), + control: self.control.clone(), + } + } +} + +#[cfg(feature = "modules")] +impl Drop for RuntimeLease { + fn drop(&mut self) { + if self.control.users.fetch_sub(1, Ordering::AcqRel) == 1 { + self.control.start(); + } + } +} + +#[cfg(feature = "modules")] +impl ModuleRuntime { + /// Reserve, validate, export a callback, and start a module on this runtime's owned task. + /// + /// This is a normal function: it submits the owned activation transaction before returning + /// the future. Cancelling the returned future only abandons its result; activation and any + /// required rollback continue on the dedicated runtime. + #[allow(clippy::type_complexity)] // The future must retain the driver's object and typed error. + pub fn lease( + &self, + driver: D, + make_callback: F, + policy: RetryPolicy, + reporter: R, + ) -> impl Future, LeaseError>> + + Send + + 'static + where + D: RuntimeLeaseDriver, + F: FnOnce(&D::Reservation, &D::Object) -> Result + Send + 'static, + I: crate::Interface, + R: Fn(LeaseReport) + Send + Sync + 'static, + { + let host_objects = self.lease_host_object_registry(); + let (response, result) = oneshot::channel(); + self.spawn(async move { + let runtime = Handle::current(); + let activated = activate( + runtime, + host_objects, + driver, + make_callback, + policy, + reporter, + ) + .await; + let _ = response.send(activated); + }); + async move { + result + .await + .unwrap_or_else(|_| Err(LeaseError::new(LeasePhase::Runtime, None, false, None))) + } + } +} + +#[cfg(feature = "modules")] +async fn activate( + runtime: Handle, + host_objects: Arc>, + driver: D, + make_callback: F, + policy: RetryPolicy, + reporter: R, +) -> Result, LeaseError> +where + D: RuntimeLeaseDriver, + F: FnOnce(&D::Reservation, &D::Object) -> Result + Send + 'static, + I: crate::Interface, + R: Fn(LeaseReport) + Send + Sync + 'static, +{ + let driver = Arc::new(driver); + let reservation = match driver.reserve().await { + Ok(reservation) => Arc::new(reservation), + Err(error) => { + return Err(LeaseError::new( + LeasePhase::Reserve, + None, + false, + Some(error), + )); + } + }; + + // This cleanup owner exists before validation and before any later await. The returned lease + // and every rollback path share this one worker starter and exact callback guard slot. + let callback_guard = Arc::new(Mutex::new(None)); + let cleanup_driver = driver.clone(); + let cleanup_reservation = reservation.clone(); + let cleanup_policy = policy.clone(); + let cleanup_reporter = Arc::new(reporter); + let cleanup_guard = callback_guard.clone(); + let starter: StartWorker = Box::new(move |state| { + Box::pin(cleanup_worker( + state, + cleanup_driver, + cleanup_reservation, + cleanup_guard, + cleanup_policy, + cleanup_reporter, + )) + }); + let control = Arc::new(LeaseControl::new(runtime.clone(), starter)); + let mut rollback = ActivationRollback { + control: control.clone(), + retained_object: None, + armed: true, + start_in_flight: false, + }; + + if let Err(error) = driver.validate(&reservation) { + let acknowledged = control.wait().await.is_ok(); + return Err(LeaseError::new( + LeasePhase::Validate, + None, + acknowledged, + Some(error), + )); + } + + let object = match driver.object(&reservation).await { + Ok(object) => Arc::new(object), + Err(error) => { + let acknowledged = control.wait().await.is_ok(); + return Err(LeaseError::new( + LeasePhase::Object, + None, + acknowledged, + Some(error), + )); + } + }; + rollback.retain_object(object.clone()); + + let callback = match catch_unwind(AssertUnwindSafe(|| make_callback(&reservation, &object))) { + Ok(Ok(callback)) => callback, + Ok(Err(error)) => { + let acknowledged = control.wait().await.is_ok(); + return Err(LeaseError::new( + LeasePhase::Callback, + None, + acknowledged, + Some(error), + )); + } + Err(_) => { + let acknowledged = control.wait().await.is_ok(); + return Err(LeaseError::new( + LeasePhase::Callback, + None, + acknowledged, + None, + )); + } + }; + + let (name, path) = match driver.callback_address(&reservation, &object) { + Ok(address) => address, + Err(error) => { + let acknowledged = control.wait().await.is_ok(); + return Err(LeaseError::new( + LeasePhase::Address, + None, + acknowledged, + Some(error), + )); + } + }; + let host_object = super::runtime::serve_host_object_transaction( + host_objects, + runtime.clone(), + name, + path, + callback, + ) + .await; + match host_object { + Ok(guard) => { + *callback_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(guard) + } + Err(_) => { + let acknowledged = control.wait().await.is_ok(); + return Err(LeaseError::new( + LeasePhase::Export, + None, + acknowledged, + None, + )); + } + } + + rollback.mark_start_in_flight(); + let start_driver = driver.clone(); + let start_reservation = reservation.clone(); + let start = runtime.spawn(async move { start_driver.start(&start_reservation).await }); + match start.await { + Ok(StartOutcome::Started) => { + rollback.mark_start_settled(); + rollback.armed = false; + Ok(RuntimeLease { object, control }) + } + Ok(StartOutcome::SettledFailure(error)) => { + rollback.mark_start_settled(); + let acknowledged = control.wait().await.is_ok(); + Err(LeaseError::new( + LeasePhase::Start, + None, + acknowledged, + Some(error), + )) + } + Ok(StartOutcome::Unacknowledged(error)) => { + rollback.park(); + Err(LeaseError::new(LeasePhase::Start, None, false, Some(error))) + } + Err(_) => { + rollback.park(); + Err(LeaseError::new(LeasePhase::Start, None, false, None)) + } + } +} + +#[cfg(feature = "modules")] +async fn cleanup_worker( + state: watch::Sender>, + driver: Arc, + reservation: Arc, + callback_guard: Arc>>, + policy: RetryPolicy, + reporter: Arc, +) where + D: RuntimeLeaseDriver, + E: StdError + Send + Sync + 'static, +{ + let max_attempts = policy.retry_delays.len() + 1; + let mut attempt = 1; + loop { + if !check_fault(&*driver, &reservation, &state, &reporter, attempt) { + std::future::pending::<()>().await; + } + + // The child owns the driver future. A timeout never drops or aborts remote work, and this + // parent retains the callback guard while joining the same operation for a late result. + let stop_driver = driver.clone(); + let stop_reservation = reservation.clone(); + let mut stop = tokio::spawn(async move { stop_driver.stop(&stop_reservation).await }); + let result = match tokio::time::timeout(policy.per_attempt_timeout, &mut stop).await { + Ok(Ok(outcome)) => outcome, + Ok(Err(_)) => { + park_unacknowledged( + &state, + &reporter, + attempt, + None, + LeaseReportOutcome::Unacknowledged, + ) + .await + } + Err(_) => { + report( + &reporter, + LeaseReport { + phase: LeasePhase::Stop, + attempt: Some(attempt), + outcome: LeaseReportOutcome::TimedOut, + }, + ); + state.send_replace(CloseState::Unacknowledged(Arc::new(LeaseError::new( + LeasePhase::Stop, + Some(attempt), + false, + None, + )))); + match stop.await { + Ok(outcome) => outcome, + Err(_) => { + park_unacknowledged( + &state, + &reporter, + attempt, + None, + LeaseReportOutcome::Unacknowledged, + ) + .await + } + } + } + }; + + match result { + StopOutcome::Acknowledged => { + report( + &reporter, + LeaseReport { + phase: LeasePhase::Stop, + attempt: Some(attempt), + outcome: LeaseReportOutcome::Acknowledged, + }, + ); + let guard = callback_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(guard) = guard { + guard.remove().await; + } + state.send_replace(CloseState::Acknowledged); + return; + } + StopOutcome::RetryableFailure(error) => { + report( + &reporter, + LeaseReport { + phase: LeasePhase::Stop, + attempt: Some(attempt), + outcome: LeaseReportOutcome::Failed, + }, + ); + if attempt == max_attempts { + park_unacknowledged( + &state, + &reporter, + attempt, + Some(error), + LeaseReportOutcome::Exhausted, + ) + .await; + } + } + StopOutcome::Unacknowledged(error) => { + park_unacknowledged( + &state, + &reporter, + attempt, + Some(error), + LeaseReportOutcome::Unacknowledged, + ) + .await; + } + } + + if attempt < max_attempts { + if !check_fault(&*driver, &reservation, &state, &reporter, attempt) { + std::future::pending::<()>().await; + } + tokio::time::sleep(policy.retry_delays[attempt - 1]).await; + attempt += 1; + } + } +} + +#[cfg(feature = "modules")] +fn check_fault( + driver: &D, + reservation: &D::Reservation, + state: &watch::Sender>, + reporter: &Arc, + attempt: usize, +) -> bool +where + D: RuntimeLeaseDriver, + E: StdError + Send + Sync + 'static, +{ + match catch_unwind(AssertUnwindSafe(|| driver.failed(reservation))) { + Ok(false) => true, + Ok(true) => { + mark_unacknowledged(state, reporter, attempt, None, LeaseReportOutcome::Faulted); + false + } + Err(_) => { + mark_unacknowledged( + state, + reporter, + attempt, + None, + LeaseReportOutcome::Unacknowledged, + ); + false + } + } +} + +#[cfg(feature = "modules")] +fn mark_unacknowledged( + state: &watch::Sender>, + reporter: &Arc, + attempt: usize, + error: Option, + outcome: LeaseReportOutcome, +) { + report( + reporter, + LeaseReport { + phase: LeasePhase::Stop, + attempt: Some(attempt), + outcome, + }, + ); + state.send_replace(CloseState::Unacknowledged(Arc::new(LeaseError::new( + LeasePhase::Stop, + Some(attempt), + false, + error, + )))); +} + +#[cfg(feature = "modules")] +async fn park_unacknowledged( + state: &watch::Sender>, + reporter: &Arc, + attempt: usize, + error: Option, + outcome: LeaseReportOutcome, +) -> ! { + mark_unacknowledged(state, reporter, attempt, error, outcome); + loop { + std::future::pending::<()>().await; + } +} + +#[cfg(feature = "modules")] +fn report(reporter: &Arc, event: LeaseReport) { + // Host reporting is observational and cannot interrupt the cleanup owner. + let _ = catch_unwind(AssertUnwindSafe(|| reporter(event))); +} + +#[cfg(all(test, feature = "modules"))] +#[path = "lease_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "lease_contract_tests.rs"] +mod contract_tests; diff --git a/crates/tinybus/src/module/lease_contract_tests.rs b/crates/tinybus/src/module/lease_contract_tests.rs new file mode 100644 index 00000000..4c6d3094 --- /dev/null +++ b/crates/tinybus/src/module/lease_contract_tests.rs @@ -0,0 +1,73 @@ +use super::*; + +struct ContractError; + +impl std::fmt::Debug for ContractError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ContractError") + } +} + +impl std::fmt::Display for ContractError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("contract error") + } +} + +impl std::error::Error for ContractError {} + +struct ContractDriver; + +#[async_trait::async_trait] +impl RuntimeLeaseDriver for ContractDriver { + type Reservation = (); + type Object = (); + type Error = ContractError; + + async fn reserve(&self) -> Result { + Ok(()) + } + + fn validate(&self, _: &Self::Reservation) -> Result<(), Self::Error> { + Ok(()) + } + + fn callback_address( + &self, + _: &Self::Reservation, + _: &Self::Object, + ) -> Result<(crate::BusName, crate::ObjectPath), Self::Error> { + Ok(( + "ai.tinyhumans.tinybus.tests.Contract".parse().unwrap(), + "/ai/tinyhumans/tinybus/tests/Contract".parse().unwrap(), + )) + } + + async fn object(&self, _: &Self::Reservation) -> Result { + Ok(()) + } + + async fn start(&self, _: &Self::Reservation) -> StartOutcome { + StartOutcome::Started + } + + async fn stop(&self, _: &Self::Reservation) -> StopOutcome { + StopOutcome::Acknowledged + } + + fn failed(&self, _: &Self::Reservation) -> bool { + false + } +} + +#[test] +fn the_protocol_contract_compiles_without_the_module_loader_feature() { + fn assert_driver() {} + assert_driver::(); + assert_eq!( + RetryPolicy::new(std::time::Duration::from_millis(1), vec![]) + .unwrap() + .retry_delays(), + &[] + ); +} diff --git a/crates/tinybus/src/module/lease_tests.rs b/crates/tinybus/src/module/lease_tests.rs new file mode 100644 index 00000000..e3995b6d --- /dev/null +++ b/crates/tinybus/src/module/lease_tests.rs @@ -0,0 +1,1743 @@ +use super::*; + +// RuntimeLease lifecycle and callback ownership behavior tests. + +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; +use tokio::sync::{mpsc, oneshot}; + +const CALLBACK_NAME: &str = "ai.tinyhumans.tinybus.tests.RuntimeLease"; + +#[derive(Clone, Debug)] +struct Identity(Arc<()>, usize); + +#[derive(Debug)] +struct Reservation(Identity); +#[derive(Debug)] +struct Object(Identity); + +#[derive(Debug)] +struct DriverError(&'static str); + +impl std::fmt::Display for DriverError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.0) + } +} + +impl std::error::Error for DriverError {} + +enum DriverCall { + Reserve(oneshot::Sender>), + Object(Identity, oneshot::Sender>), + Start(oneshot::Sender>), + Stop( + std::time::Instant, + oneshot::Sender>, + ), + Validated, +} + +#[derive(Clone)] +struct ControlledDriver { + calls: mpsc::UnboundedSender, + failed: Arc, + validation_error: Arc, + start_error: Arc, + address_error: Arc, + validation_panic: Arc, + address_panic: Arc, + start_panic: Arc, + stop_panic: Arc, + stop_panic_after_reply: Arc, + failed_panic: Arc, + identity: Identity, +} + +#[async_trait::async_trait] +impl RuntimeLeaseDriver for ControlledDriver { + type Reservation = Reservation; + type Object = Object; + type Error = DriverError; + + async fn reserve(&self) -> Result { + let (reply, result) = oneshot::channel(); + self.calls.send(DriverCall::Reserve(reply)).unwrap(); + result.await.unwrap() + } + + fn validate(&self, _: &Self::Reservation) -> Result<(), Self::Error> { + self.calls.send(DriverCall::Validated).unwrap(); + assert!( + !self.validation_panic.load(Ordering::Acquire), + "private validation panic payload" + ); + if self.validation_error.load(Ordering::Acquire) { + Err(DriverError("private validation detail")) + } else { + Ok(()) + } + } + + fn callback_address( + &self, + reservation: &Self::Reservation, + _: &Self::Object, + ) -> Result<(crate::BusName, crate::ObjectPath), Self::Error> { + assert!( + !self.address_panic.load(Ordering::Acquire), + "private address panic payload" + ); + if self.address_error.load(Ordering::Acquire) { + return Err(DriverError("private address error detail")); + } + assert!(Arc::ptr_eq(&self.identity.0, &reservation.0.0)); + Ok(( + CALLBACK_NAME.parse().unwrap(), + format!( + "/ai/tinyhumans/tinybus/tests/RuntimeLease{}", + reservation.0.1 + ) + .parse() + .unwrap(), + )) + } + + async fn object(&self, reservation: &Self::Reservation) -> Result { + let (reply, result) = oneshot::channel(); + self.calls + .send(DriverCall::Object(reservation.0.clone(), reply)) + .unwrap(); + result.await.unwrap() + } + + async fn start(&self, _: &Self::Reservation) -> StartOutcome { + let (reply, result) = oneshot::channel(); + self.calls.send(DriverCall::Start(reply)).unwrap(); + let outcome = result.await.unwrap(); + assert!( + !self.start_panic.load(Ordering::Acquire), + "private start panic payload" + ); + if self.start_error.load(Ordering::Acquire) { + StartOutcome::SettledFailure(DriverError("private start detail")) + } else { + outcome + } + } + + async fn stop(&self, _: &Self::Reservation) -> StopOutcome { + let (reply, result) = oneshot::channel(); + self.calls + .send(DriverCall::Stop(std::time::Instant::now(), reply)) + .unwrap(); + assert!( + !self.stop_panic.load(Ordering::Acquire), + "private stop panic payload" + ); + let outcome = result.await.unwrap(); + assert!( + !self.stop_panic_after_reply.load(Ordering::Acquire), + "private delayed STOP panic payload" + ); + outcome + } + + fn failed(&self, _: &Self::Reservation) -> bool { + assert!( + !self.failed_panic.load(Ordering::Acquire), + "private fault predicate panic payload" + ); + self.failed.load(Ordering::Acquire) + } +} + +#[derive(Clone)] +struct Callback(Arc); + +#[async_trait::async_trait] +impl crate::Interface for Callback { + fn name(&self) -> crate::InterfaceName { + "ai.tinyhumans.tinybus.tests.RuntimeLease".parse().unwrap() + } + + fn members(&self) -> Vec { + vec!["Touch".parse().unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Ok(serde_json::json!(self.0.fetch_add(1, Ordering::AcqRel) + 1)) + } +} + +struct PanickingNameCallback { + calls: Arc, + armed: Arc, +} + +#[async_trait::async_trait] +impl crate::Interface for PanickingNameCallback { + fn name(&self) -> crate::InterfaceName { + assert!( + !self.armed.load(Ordering::Acquire), + "name called during removal" + ); + CALLBACK_NAME.parse().unwrap() + } + + fn members(&self) -> Vec { + vec!["Touch".parse().unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Ok(serde_json::json!( + self.calls.fetch_add(1, Ordering::AcqRel) + 1 + )) + } +} + +struct PanickingDropCallback { + calls: Arc, + armed: Arc, +} + +impl Drop for PanickingDropCallback { + fn drop(&mut self) { + assert!( + !self.armed.load(Ordering::Acquire), + "callback destructor panic" + ); + } +} + +#[async_trait::async_trait] +impl crate::Interface for PanickingDropCallback { + fn name(&self) -> crate::InterfaceName { + CALLBACK_NAME.parse().unwrap() + } + + fn members(&self) -> Vec { + vec!["Touch".parse().unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Ok(serde_json::json!( + self.calls.fetch_add(1, Ordering::AcqRel) + 1 + )) + } +} + +struct Harness { + runtime: crate::module::ModuleRuntime, + driver: ControlledDriver, + calls: mpsc::UnboundedReceiver, + callback_calls: Arc, + reports: mpsc::UnboundedReceiver, + report_sender: mpsc::UnboundedSender, +} + +impl Harness { + fn new() -> Self { + let runtime = + crate::module::ModuleRuntime::start(&crate::module::RuntimeNames::default(), None) + .unwrap(); + let (calls, receiver) = mpsc::unbounded_channel(); + let (report_sender, reports) = mpsc::unbounded_channel(); + Self { + runtime, + driver: ControlledDriver { + calls, + failed: Arc::new(AtomicBool::new(false)), + validation_error: Arc::new(AtomicBool::new(false)), + start_error: Arc::new(AtomicBool::new(false)), + address_error: Arc::new(AtomicBool::new(false)), + validation_panic: Arc::new(AtomicBool::new(false)), + address_panic: Arc::new(AtomicBool::new(false)), + start_panic: Arc::new(AtomicBool::new(false)), + stop_panic: Arc::new(AtomicBool::new(false)), + stop_panic_after_reply: Arc::new(AtomicBool::new(false)), + failed_panic: Arc::new(AtomicBool::new(false)), + identity: Identity(Arc::new(()), 1), + }, + calls: receiver, + callback_calls: Arc::new(AtomicUsize::new(0)), + reports, + report_sender, + } + } + + fn open( + &self, + ) -> impl std::future::Future< + Output = Result, LeaseError>, + > + Send + + 'static { + let calls = self.callback_calls.clone(); + self.open_with(move |reservation, object| { + if !Arc::ptr_eq(&reservation.0.0, &object.0.0) { + return Err(DriverError("reservation/object mismatch")); + } + Ok(Callback(calls)) + }) + } + + fn open_with( + &self, + make_callback: F, + ) -> impl std::future::Future< + Output = Result, LeaseError>, + > + Send + + 'static + where + F: FnOnce(&Reservation, &Object) -> Result + Send + 'static, + { + let policy = RetryPolicy::new( + std::time::Duration::from_millis(20), + vec![std::time::Duration::ZERO, std::time::Duration::ZERO], + ) + .unwrap(); + self.open_with_policy(make_callback, policy) + } + + fn open_with_policy( + &self, + make_callback: F, + policy: RetryPolicy, + ) -> impl std::future::Future< + Output = Result, LeaseError>, + > + Send + + 'static + where + F: FnOnce(&Reservation, &Object) -> Result + Send + 'static, + { + let reports = self.report_sender.clone(); + self.open_with_policy_and_reporter(make_callback, policy, move |report| { + let _ = reports.send(report); + }) + } + + fn open_with_policy_and_reporter( + &self, + make_callback: F, + policy: RetryPolicy, + reporter: R, + ) -> impl std::future::Future< + Output = Result, LeaseError>, + > + Send + + 'static + where + F: FnOnce(&Reservation, &Object) -> Result + Send + 'static, + R: Fn(LeaseReport) + Send + Sync + 'static, + { + let driver = self.driver.clone(); + self.runtime.lease(driver, make_callback, policy, reporter) + } + + async fn opened(&mut self) -> RuntimeLease { + self.opened_with_policy( + RetryPolicy::new( + std::time::Duration::from_millis(20), + vec![std::time::Duration::ZERO, std::time::Duration::ZERO], + ) + .unwrap(), + ) + .await + } + + async fn opened_with_policy( + &mut self, + policy: RetryPolicy, + ) -> RuntimeLease { + let calls = self.callback_calls.clone(); + let opening = tokio::spawn(self.open_with_policy( + move |reservation, object| { + if !Arc::ptr_eq(&reservation.0.0, &object.0.0) { + return Err(DriverError("reservation/object mismatch")); + } + Ok(Callback(calls)) + }, + policy, + )); + match self.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => { + reply + .send(Ok(Reservation(self.driver.identity.clone()))) + .unwrap(); + } + _ => panic!("reserve must be the first driver operation"), + } + assert!(matches!( + self.calls.recv().await, + Some(DriverCall::Validated) + )); + match self.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => reply.send(Ok(Object(identity))).unwrap(), + _ => panic!("object creation follows validation"), + } + match self.calls.recv().await.unwrap() { + DriverCall::Start(reply) => reply.send(StartOutcome::Started).unwrap(), + _ => panic!("start follows callback registration"), + } + opening.await.unwrap().unwrap() + } + + async fn callback(&self) -> crate::Result { + self.callback_at(self.driver.identity.1).await + } + + async fn callback_at(&self, id: usize) -> crate::Result { + self.runtime + .proxy( + CALLBACK_NAME, + &format!("/ai/tinyhumans/tinybus/tests/RuntimeLease{id}"), + ) + .unwrap() + .call("Touch", ()) + .await + } + + async fn wait_callback_removed(&self) { + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if self.callback().await.is_err() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("acknowledged cleanup did not withdraw its callback"); + } +} + +#[test] +fn retry_policy_validates_positive_timeouts_and_exposes_its_schedule() { + let invalid = RetryPolicy::new(std::time::Duration::ZERO, vec![]).unwrap_err(); + assert_eq!(invalid.to_string(), "STOP attempt timeout must be positive"); + let timeout = std::time::Duration::from_millis(17); + let delays = vec![std::time::Duration::from_millis(3)]; + let policy = RetryPolicy::new(timeout, delays.clone()).unwrap(); + assert_eq!(policy.per_attempt_timeout(), timeout); + assert_eq!(policy.retry_delays(), delays); +} + +#[test] +fn stop_outcome_debug_names_the_disposition_but_redacts_typed_errors() { + let retry = StopOutcome::RetryableFailure(DriverError("private retry detail")); + let uncertain = StopOutcome::Unacknowledged(DriverError("private timeout detail")); + assert_eq!(format!("{retry:?}"), "RetryableFailure()"); + assert_eq!(format!("{uncertain:?}"), "Unacknowledged()"); + assert_eq!( + format!("{:?}", StopOutcome::::Acknowledged), + "Acknowledged" + ); +} + +#[test] +fn start_outcome_debug_redacts_typed_errors() { + let settled = StartOutcome::SettledFailure(DriverError("private settled start detail")); + let uncertain = StartOutcome::Unacknowledged(DriverError("private start timeout")); + assert_eq!(format!("{settled:?}"), "SettledFailure()"); + assert_eq!(format!("{uncertain:?}"), "Unacknowledged()"); + assert_eq!( + format!("{:?}", StartOutcome::::Started), + "Started" + ); +} + +#[tokio::test] +async fn acknowledged_close_removes_only_its_exact_callback_registration() { + let mut harness = Harness::new(); + let first = harness.opened().await; + let second_driver = ControlledDriver { + identity: Identity(Arc::new(()), 2), + ..harness.driver.clone() + }; + let callback_calls = harness.callback_calls.clone(); + let reports = harness.report_sender.clone(); + let policy = RetryPolicy::new( + std::time::Duration::from_millis(20), + vec![std::time::Duration::ZERO], + ) + .unwrap(); + let opening = tokio::spawn(harness.runtime.lease( + second_driver.clone(), + move |reservation: &Reservation, object: &Object| { + if !Arc::ptr_eq(&reservation.0.0, &object.0.0) { + return Err(DriverError("reservation/object mismatch")); + } + Ok(Callback(callback_calls)) + }, + policy, + move |report| { + let _ = reports.send(report); + }, + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(second_driver.identity.clone()))) + .unwrap(), + _ => panic!("second reservation begins independently"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => reply.send(Ok(Object(identity))).unwrap(), + _ => panic!("second object follows validation"), + } + match harness.calls.recv().await.unwrap() { + DriverCall::Start(reply) => reply.send(StartOutcome::Started).unwrap(), + _ => panic!("second callback is registered before start"), + } + let second = opening.await.unwrap().unwrap(); + assert_eq!(harness.callback_at(1).await.unwrap(), serde_json::json!(1)); + assert_eq!(harness.callback_at(2).await.unwrap(), serde_json::json!(2)); + + let first_close = tokio::spawn(async move { first.close().await }); + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply.send(StopOutcome::Acknowledged).unwrap(), + _ => panic!("first close stops only its reservation"), + } + assert!(first_close.await.unwrap().is_ok()); + assert!(harness.callback_at(1).await.is_err()); + assert_eq!(harness.callback_at(2).await.unwrap(), serde_json::json!(3)); + + let second_close = tokio::spawn(async move { second.close().await }); + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply.send(StopOutcome::Acknowledged).unwrap(), + _ => panic!("second close stops its own reservation"), + } + assert!(second_close.await.unwrap().is_ok()); + assert!(harness.callback_at(2).await.is_err()); +} + +#[tokio::test] +async fn acknowledged_close_preserves_a_newer_registration_at_its_callback_path() { + let mut harness = Harness::new(); + let lease = harness.opened().await; + let replacement_calls = Arc::new(AtomicUsize::new(0)); + let replacement = harness + .runtime + .serve_host_object( + CALLBACK_NAME.parse().unwrap(), + "/ai/tinyhumans/tinybus/tests/RuntimeLease1" + .parse() + .unwrap(), + Callback(replacement_calls.clone()), + ) + .await + .unwrap(); + assert_eq!(harness.callback().await.unwrap(), serde_json::json!(1)); + + let closing = tokio::spawn(async move { lease.close().await }); + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply.send(StopOutcome::Acknowledged).unwrap(), + _ => panic!("close starts STOP"), + } + assert!(closing.await.unwrap().is_ok()); + assert_eq!(harness.callback().await.unwrap(), serde_json::json!(2)); + assert_eq!(replacement_calls.load(Ordering::Acquire), 2); + drop(replacement); + harness.wait_callback_removed().await; +} + +#[tokio::test] +async fn cancelling_the_open_waiter_does_not_lose_a_late_reservation() { + let mut harness = Harness::new(); + let opening = tokio::spawn(harness.open()); + let reply = match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply, + _ => panic!("reserve must begin the activation transaction"), + }; + opening.abort(); + reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(); + + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => reply.send(Ok(Object(identity))).unwrap(), + _ => panic!("object creation follows validation"), + } + match harness.calls.recv().await.unwrap() { + DriverCall::Start(reply) => reply.send(StartOutcome::Started).unwrap(), + _ => panic!("start follows callback registration"), + } + let stop = match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply, + _ => panic!("a lease whose result was abandoned is rolled back"), + }; + assert_eq!(harness.callback().await.unwrap(), serde_json::json!(1)); + stop.send(StopOutcome::Acknowledged).unwrap(); + harness.wait_callback_removed().await; +} + +#[tokio::test] +async fn reserve_error_returns_the_typed_failure_without_starting_cleanup() { + let mut harness = Harness::new(); + let opening = tokio::spawn(harness.open()); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Err(DriverError("private reserve detail"))) + .unwrap(), + _ => panic!("reserve is the first lifecycle operation"), + } + let Err(error) = opening.await.unwrap() else { + panic!("reserve error cannot produce a lease"); + }; + assert_eq!(error.phase(), LeasePhase::Reserve); + assert_eq!(error.driver_error().unwrap().0, "private reserve detail"); + assert!(!error.to_string().contains("private reserve detail")); + assert!( + harness.calls.try_recv().is_err(), + "no reservation means no STOP or START" + ); +} + +#[tokio::test] +async fn cancelling_during_object_creation_keeps_activation_and_one_rollback_owner() { + let mut harness = Harness::new(); + let opening = tokio::spawn(harness.open()); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve is first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => { + opening.abort(); + assert!(matches!(opening.await, Err(error) if error.is_cancelled())); + reply.send(Ok(Object(identity))).unwrap(); + } + _ => panic!("object creation follows validation"), + } + match harness.calls.recv().await.unwrap() { + DriverCall::Start(reply) => { + assert_eq!(harness.callback().await.unwrap(), serde_json::json!(1)); + reply.send(StartOutcome::Started).unwrap(); + } + _ => panic!("owned activation continues through start"), + } + let stop = match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply, + _ => panic!("abandoned open result drops exactly one lease owner"), + }; + assert!(harness.callback().await.is_ok()); + assert!( + harness.calls.try_recv().is_err(), + "only one cleanup worker is active" + ); + stop.send(StopOutcome::Acknowledged).unwrap(); + harness.wait_callback_removed().await; +} + +#[tokio::test] +async fn cancelling_during_start_retains_the_export_until_rollback_ack() { + let mut harness = Harness::new(); + let opening = tokio::spawn(harness.open()); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve is first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => reply.send(Ok(Object(identity))).unwrap(), + _ => panic!("object follows validation"), + } + let start_reply = match harness.calls.recv().await.unwrap() { + DriverCall::Start(reply) => reply, + _ => panic!("start follows callback export"), + }; + assert_eq!(harness.callback().await.unwrap(), serde_json::json!(1)); + opening.abort(); + assert!(matches!(opening.await, Err(error) if error.is_cancelled())); + start_reply.send(StartOutcome::Started).unwrap(); + let stop = match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply, + _ => panic!("owned activation rollback survives waiter cancellation"), + }; + assert!(harness.callback().await.is_ok()); + assert!(harness.calls.try_recv().is_err()); + stop.send(StopOutcome::Acknowledged).unwrap(); + harness.wait_callback_removed().await; +} + +#[tokio::test] +async fn validation_panic_after_reserve_still_stops_the_owned_reservation() { + let mut harness = Harness::new(); + harness + .driver + .validation_panic + .store(true, Ordering::Release); + let opening = tokio::spawn(harness.open()); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve is first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + let stop = tokio::time::timeout(std::time::Duration::from_secs(2), harness.calls.recv()) + .await + .expect("validation panic must not discard the reservation") + .unwrap(); + match stop { + DriverCall::Stop(_, reply) => reply.send(StopOutcome::Acknowledged).unwrap(), + _ => panic!("panic rollback uses STOP"), + } + assert!(opening.await.unwrap().is_err()); +} + +#[tokio::test] +async fn callback_address_panic_after_object_creation_still_stops_the_reservation() { + let mut harness = Harness::new(); + harness.driver.address_panic.store(true, Ordering::Release); + let opening = tokio::spawn(harness.open()); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve is first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => reply.send(Ok(Object(identity))).unwrap(), + _ => panic!("object follows validation"), + } + let stop = tokio::time::timeout(std::time::Duration::from_secs(2), harness.calls.recv()) + .await + .expect("address panic must not discard the reservation") + .unwrap(); + match stop { + DriverCall::Stop(_, reply) => reply.send(StopOutcome::Acknowledged).unwrap(), + _ => panic!("panic rollback uses STOP"), + } + assert!(opening.await.unwrap().is_err()); +} + +#[tokio::test] +async fn start_panic_after_submission_parks_the_exported_callback_without_stop() { + let mut harness = Harness::new(); + harness.driver.start_panic.store(true, Ordering::Release); + let opening = tokio::spawn(harness.open()); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve is first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => reply.send(Ok(Object(identity))).unwrap(), + _ => panic!("object follows validation"), + } + match harness.calls.recv().await.unwrap() { + DriverCall::Start(reply) => reply.send(StartOutcome::Started).unwrap(), + _ => panic!("start follows callback registration"), + } + let Err(error) = opening.await.unwrap() else { + panic!("a panicking START is not a successful lease"); + }; + assert_eq!(error.phase(), LeasePhase::Start); + assert!(!error.acknowledged()); + assert_eq!(harness.callback().await.unwrap(), serde_json::json!(1)); + assert!( + harness.calls.try_recv().is_err(), + "a panicking START has unknown settlement and must not issue STOP" + ); +} + +#[tokio::test] +async fn validation_failure_after_reserve_still_stops_and_never_starts() { + let mut harness = Harness::new(); + harness + .driver + .validation_error + .store(true, Ordering::Release); + let opening = tokio::spawn(harness.open()); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve must be first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply.send(StopOutcome::Acknowledged).unwrap(), + _ => panic!("validation error is rolled back before object/export/start"), + } + let Err(error) = opening.await.unwrap() else { + panic!("invalid reservation must fail activation"); + }; + assert_eq!(error.phase(), LeasePhase::Validate); + assert!( + error.acknowledged(), + "successful rollback STOP is reflected in the error" + ); + assert!(!format!("{error:?}").contains("private validation detail")); + assert!(!error.to_string().contains("private validation detail")); + assert!( + harness.calls.try_recv().is_err(), + "failed validation has no later stage" + ); + assert!(harness.callback().await.is_err()); +} + +#[tokio::test] +async fn validation_error_reports_unacknowledged_when_rollback_is_uncertain() { + let mut harness = Harness::new(); + harness + .driver + .validation_error + .store(true, Ordering::Release); + let opening = tokio::spawn(harness.open()); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve must be first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply + .send(StopOutcome::Unacknowledged(DriverError( + "uncertain rollback", + ))) + .unwrap(), + _ => panic!("validation error starts rollback"), + } + let Err(error) = opening.await.unwrap() else { + panic!("failed validation cannot produce a lease"); + }; + assert_eq!(error.phase(), LeasePhase::Validate); + assert!(!error.acknowledged()); + assert_eq!(error.driver_error().unwrap().0, "private validation detail"); + assert!(!format!("{error:?}").contains("uncertain rollback")); + assert!(harness.calls.try_recv().is_err()); +} + +#[tokio::test] +async fn object_failure_after_reserve_keeps_the_cleanup_owner_until_stop_ack() { + let mut harness = Harness::new(); + let opening = tokio::spawn(harness.open()); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve must be first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(_, reply) => reply + .send(Err(DriverError("private object detail"))) + .unwrap(), + _ => panic!("object failure follows validation"), + } + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply.send(StopOutcome::Acknowledged).unwrap(), + _ => panic!("object failure rolls back the already-owned reservation"), + } + let Err(error) = opening.await.unwrap() else { + panic!("object creation failure must fail activation"); + }; + assert!(!format!("{error:?}").contains("private object detail")); + assert!(!error.to_string().contains("private object detail")); + assert!( + harness.calls.try_recv().is_err(), + "failed object creation never serves or starts" + ); + assert!(harness.callback().await.is_err()); +} + +#[tokio::test] +async fn start_failure_keeps_the_exported_callback_until_stop_ack() { + let mut harness = Harness::new(); + harness.driver.start_error.store(true, Ordering::Release); + let opening = tokio::spawn(harness.open()); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve is first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => reply.send(Ok(Object(identity))).unwrap(), + _ => panic!("object follows validation"), + } + match harness.calls.recv().await.unwrap() { + DriverCall::Start(reply) => reply.send(StartOutcome::Started).unwrap(), + _ => panic!("start follows callback export"), + } + let stop_reply = match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply, + _ => panic!("start error rolls back the reservation"), + }; + assert_eq!(harness.callback().await.unwrap(), serde_json::json!(1)); + stop_reply.send(StopOutcome::Acknowledged).unwrap(); + let Err(error) = opening.await.unwrap() else { + panic!("failed start cannot produce a lease"); + }; + assert_eq!(error.phase(), LeasePhase::Start); + assert_eq!(error.driver_error().unwrap().0, "private start detail"); + assert!( + error.acknowledged(), + "settled START failure rolls back to STOP ACK" + ); + assert!(harness.callback().await.is_err()); + assert!(harness.calls.try_recv().is_err()); +} + +#[tokio::test] +async fn callback_factory_error_rolls_back_before_start_or_export() { + let mut harness = Harness::new(); + let opening = + tokio::spawn(harness.open_with(|_, _| Err(DriverError("private callback factory detail")))); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve is first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => reply.send(Ok(Object(identity))).unwrap(), + _ => panic!("object follows validation"), + } + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply.send(StopOutcome::Acknowledged).unwrap(), + _ => panic!("factory error rolls back before start"), + } + let Err(error) = opening.await.unwrap() else { + panic!("callback factory error cannot produce a lease"); + }; + assert_eq!(error.phase(), LeasePhase::Callback); + assert_eq!( + error.driver_error().unwrap().0, + "private callback factory detail" + ); + assert!( + !error + .to_string() + .contains("private callback factory detail") + ); + assert!(harness.calls.try_recv().is_err()); + assert!(harness.callback().await.is_err()); +} + +unsafe extern "C" fn collision_module_init( + _: *const crate::module::abi::TbHostVtable, + _: *mut crate::module::abi::TbModuleVtable, +) -> i32 { + crate::module::abi::TB_BAD_ARGUMENT +} + +#[tokio::test] +async fn callback_name_collision_rolls_back_without_export_or_start() { + let mut harness = Harness::new(); + let path = "/ai/tinyhumans/tinybus/tests/RuntimeLease1"; + let manifest = crate::module::manifest::ModuleManifest { + schema: crate::module::manifest::MANIFEST_SCHEMA, + module: crate::module::manifest::ModuleIdentity { + name: "runtime-lease-callback-collision".to_string(), + version: crate::version::Version::new(0, 1, 0), + description: String::new(), + homepage: None, + license: String::new(), + }, + bus_name: CALLBACK_NAME.parse().unwrap(), + object_path: path.parse().unwrap(), + provides: Vec::new(), + requires: Vec::new(), + environment: Vec::new(), + capabilities: Vec::new(), + lazy_init: true, + worker_threads: 1, + on_panic: crate::module::manifest::PanicPolicy::Detach, + }; + harness + .runtime + .host() + .attach_linked_with_config( + crate::module::LinkedModule { + descriptor: crate::module::abi::TbAbiDescriptor::current( + "runtime-lease-callback-collision", + "0.1.0", + ), + manifest, + init: collision_module_init, + init_with_setup: None, + }, + serde_json::Value::Null, + ) + .unwrap(); + let owner = harness + .runtime + .connection() + .name_owner(CALLBACK_NAME) + .await + .unwrap(); + assert!(owner.is_some(), "the linked module owns the callback name"); + + let opening = tokio::spawn(harness.open()); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve precedes export collision"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => reply.send(Ok(Object(identity))).unwrap(), + _ => panic!("object follows validation"), + } + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply.send(StopOutcome::Acknowledged).unwrap(), + _ => panic!("export collision rolls back the reservation"), + } + let Err(error) = opening.await.unwrap() else { + panic!("a name collision cannot yield a started lease"); + }; + assert_eq!(error.phase(), LeasePhase::Export); + assert!( + harness.calls.try_recv().is_err(), + "business start never ran" + ); + assert_eq!( + harness + .runtime + .connection() + .name_owner(CALLBACK_NAME) + .await + .unwrap(), + owner, + "rollback preserves the existing peer's name" + ); +} + +#[tokio::test] +async fn callback_factory_panic_still_rolls_back_the_reserved_resource() { + let mut harness = Harness::new(); + let opening = tokio::spawn(harness.open_with(|_, _| -> Result { + std::panic::panic_any(()); + })); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve is first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => reply.send(Ok(Object(identity))).unwrap(), + _ => panic!("object follows validation"), + } + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply.send(StopOutcome::Acknowledged).unwrap(), + _ => panic!("factory panic still starts rollback"), + } + let Err(error) = opening.await.unwrap() else { + panic!("panicking callback factory cannot create a lease"); + }; + assert_eq!(error.phase(), LeasePhase::Callback); + assert!(harness.callback().await.is_err()); +} + +#[tokio::test] +async fn callback_address_error_rolls_back_before_export_or_start() { + let mut harness = Harness::new(); + harness.driver.address_error.store(true, Ordering::Release); + let opening = tokio::spawn(harness.open()); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve is first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => reply.send(Ok(Object(identity))).unwrap(), + _ => panic!("object follows validation"), + } + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply.send(StopOutcome::Acknowledged).unwrap(), + _ => panic!("address error rolls back before export"), + } + let Err(error) = opening.await.unwrap() else { + panic!("callback address error cannot produce a lease"); + }; + assert_eq!(error.phase(), LeasePhase::Address); + assert_eq!( + error.driver_error().unwrap().0, + "private address error detail" + ); + assert!( + harness.calls.try_recv().is_err(), + "address error prevents START" + ); + assert!(harness.callback().await.is_err()); +} + +#[tokio::test] +async fn reporter_panic_cannot_interrupt_acknowledged_callback_withdrawal() { + let mut harness = Harness::new(); + let calls = harness.callback_calls.clone(); + let opening = tokio::spawn( + harness.open_with_policy_and_reporter( + move |reservation, object| { + assert!(Arc::ptr_eq(&reservation.0.0, &object.0.0)); + Ok(Callback(calls)) + }, + RetryPolicy::new( + std::time::Duration::from_millis(20), + vec![std::time::Duration::ZERO], + ) + .unwrap(), + |_| std::panic::panic_any(()), + ), + ); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve is first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => reply.send(Ok(Object(identity))).unwrap(), + _ => panic!("object follows validation"), + } + match harness.calls.recv().await.unwrap() { + DriverCall::Start(reply) => reply.send(StartOutcome::Started).unwrap(), + _ => panic!("callback registers before start"), + } + let lease = opening.await.unwrap().unwrap(); + let closing = tokio::spawn(async move { lease.close().await }); + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply.send(StopOutcome::Acknowledged).unwrap(), + _ => panic!("close sends STOP"), + } + assert!(closing.await.unwrap().is_ok()); + assert!(harness.callback().await.is_err()); +} + +#[tokio::test] +async fn callback_name_is_not_reinvoked_during_acknowledged_withdrawal() { + let mut harness = Harness::new(); + let calls = harness.callback_calls.clone(); + let armed = Arc::new(AtomicBool::new(false)); + let name_armed = armed.clone(); + let opening = tokio::spawn( + harness.runtime.lease( + harness.driver.clone(), + move |_, _| { + Ok(PanickingNameCallback { + calls, + armed: name_armed, + }) + }, + RetryPolicy::new( + std::time::Duration::from_millis(100), + vec![std::time::Duration::ZERO], + ) + .unwrap(), + |_| {}, + ), + ); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve is first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => reply.send(Ok(Object(identity))).unwrap(), + _ => panic!("object follows validation"), + } + match harness.calls.recv().await.unwrap() { + DriverCall::Start(reply) => reply.send(StartOutcome::Started).unwrap(), + _ => panic!("start follows callback registration"), + } + let lease = opening.await.unwrap().unwrap(); + assert_eq!(harness.callback().await.unwrap(), serde_json::json!(1)); + armed.store(true, Ordering::Release); + + let closing = tokio::spawn(async move { lease.close().await }); + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply.send(StopOutcome::Acknowledged).unwrap(), + _ => panic!("close sends STOP"), + } + let closed = tokio::time::timeout(std::time::Duration::from_secs(2), closing) + .await + .expect("close waiter must finish after callback removal") + .unwrap(); + assert!(closed.is_ok()); + assert!(harness.callback().await.is_err()); +} + +#[tokio::test] +async fn callback_destructor_panic_does_not_interrupt_acknowledged_withdrawal() { + let mut harness = Harness::new(); + let calls = harness.callback_calls.clone(); + let armed = Arc::new(AtomicBool::new(false)); + let drop_armed = armed.clone(); + let opening = tokio::spawn( + harness.runtime.lease( + harness.driver.clone(), + move |_, _| { + Ok(PanickingDropCallback { + calls, + armed: drop_armed, + }) + }, + RetryPolicy::new( + std::time::Duration::from_millis(100), + vec![std::time::Duration::ZERO], + ) + .unwrap(), + |_| {}, + ), + ); + match harness.calls.recv().await.unwrap() { + DriverCall::Reserve(reply) => reply + .send(Ok(Reservation(harness.driver.identity.clone()))) + .unwrap(), + _ => panic!("reserve is first"), + } + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Validated) + )); + match harness.calls.recv().await.unwrap() { + DriverCall::Object(identity, reply) => reply.send(Ok(Object(identity))).unwrap(), + _ => panic!("object follows validation"), + } + match harness.calls.recv().await.unwrap() { + DriverCall::Start(reply) => reply.send(StartOutcome::Started).unwrap(), + _ => panic!("start follows callback registration"), + } + let lease = opening.await.unwrap().unwrap(); + assert_eq!(harness.callback().await.unwrap(), serde_json::json!(1)); + armed.store(true, Ordering::Release); + + let closing = tokio::spawn(async move { lease.close().await }); + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply.send(StopOutcome::Acknowledged).unwrap(), + _ => panic!("close sends STOP"), + } + let closed = tokio::time::timeout(std::time::Duration::from_secs(2), closing) + .await + .expect("close waiter must finish when callback Drop panics") + .unwrap(); + assert!(closed.is_ok()); + assert!(harness.callback().await.is_err()); +} + +#[tokio::test] +async fn acknowledged_stop_is_the_only_event_that_withdraws_the_callback() { + let mut harness = Harness::new(); + let lease = harness.opened().await; + assert_eq!(lease.object().0.1, 1); + assert_eq!(harness.callback().await.unwrap(), serde_json::json!(1)); + + let closing = tokio::spawn(async move { lease.close().await }); + let stop_reply = match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply, + _ => panic!("close starts one STOP"), + }; + assert_eq!(harness.callback().await.unwrap(), serde_json::json!(2)); + stop_reply.send(StopOutcome::Acknowledged).unwrap(); + assert!(closing.await.unwrap().is_ok()); + assert!(harness.callback().await.is_err()); +} + +#[tokio::test] +async fn exhausted_stop_parks_the_callback_until_an_acknowledgement() { + let mut harness = Harness::new(); + let lease = harness.opened().await; + let closing = tokio::spawn(async move { lease.close().await }); + + for _ in 0..3 { + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply + .send(StopOutcome::RetryableFailure(DriverError( + "private stop detail", + ))) + .unwrap(), + _ => panic!("bounded retry calls STOP only"), + } + } + let Err(error) = closing.await.unwrap() else { + panic!("exhausted settled failures are not an acknowledgement"); + }; + assert_eq!(error.driver_error().unwrap().0, "private stop detail"); + assert!(!format!("{error:?}").contains("private stop detail")); + assert!(!error.to_string().contains("private stop detail")); + assert!(std::error::Error::source(&*error).is_none()); + assert_eq!(harness.callback().await.unwrap(), serde_json::json!(1)); + assert!( + harness.calls.try_recv().is_err(), + "exhaustion does not busy-loop" + ); +} + +#[tokio::test] +async fn cloned_close_waiters_share_the_same_unacknowledged_typed_result() { + let mut harness = Harness::new(); + let lease = harness.opened().await; + let first = lease.clone(); + let second = lease.clone(); + drop(lease); + let first_waiter = tokio::spawn(async move { first.close().await }); + let second_waiter = tokio::spawn(async move { second.close().await }); + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply + .send(StopOutcome::Unacknowledged(DriverError( + "private uncertain result", + ))) + .unwrap(), + _ => panic!("close starts the single STOP worker"), + } + let first_error = first_waiter.await.unwrap().unwrap_err(); + let second_error = second_waiter.await.unwrap().unwrap_err(); + assert!(Arc::ptr_eq(&first_error, &second_error)); + assert_eq!( + first_error.driver_error().unwrap().0, + "private uncertain result" + ); + assert!(!format!("{first_error:?}").contains("private uncertain result")); + assert!(harness.callback().await.is_ok()); + assert!( + harness.calls.try_recv().is_err(), + "joined waiters never duplicate STOP" + ); +} + +#[tokio::test] +async fn retryable_stop_uses_the_configured_nonzero_backoff_sequence_in_order() { + let mut harness = Harness::new(); + let first_delay = std::time::Duration::from_millis(40); + let second_delay = std::time::Duration::from_millis(5); + let lease = harness + .opened_with_policy( + RetryPolicy::new( + std::time::Duration::from_secs(1), + vec![first_delay, second_delay], + ) + .unwrap(), + ) + .await; + let closing = tokio::spawn(async move { lease.close().await }); + let mut starts = Vec::new(); + for _ in 1..=3 { + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(started, reply) => { + starts.push(started); + reply + .send(StopOutcome::RetryableFailure(DriverError("settled"))) + .unwrap(); + } + _ => panic!("retry sequence contains only STOP"), + } + } + assert_eq!(starts.len(), 3); + assert!(starts[1].duration_since(starts[0]) >= first_delay); + assert!(starts[2].duration_since(starts[1]) >= second_delay); + assert!(closing.await.unwrap().is_err()); + assert!( + harness.calls.try_recv().is_err(), + "the retry vector is finite" + ); + assert!(harness.callback().await.is_ok()); +} + +#[tokio::test] +async fn ambiguous_stop_failure_is_reported_without_retry_or_callback_withdrawal() { + let mut harness = Harness::new(); + let lease = harness.opened().await; + let closing = tokio::spawn(async move { lease.close().await }); + match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply + .send(StopOutcome::Unacknowledged(DriverError( + "private transport timeout", + ))) + .unwrap(), + _ => panic!("close starts STOP"), + } + let Err(error) = closing.await.unwrap() else { + panic!("an uncertain result is not an acknowledgement"); + }; + assert_eq!(error.phase(), LeasePhase::Stop); + assert_eq!(error.attempt(), Some(1)); + assert!(!error.acknowledged()); + assert_eq!(error.driver_error().unwrap().0, "private transport timeout"); + assert!(!format!("{error:?}").contains("private transport timeout")); + assert!(!error.to_string().contains("private transport timeout")); + assert!(std::error::Error::source(&*error).is_none()); + let report = harness.reports.recv().await.unwrap(); + assert_eq!(report.outcome, LeaseReportOutcome::Unacknowledged); + assert!(!format!("{report:?}").contains("private transport timeout")); + assert!( + harness.callback().await.is_ok(), + "uncertain STOP retains its callback" + ); + assert!( + harness.calls.try_recv().is_err(), + "uncertain STOP is never retried" + ); +} + +#[tokio::test] +async fn a_stop_deadline_keeps_the_remote_operation_owned_until_its_late_ack() { + let mut harness = Harness::new(); + let lease = harness.opened().await; + let closing = tokio::spawn(async move { lease.close().await }); + let first_reply = match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply, + _ => panic!("close starts one STOP worker"), + }; + + loop { + if matches!( + harness.reports.recv().await.unwrap().outcome, + LeaseReportOutcome::TimedOut + ) { + break; + } + } + assert!( + harness.callback().await.is_ok(), + "deadline is not STOP acknowledgement" + ); + assert!( + harness.calls.try_recv().is_err(), + "unresolved STOP remains the only remote operation" + ); + assert!( + closing.await.unwrap().is_err(), + "close observes the deadline as unacknowledged" + ); + first_reply.send(StopOutcome::Acknowledged).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if harness.callback().await.is_err() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("late STOP acknowledgement withdraws the callback"); + assert!( + harness.calls.try_recv().is_err(), + "late ACK causes no duplicate STOP" + ); +} + +#[tokio::test] +async fn a_child_stop_panic_after_deadline_keeps_the_callback_parked() { + let mut harness = Harness::new(); + harness + .driver + .stop_panic_after_reply + .store(true, Ordering::Release); + let lease = harness.opened().await; + let closing = tokio::spawn(async move { lease.close().await }); + let stop_reply = match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply, + _ => panic!("close starts the owned STOP child"), + }; + loop { + if matches!( + harness.reports.recv().await.unwrap().outcome, + LeaseReportOutcome::TimedOut + ) { + break; + } + } + assert!(closing.await.unwrap().is_err()); + stop_reply + .send(StopOutcome::Unacknowledged(DriverError("uncertain"))) + .unwrap(); + assert!( + harness.callback().await.is_ok(), + "a child panic cannot acknowledge STOP" + ); + assert!( + harness.calls.try_recv().is_err(), + "the panicked operation is never retried" + ); +} + +#[tokio::test] +async fn cloned_close_waiters_and_last_user_drop_share_one_stop_worker() { + let mut harness = Harness::new(); + let lease = harness.opened().await; + let first = lease.clone(); + let second = lease.clone(); + let first_waiter = tokio::spawn(async move { first.close().await }); + let stop_reply = match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply, + _ => panic!("first close starts the worker"), + }; + let second_waiter = tokio::spawn(async move { second.close().await }); + drop(lease); + first_waiter.abort(); + assert!( + harness.calls.try_recv().is_err(), + "joining close and Drop do not duplicate STOP" + ); + stop_reply.send(StopOutcome::Acknowledged).unwrap(); + assert!(second_waiter.await.unwrap().is_ok()); + assert!(harness.calls.try_recv().is_err()); + assert!(harness.callback().await.is_err()); +} + +#[tokio::test] +async fn timing_out_a_close_waiter_does_not_cancel_the_dedicated_stop_worker() { + let mut harness = Harness::new(); + let lease = harness.opened().await; + let waiter = tokio::spawn(async move { + tokio::time::timeout(std::time::Duration::from_millis(1), lease.close()).await + }); + let stop_reply = match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply, + _ => panic!("close starts a dedicated stop worker"), + }; + assert!( + waiter.await.unwrap().is_err(), + "the external close wait can expire" + ); + assert!( + harness.callback().await.is_ok(), + "wait timeout is not STOP acknowledgement" + ); + stop_reply.send(StopOutcome::Acknowledged).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if harness.callback().await.is_err() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("the original worker removes the callback after its acknowledgement"); + assert!( + harness.calls.try_recv().is_err(), + "caller timeout creates no second STOP" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn destroying_the_callers_runtime_does_not_cancel_the_dedicated_stop_worker() { + let mut harness = Harness::new(); + let lease = harness.opened().await; + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (destroy_tx, destroy_rx) = std::sync::mpsc::channel(); + let caller = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + runtime.spawn(async move { + let _ = lease.close().await; + }); + ready_tx.send(()).unwrap(); + destroy_rx.recv().unwrap(); + drop(runtime); + }); + tokio::task::spawn_blocking(move || ready_rx.recv().unwrap()) + .await + .unwrap(); + let stop_reply = match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply, + _ => panic!("caller close starts the dedicated worker"), + }; + destroy_tx.send(()).unwrap(); + tokio::task::spawn_blocking(move || caller.join().unwrap()) + .await + .unwrap(); + assert!( + harness.callback().await.is_ok(), + "caller runtime drop is not STOP ACK" + ); + stop_reply.send(StopOutcome::Acknowledged).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if harness.callback().await.is_err() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("the runtime-owned worker completes after its caller runtime is gone"); + assert!(harness.calls.try_recv().is_err()); +} + +#[tokio::test] +async fn dropping_the_last_lease_outside_tokio_starts_cleanup_on_the_owned_runtime() { + let mut harness = Harness::new(); + let lease = harness.opened().await; + tokio::task::spawn_blocking(move || std::thread::spawn(move || drop(lease)).join().unwrap()) + .await + .unwrap(); + let stop_reply = match harness.calls.recv().await.unwrap() { + DriverCall::Stop(_, reply) => reply, + _ => panic!("last-user Drop starts STOP"), + }; + assert!(harness.callback().await.is_ok()); + stop_reply.send(StopOutcome::Acknowledged).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if harness.callback().await.is_err() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("the dedicated worker withdraws after ACK"); +} + +#[tokio::test] +async fn logical_failure_stops_retrying_without_claiming_acknowledgement() { + let mut harness = Harness::new(); + let lease = harness.opened().await; + harness.driver.failed.store(true, Ordering::Release); + let closing = tokio::spawn(async move { lease.close().await }); + assert!(closing.await.unwrap().is_err()); + assert!( + harness.calls.try_recv().is_err(), + "fault before first attempt sends no STOP" + ); + assert!( + harness.calls.try_recv().is_err(), + "failed module has no subsequent retry" + ); + assert!( + harness.callback().await.is_ok(), + "logical failure is not STOP ACK" + ); +} + +#[tokio::test] +async fn stop_panic_after_export_parks_the_callback_without_acknowledgement() { + let mut harness = Harness::new(); + let lease = harness.opened().await; + harness.driver.stop_panic.store(true, Ordering::Release); + let close = tokio::spawn(async move { lease.close().await }); + assert!(matches!( + harness.calls.recv().await, + Some(DriverCall::Stop(_, _)) + )); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(2), close) + .await + .expect("STOP panic must publish an unacknowledged result") + .unwrap() + .is_err() + ); + assert!( + harness.callback().await.is_ok(), + "a panicking STOP has no acknowledgement" + ); + assert!( + harness.calls.try_recv().is_err(), + "panic does not start a duplicate STOP" + ); +} + +#[tokio::test] +async fn fault_predicate_panic_parks_the_callback_without_sending_stop() { + let mut harness = Harness::new(); + let lease = harness.opened().await; + harness.driver.failed_panic.store(true, Ordering::Release); + let close = tokio::spawn(async move { lease.close().await }); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(2), close) + .await + .expect("fault predicate panic must publish an unacknowledged result") + .unwrap() + .is_err() + ); + assert!( + harness.callback().await.is_ok(), + "a panicking fault check is not an ACK" + ); + assert!( + harness.calls.try_recv().is_err(), + "fault check panic prevents unsafe retry" + ); +} diff --git a/crates/tinybus/src/module/loader.rs b/crates/tinybus/src/module/loader.rs index 104bfa07..42df3093 100644 --- a/crates/tinybus/src/module/loader.rs +++ b/crates/tinybus/src/module/loader.rs @@ -1,6 +1,8 @@ //! Platform loader: resolve the three v1 symbols and deliberately never unload. +use std::collections::HashMap; use std::path::Path; +use std::sync::{Mutex, OnceLock}; use crate::error::{Error, Result}; use crate::module::abi::{ @@ -20,6 +22,46 @@ pub(crate) struct LoadedArtifact { pub(crate) descriptor: TbAbiDescriptor, pub(crate) manifest: ModuleManifest, pub(crate) init: TbModuleInit, + pub(crate) library_sha256: Option, +} + +#[derive(Clone)] +struct MappedImage { + file_sha256: String, + trusted_library_sha256: Option, +} + +fn mapped_images() -> &'static Mutex> { + static IMAGES: OnceLock>> = OnceLock::new(); + IMAGES.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn record_mapped_image( + images: &mut HashMap, + handle: usize, + file_sha256: String, + expected_library_sha256: Option<&str>, +) -> std::result::Result, &'static str> { + if let Some(mapped) = images.get(&handle) { + if mapped.file_sha256 != file_sha256 { + return Err("mapped library bytes differ from the original image"); + } + if expected_library_sha256 + .is_some_and(|expected| mapped.trusted_library_sha256.as_deref() != Some(expected)) + { + return Err("mapped image has no matching library digest proof"); + } + return Ok(expected_library_sha256.map(str::to_string)); + } + let trusted_library_sha256 = expected_library_sha256.map(str::to_string); + images.insert( + handle, + MappedImage { + file_sha256, + trusted_library_sha256: trusted_library_sha256.clone(), + }, + ); + Ok(trusted_library_sha256) } #[repr(C)] @@ -30,8 +72,47 @@ struct DescriptorPrefix { descriptor_size: u32, } +#[allow(dead_code)] // Legacy unverified load remains available to internal fixtures. pub(crate) fn load(path: &Path, strict: bool) -> Result { + load_verified(path, strict, None) +} + +/// Load one library, binding an optional expected digest to the first native +/// image TinyBus maps for that handle. Shared library handles are never closed, +/// so a later file at the same path cannot inherit proof for an older image. +pub(crate) fn load_verified( + path: &Path, + strict: bool, + expected_library_sha256: Option<&str>, +) -> Result { + let mut images = mapped_images() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let actual_sha256 = crate::module::hash::file_hex( + std::fs::File::open(path) + .map_err(|_| Error::module_refused(path, "dynamic loader rejected the artifact"))?, + ) + .map_err(|_| Error::module_refused(path, "module library could not be hashed"))?; + if let Some(expected) = expected_library_sha256 { + if !crate::attest::is_hex_sha256(expected) { + return Err(Error::module_refused(path, "library digest pin is invalid")); + } + if !actual_sha256.eq_ignore_ascii_case(expected) { + return Err(Error::module_refused( + path, + "module library hash does not match its pin", + )); + } + } let handle = platform::open(path)?; + let library_sha256 = record_mapped_image( + &mut images, + handle as usize, + actual_sha256, + expected_library_sha256, + ) + .map_err(|reason| Error::module_refused(path, reason))?; + drop(images); let descriptor_ptr = platform::symbol(handle, b"TINYBUS_MODULE_ABI_V1\0", path)?; let prefix = unsafe { descriptor_ptr.cast::().read_unaligned() }; @@ -60,6 +141,7 @@ pub(crate) fn load(path: &Path, strict: bool) -> Result { descriptor, manifest, init, + library_sha256, }) } diff --git a/crates/tinybus/src/module/loader_tests.rs b/crates/tinybus/src/module/loader_tests.rs index 2cc09a30..7ce6dce0 100644 --- a/crates/tinybus/src/module/loader_tests.rs +++ b/crates/tinybus/src/module/loader_tests.rs @@ -97,6 +97,63 @@ fn the_manifest_slice_is_refused_for_each_reason() { } } +#[test] +fn a_replaced_path_cannot_promote_an_unverified_native_handle() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("module.so"); + std::fs::write(&path, b"first mapped image").unwrap(); + let first = crate::module::sha256_file(&path).unwrap(); + let mut images = HashMap::new(); + assert_eq!( + record_mapped_image(&mut images, 7, first.clone(), None), + Ok(None) + ); + + std::fs::write(&path, b"replacement image").unwrap(); + let replacement = crate::module::sha256_file(&path).unwrap(); + assert_ne!(first, replacement); + assert!( + record_mapped_image(&mut images, 7, replacement.clone(), Some(&replacement)).is_err(), + "a new pathname digest must not become proof for the old handle" + ); +} + +#[test] +fn one_native_handle_cannot_be_reused_with_a_different_proof() { + let mut images = HashMap::new(); + let first = "a".repeat(64); + let second = "b".repeat(64); + assert_eq!( + record_mapped_image(&mut images, 11, first.clone(), Some(&first)), + Ok(Some(first.clone())) + ); + assert!(record_mapped_image(&mut images, 11, first.clone(), Some(&second)).is_err()); + assert_eq!( + record_mapped_image(&mut images, 11, first.clone(), Some(&first)), + Ok(Some(first)) + ); +} + +#[test] +fn a_library_pin_is_validated_against_file_bytes_before_mapping() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("not-a-library.so"); + std::fs::write(&path, b"library bytes with a compiled pin").unwrap(); + + let invalid_pin = load_verified(&path, false, Some("not-a-sha256")) + .err() + .expect("invalid pin must be refused") + .to_string(); + assert!(invalid_pin.contains("library digest pin is invalid")); + + let wrong_pin = "a".repeat(64); + let mismatched_pin = load_verified(&path, false, Some(&wrong_pin)) + .err() + .expect("mismatched library pin must be refused") + .to_string(); + assert!(mismatched_pin.contains("module library hash does not match its pin")); +} + #[cfg(unix)] #[test] fn unix_loader_refuses_missing_and_nul_containing_paths() { diff --git a/crates/tinybus/src/module/mod.rs b/crates/tinybus/src/module/mod.rs index 0646dd44..ff2a0bd4 100644 --- a/crates/tinybus/src/module/mod.rs +++ b/crates/tinybus/src/module/mod.rs @@ -3,8 +3,11 @@ //! The ABI and manifest types are always compiled so a module can depend on //! tinybus with no default features. Loading is behind `modules`: in-process //! modules are trusted code with the host's full address-space privileges. +//! The coarse `Availability` status is available even when loading is disabled. pub mod abi; +mod availability; +pub use availability::Availability; #[cfg(feature = "modules")] mod cache; pub mod client; @@ -14,11 +17,27 @@ mod first_admitted; #[cfg(feature = "modules")] mod github; mod hash; +pub mod lease; +#[cfg(feature = "modules")] +pub use lease::RuntimeLease; +pub use lease::{ + LeaseError, LeasePhase, LeaseReport, LeaseReportOutcome, RetryPolicy, RetryPolicyError, + RuntimeLeaseDriver, StartOutcome, StopOutcome, +}; pub mod manifest; pub mod platform; +#[cfg(feature = "modules")] +pub mod reconfigure; mod release; #[cfg(feature = "modules")] pub mod resolution; +pub mod service_slot; +#[cfg(any(feature = "modules", feature = "cli"))] +mod zip_deflate; +#[cfg(any(feature = "modules", feature = "cli"))] +mod zip_descriptor; +#[cfg(any(feature = "modules", feature = "cli"))] +mod zip_extract; #[cfg(feature = "modules")] pub(crate) mod host; @@ -38,8 +57,10 @@ mod windows_acl; #[cfg(feature = "modules")] pub use cache::{artifact_dir, is_safe_path_component, prune_stale_versions}; pub use client::{ - AccessFuture, ModuleAccess, ModuleCallError, ModuleClient, ModuleObjectClient, ProxyFailure, - classify, + AccessFuture, BoundModuleClient, ModuleAccess, ModuleCallError, ModuleCallFailure, + ModuleClient, ModuleObjectClient, PinnedModuleClient, PinnedModuleObjectClient, ProxyFailure, + ReadinessFailure, ReconfigureFailure, UnloadedAccess, classify, report_proxy_failure, + wire_kind, }; pub use failure::{FailureReport, FailureReporter, FailureSink, Reason}; #[cfg(feature = "modules")] @@ -52,15 +73,44 @@ pub use host::callbacks::{ HostCallbackSignal, HostStreamCallbackEndpoint, }; #[cfg(feature = "modules")] -pub use host::{ModuleHost, ModuleInfo, ModuleState}; +pub use host::{ + ModuleHost, ModuleInfo, ModuleSenderLookup, ModuleState, PreReadyConfidentialRoute, + SearchPathConfig, +}; +#[cfg(feature = "modules")] +pub use reconfigure::{ConfigFingerprint, ReconfigurableModule, Reconfigure, ReconfigureGuard}; pub use release::{ReleaseAsset, ReleaseRecord}; #[cfg(feature = "modules")] pub use resolve::{ - Availability, LinkedFactory, LoadError, ModuleConfigProvider, ModuleGate, ModuleSource, - ResolvedHook, Resolver, ResolverInputs, RuntimeFuture, RuntimeProvider, + AsyncModuleConfigProvider, LinkedFactory, LinkedRegistry, LoadError, ModuleConfigProvider, + ModuleGate, ModuleSource, ResolvedHook, Resolver, ResolverInputs, RuntimeFuture, + RuntimeProvider, resolve_bundled_dir, set_bundled_releases_dir, }; #[cfg(feature = "modules")] -pub use runtime::{ModuleRuntime, RuntimeNames, RuntimeOptions, RuntimeSetup}; +pub use runtime::{ + HostObjectGuard, LinkedModuleInitWithSetup, LinkedSetup, ModuleRuntime, RuntimeNames, + RuntimeOptions, RuntimeSetup, +}; + +/// Stable, validated identifier of one admitted module package. +/// +/// This is independent of a module's display summary and bus aliases. It is +/// derived from the canonical module identity in the admitted manifest. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ModuleId(String); + +impl ModuleId { + #[cfg(feature = "modules")] + pub(crate) fn new(value: String) -> Self { + Self(value) + } + + /// The canonical module package identifier. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} /// Entry points compiled into the host instead of loaded from a shared library. /// @@ -74,6 +124,11 @@ pub struct LinkedModule { pub manifest: manifest::ModuleManifest, /// Process-lifetime initialization entry point. pub init: abi::TbModuleInit, + /// Rust-only initializer that accepts a per-attachment setup callback. + /// Generated only for statically linked exports. Dynamic modules have no + /// such hook and continue through the unchanged C ABI initializer. + #[doc(hidden)] + pub init_with_setup: Option, } #[cfg(feature = "modules")] @@ -105,8 +160,29 @@ impl LinkedModule { descriptor: *descriptor, manifest, init, + init_with_setup: None, }) } + + /// Construct a linked export with a Rust-only per-instance setup entrypoint. + /// + /// # Errors + /// Returns an error for an incompatible descriptor or malformed manifest. + /// + /// # Safety + /// The safety requirements are identical to [`Self::from_exports`], and + /// `init_with_setup` must be generated for this exact linked module. + #[doc(hidden)] + pub unsafe fn from_exports_with_setup( + descriptor: &abi::TbAbiDescriptor, + manifest: unsafe extern "C" fn() -> abi::TbSlice, + init: abi::TbModuleInit, + init_with_setup: LinkedModuleInitWithSetup, + ) -> crate::Result { + let mut module = unsafe { Self::from_exports(descriptor, manifest, init)? }; + module.init_with_setup = Some(init_with_setup); + Ok(module) + } } /// Compute the lowercase SHA-256 digest of a release asset. @@ -126,3 +202,16 @@ mod requirements; /// Host-selected activation requirements. #[cfg(feature = "modules")] pub use requirements::{ModuleRequirements, RequiredInterface}; + +/// Return a ZIP module archive's library digest using the bounded release +/// extractor. The CLI uses this when producing cross-platform release +/// checksums, including Windows `.dll` assets on a Unix host. +#[cfg(feature = "cli")] +pub fn sha256_zip_module_library(path: impl AsRef) -> crate::Result { + zip_extract::library_sha256(path.as_ref()) +} + +#[cfg(any(feature = "modules", feature = "cli"))] +pub(crate) fn refused(reason: impl Into) -> crate::Error { + crate::Error::module_refused(std::path::Path::new("github-release"), reason) +} diff --git a/crates/tinybus/src/module/reconfigure.rs b/crates/tinybus/src/module/reconfigure.rs new file mode 100644 index 00000000..facd4861 --- /dev/null +++ b/crates/tinybus/src/module/reconfigure.rs @@ -0,0 +1,359 @@ +//! Serialize configuration changes and proxy admission for a loaded module. +//! +//! A [`ReconfigurableModule`] serializes configuration transitions. Callers +//! that need the selected configuration to remain active for a whole bus call +//! hold [`ReconfigureGuard`] and obtain the proxy through the guard. A plain +//! [`ReconfigurableModule::proxy`] only serializes selection and reconfiguration; +//! it cannot protect later work performed through the returned proxy. + +use std::time::Duration; + +use serde::Serialize; +use sha2::{Digest, Sha256}; +use tokio::sync::{Mutex, MutexGuard}; + +use super::client::{ModuleAccess, ModuleCallError, ProxyFailure, ReconfigureFailure}; +use super::failure::Reason; +use crate::Proxy; + +/// A hash of serialized module configuration, with no configuration retained. +#[derive(Clone, Eq, PartialEq)] +pub struct ConfigFingerprint([u8; 32]); + +impl ConfigFingerprint { + /// Serialize and hash `config`; the serialized bytes are discarded before + /// this function returns. + /// + /// # Errors + /// Returns an error if `config` cannot be serialized as JSON. + pub fn of(config: &impl Serialize) -> Result { + let bytes = serde_json::to_vec(config)?; + Ok(Self(Sha256::digest(bytes).into())) + } +} + +/// How a configuration change is delivered to the module. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Reconfigure { + /// Use TinyBus' module lifecycle control operation. + Lifecycle, + /// Call the named module member with the configuration as its sole argument. + Member(&'static str), +} + +/// Coordinates configuration-sensitive access to one registered module. +/// +/// For [`Reconfigure::Member`], a failed, canceled, or timed-out remote +/// configuration call leaves its completion uncertain: TinyBus stops waiting, +/// but cannot cancel a handler already running in the module. The coordinator +/// then refuses further access, even for the same fingerprint, until the host +/// has independently established that the module restarted and constructs a +/// new coordinator. This fail-closed behavior prevents a late handler from +/// overwriting a newer configuration. +pub struct ReconfigurableModule { + access: Box, + module_id: &'static str, + strategy: Reconfigure, + retry_attempts: usize, + retry_delay: Duration, + mark_faulted: bool, + transition: Mutex<()>, + loaded: std::sync::Mutex, +} + +#[derive(Default)] +struct ConfigurationState { + fingerprint: Option, + member_uncertain: bool, +} + +impl ReconfigurableModule { + /// Create a coordinator for the registered module identified by `module_id`. + #[must_use] + pub fn new(access: impl ModuleAccess + 'static, module_id: &'static str) -> Self { + Self { + access: Box::new(access), + module_id, + strategy: Reconfigure::Lifecycle, + retry_attempts: 1, + retry_delay: Duration::ZERO, + mark_faulted: false, + transition: Mutex::new(()), + loaded: std::sync::Mutex::new(ConfigurationState::default()), + } + } + + /// Select how a changed configuration is applied. + #[must_use] + pub fn with_strategy(mut self, strategy: Reconfigure) -> Self { + self.strategy = strategy; + self + } + + /// Retry only typed `Initializing` responses, with `attempts` total tries. + /// A value of zero is treated as one attempt. + #[must_use] + pub fn retry_while_initializing(mut self, attempts: usize, delay: Duration) -> Self { + self.retry_attempts = attempts.max(1); + self.retry_delay = delay; + self + } + + /// Mark the resolver's module slot terminal when reconfiguration reports + /// the module unavailable. + #[must_use] + pub fn mark_faulted_on_unavailable(mut self, enabled: bool) -> Self { + self.mark_faulted = enabled; + self + } + + /// Record the configuration supplied to the module's successful load. + /// + /// # Errors + /// Returns an error if the configuration cannot be serialized. + pub fn record_loaded_with(&self, config: &impl Serialize) -> Result<(), serde_json::Error> { + let fingerprint = ConfigFingerprint::of(config)?; + self.loaded_state().fingerprint = Some(fingerprint); + Ok(()) + } + + /// Forget which configuration the module currently holds. + /// + /// This does not clear an uncertain member transition. Only an acknowledged + /// member configuration call or a new coordinator after an established + /// module restart can reopen access. + pub fn invalidate(&self) { + self.loaded_state().fingerprint = None; + } + + /// Obtain a proxy after reconciling the module's configuration. + /// + /// Concurrent calls serialize their transitions. The returned proxy is no + /// longer protected after this method returns; use [`Self::exclusive`] and + /// [`ReconfigureGuard::proxy`] when a subsequent call must share the same + /// configuration without another caller changing it in between. + /// + /// # Errors + /// Returns a sanitized module outcome or a JSON serialization error. + pub async fn proxy(&self, config: &impl Serialize) -> Result { + let _guard = self.transition.lock().await; + self.proxy_locked(config).await + } + + /// Hold configuration admission exclusively through a caller's bus call. + /// + /// All callers using this module's guard must use `guard.proxy(config)`; + /// calling [`Self::proxy`] while holding this guard would wait on the same + /// lock. + pub async fn exclusive(&self) -> ReconfigureGuard<'_> { + ReconfigureGuard { + module: self, + _guard: self.transition.lock().await, + } + } + + async fn proxy_locked(&self, config: &impl Serialize) -> Result { + if self.loaded_state().member_uncertain { + return Err( + self.report_failure(ModuleCallError::Unavailable, Reason::ModuleUnavailable) + ); + } + let Some(record) = self.access.find(self.module_id) else { + return Err(self.report_failure(ModuleCallError::Unavailable, Reason::UnknownModule)); + }; + if !self.access.enabled() { + return Err(self.report_failure(ModuleCallError::Unavailable, Reason::Disabled)); + } + self.access.ensure_loaded(record.id).await.map_err(|_| { + self.report_failure(ModuleCallError::Unavailable, Reason::ResolutionFailed) + })?; + + let fingerprint = ConfigFingerprint::of(config) + .map_err(|_| self.report_failure(ModuleCallError::ModuleFault, Reason::ModuleFault))?; + let current = self.loaded_state().fingerprint.clone(); + if current.as_ref() != Some(&fingerprint) { + // Clear before the operation: any failure leaves the held config + // unknown, so reverting to the prior config still triggers a retry. + self.loaded_state().fingerprint = None; + let value = serde_json::to_value(config).map_err(|_| { + self.report_failure(ModuleCallError::ModuleFault, Reason::ModuleFault) + })?; + let mut report_reason = None; + self.reconfigure(value, fingerprint.clone(), &mut report_reason) + .await + .map_err(|error| { + self.report_failure( + error, + report_reason.unwrap_or_else(|| Self::reason_for(error)), + ) + })?; + self.loaded_state().fingerprint = Some(fingerprint); + } + + self.access + .proxy(record.bus_name, record.object_path) + .await + .map_err(|failure| match failure { + ProxyFailure::Transport => { + self.report_failure(ModuleCallError::TransportFailed, Reason::TransportFailed) + } + ProxyFailure::Contract => self.report_failure( + ModuleCallError::IncompatibleContract, + Reason::IncompatibleContract, + ), + }) + } + + async fn reconfigure( + &self, + config: serde_json::Value, + fingerprint: ConfigFingerprint, + report_reason: &mut Option, + ) -> Result<(), ModuleCallError> { + let mut outcome = Err(ReconfigureFailure::Failed); + for attempt in 0..self.retry_attempts { + outcome = match self.strategy { + Reconfigure::Lifecycle => { + self.access + .reinitialize(self.module_id, config.clone()) + .await + } + Reconfigure::Member(member) => { + self.member_reconfigure( + member, + config.clone(), + fingerprint.clone(), + report_reason, + ) + .await + } + }; + match outcome { + Ok(()) => return Ok(()), + Err(ReconfigureFailure::Initializing) if attempt + 1 < self.retry_attempts => { + if !self.retry_delay.is_zero() { + tokio::time::sleep(self.retry_delay).await; + } + } + Err(_) => break, + } + } + if matches!( + outcome, + Err(ReconfigureFailure::Unavailable | ReconfigureFailure::Initializing) + ) && self.mark_faulted + { + self.access.mark_faulted(self.module_id); + } + Err(match outcome { + Err(ReconfigureFailure::Unavailable | ReconfigureFailure::Initializing) => { + ModuleCallError::Unavailable + } + Err(ReconfigureFailure::Unsupported | ReconfigureFailure::Failed) | Ok(()) => { + ModuleCallError::ModuleFault + } + }) + } + + async fn member_reconfigure( + &self, + member: &'static str, + config: serde_json::Value, + fingerprint: ConfigFingerprint, + report_reason: &mut Option, + ) -> Result<(), ReconfigureFailure> { + let record = self + .access + .find(self.module_id) + .ok_or(ReconfigureFailure::Unavailable)?; + if !self.access.enabled() { + return Err(ReconfigureFailure::Unavailable); + } + self.access.ensure_loaded(record.id).await.map_err(|_| { + *report_reason = Some(Reason::ResolutionFailed); + ReconfigureFailure::Unavailable + })?; + let proxy = self + .access + .proxy(record.bus_name, record.object_path) + .await + .map_err(|failure| match failure { + ProxyFailure::Transport | ProxyFailure::Contract => ReconfigureFailure::Failed, + })?; + // TinyBus deadlines and caller cancellation stop waiting but do not + // cancel a handler already running in the remote module. Fence future + // admissions before polling the call, and keep the fence on every + // ambiguous result. A new coordinator may be created only after the + // host has independently established that the module was restarted. + self.loaded_state().member_uncertain = true; + match proxy + .call_confidential::(member, (config,)) + .await + { + Ok(_) => { + let mut state = self.loaded_state(); + state.fingerprint = Some(fingerprint); + state.member_uncertain = false; + Ok(()) + } + Err(error) => Err(super::resolve::access::reconfigure_failure(&error)), + } + } + + fn reason_for(error: ModuleCallError) -> Reason { + match error { + ModuleCallError::Unavailable => Reason::ModuleUnavailable, + ModuleCallError::IncompatibleContract => Reason::IncompatibleContract, + ModuleCallError::TransportFailed => Reason::TransportFailed, + ModuleCallError::ModuleFault => Reason::ModuleFault, + } + } + + fn report_failure(&self, error: ModuleCallError, reason: Reason) -> ModuleCallError { + if reason == Reason::UnknownModule { + self.access.reporter().report_unknown_module(); + return error; + } + let Some(record) = self.access.find(self.module_id) else { + self.access.reporter().report_unknown_module(); + return error; + }; + if matches!( + reason, + Reason::Disabled | Reason::ResolutionFailed | Reason::ModuleUnavailable + ) { + self.access + .reporter() + .report(record.id, record.version, reason); + } else { + self.access + .reporter() + .report_invocation(record.id, record.version, reason); + } + error + } + + fn loaded_state(&self) -> std::sync::MutexGuard<'_, ConfigurationState> { + self.loaded + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +/// A configuration admission lock held across proxy use. +pub struct ReconfigureGuard<'a> { + module: &'a ReconfigurableModule, + _guard: MutexGuard<'a, ()>, +} + +impl ReconfigureGuard<'_> { + /// Reconcile the configuration while retaining exclusive access until this + /// guard is dropped. + pub async fn proxy(&mut self, config: &impl Serialize) -> Result { + self.module.proxy_locked(config).await + } +} + +#[cfg(test)] +#[path = "reconfigure_tests.rs"] +mod tests; diff --git a/crates/tinybus/src/module/reconfigure_policy_tests.rs b/crates/tinybus/src/module/reconfigure_policy_tests.rs new file mode 100644 index 00000000..bfb800e3 --- /dev/null +++ b/crates/tinybus/src/module/reconfigure_policy_tests.rs @@ -0,0 +1,105 @@ +use super::*; + +#[tokio::test(start_paused = true)] +async fn retry_delay_is_applied_between_typed_initializing_attempts() { + let _test_guard = FIXTURE_LOCK.lock().await; + let mut access = fixture().await; + access.initializing_left = StdMutex::new(1); + let calls = access.reinitialize_calls.clone(); + let (attempt_tx, attempt_rx) = tokio::sync::oneshot::channel(); + access.initializing_notifier = StdMutex::new(Some(attempt_tx)); + let module = Arc::new( + ReconfigurableModule::new(access, ID).retry_while_initializing(2, Duration::from_secs(5)), + ); + let config = serde_json::json!({ "route": "after-retry" }); + let retrying_module = module.clone(); + let retrying_config = config.clone(); + let retrying = tokio::spawn(async move { retrying_module.proxy(&retrying_config).await }); + + attempt_rx.await.unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + tokio::time::advance(Duration::from_secs(5)).await; + let proxy = retrying.await.unwrap().unwrap(); + tokio::time::resume(); + assert_eq!(read_config(&proxy).await, config); + assert_eq!(calls.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn registry_and_enabled_gates_refuse_proxy_reconciliation() { + let _test_guard = FIXTURE_LOCK.lock().await; + let mut missing = fixture().await; + missing.registered = false; + let missing = ReconfigurableModule::new(missing, ID); + assert_eq!( + missing.proxy(&serde_json::json!({})).await.unwrap_err(), + ModuleCallError::Unavailable + ); + + let mut disabled = fixture().await; + disabled.enabled = false; + let disabled = ReconfigurableModule::new(disabled, ID); + assert_eq!( + disabled.proxy(&serde_json::json!({})).await.unwrap_err(), + ModuleCallError::Unavailable + ); +} + +#[tokio::test] +async fn unavailable_fault_policy_only_marks_when_enabled() { + let _test_guard = FIXTURE_LOCK.lock().await; + let mut access = fixture().await; + access.forced_reinitialize_failure = Some(ReconfigureFailure::Unavailable); + let fault_calls = access.fault_calls.clone(); + let module = ReconfigurableModule::new(access, ID).mark_faulted_on_unavailable(false); + assert_eq!( + module.proxy(&serde_json::json!({})).await.unwrap_err(), + ModuleCallError::Unavailable + ); + assert_eq!(fault_calls.load(Ordering::SeqCst), 0); + + let mut access = fixture().await; + access.forced_reinitialize_failure = Some(ReconfigureFailure::Unavailable); + let fault_calls = access.fault_calls.clone(); + let module = ReconfigurableModule::new(access, ID).mark_faulted_on_unavailable(true); + assert_eq!( + module.proxy(&serde_json::json!({})).await.unwrap_err(), + ModuleCallError::Unavailable + ); + assert_eq!(fault_calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn lifecycle_and_member_proxy_failures_map_to_sanitized_outcomes() { + let _test_guard = FIXTURE_LOCK.lock().await; + for (failure, expected) in [ + (ProxyFailure::Transport, ModuleCallError::TransportFailed), + ( + ProxyFailure::Contract, + ModuleCallError::IncompatibleContract, + ), + ] { + let mut access = fixture().await; + access.proxy_failure = Some(failure); + let lifecycle = ReconfigurableModule::new(access, ID); + assert_eq!( + lifecycle + .proxy(&serde_json::json!({ "route": "lifecycle" })) + .await + .unwrap_err(), + expected + ); + } + + let mut access = fixture().await; + access.proxy_failure = Some(ProxyFailure::Contract); + let member = + ReconfigurableModule::new(access, ID).with_strategy(Reconfigure::Member("Configure")); + assert_eq!( + member + .proxy(&serde_json::json!({ "route": "member" })) + .await + .unwrap_err(), + ModuleCallError::ModuleFault + ); +} diff --git a/crates/tinybus/src/module/reconfigure_tests.rs b/crates/tinybus/src/module/reconfigure_tests.rs new file mode 100644 index 00000000..2c453e03 --- /dev/null +++ b/crates/tinybus/src/module/reconfigure_tests.rs @@ -0,0 +1,1142 @@ +use super::*; +use crate::broker::Broker; +use crate::module::abi::{TB_BAD_ARGUMENT, TB_OK, TbAbiDescriptor, TbHostVtable, TbModuleVtable}; +use crate::module::failure::{FailureReport, FailureReporter, FailureSink, Reason}; +use crate::module::host::ModuleHost; +use crate::module::manifest::{MANIFEST_SCHEMA, ModuleIdentity, ModuleManifest, PanicPolicy}; +use crate::module::{AccessFuture, ModuleState, ReleaseRecord}; +use crate::name::{BusName, ObjectPath}; +use crate::transport::memory::MemoryBus; +use crate::{Connection, Message, Proxy}; +use std::ffi::c_void; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; + +const ID: &str = "reconfigure-test-module"; +const BUS: &str = "ai.tinyhumans.test.Reconfigure"; +const UNATTESTED_BUS: &str = "ai.tinyhumans.test.Unattested"; +const PATH: &str = "/ai/tinyhumans/test/Reconfigure"; +static RECORD: ReleaseRecord = ReleaseRecord { + id: ID, + bus_name: BUS, + object_path: PATH, + version: "0.0.0", + release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v0.0.0", + assets: &[], +}; +static FAILED_RESOLVER_RECORD: ReleaseRecord = ReleaseRecord { + id: "reconfigure-report-resolution-failure", + bus_name: "ai.tinyhumans.test.ReconfigureReportResolutionFailure", + object_path: "/ai/tinyhumans/test/ReconfigureReportResolutionFailure", + version: "0.0.0", + release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v0.0.0", + assets: &[], +}; + +#[derive(Default)] +struct RecordingSink(StdMutex>); + +impl FailureSink for RecordingSink { + fn capture(&self, report: &FailureReport<'_>) { + self.0.lock().unwrap().push(( + report.module.to_owned(), + report.version.to_owned(), + report.reason, + )); + } +} +static FIXTURE_LOCK: Mutex<()> = Mutex::const_new(()); +static INITIALIZER_STARTED: OnceLock>>> = + OnceLock::new(); +static INITIALIZER_RELEASE: OnceLock>>> = + OnceLock::new(); +static BLOCKED_MEMBER: OnceLock>> = OnceLock::new(); +static MEMBER_CONFIG_CALLS: AtomicUsize = AtomicUsize::new(0); + +struct BlockedMember { + entered: std::sync::mpsc::SyncSender<()>, + release: std::sync::mpsc::Receiver<()>, + finished: std::sync::mpsc::SyncSender<()>, +} + +struct FixtureState { + tx: std::sync::mpsc::SyncSender>, + config: Arc>, +} + +unsafe extern "C" fn fixture_deliver(ctx: *mut c_void, ptr: *const u8, len: usize) -> i32 { + if ctx.is_null() || ptr.is_null() { + return crate::module::abi::TB_BAD_ARGUMENT; + } + let state = unsafe { &*ctx.cast::() }; + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec(); + match state.tx.try_send(bytes) { + Ok(()) => TB_OK, + Err(std::sync::mpsc::TrySendError::Full(_)) => crate::module::abi::TB_BACKPRESSURE, + Err(std::sync::mpsc::TrySendError::Disconnected(_)) => crate::module::abi::TB_CLOSED, + } +} + +unsafe extern "C" fn fixture_reinitialize(ctx: *mut c_void, ptr: *const u8, len: usize) -> i32 { + if ctx.is_null() || (ptr.is_null() && len != 0) { + return TB_BAD_ARGUMENT; + } + let config = if len == 0 { + serde_json::Value::Null + } else { + match serde_json::from_slice(unsafe { std::slice::from_raw_parts(ptr, len) }) { + Ok(config) => config, + Err(_) => return TB_BAD_ARGUMENT, + } + }; + if config.get("reject").and_then(serde_json::Value::as_bool) == Some(true) { + return TB_BAD_ARGUMENT; + } + let state = unsafe { &*ctx.cast::() }; + *state.config.lock().expect("fixture config lock") = config; + TB_OK +} + +unsafe extern "C" fn fixture_shutdown(_: *mut c_void, _: u64) -> i32 { + TB_OK +} + +unsafe extern "C" fn fixture_init(host: *const TbHostVtable, out: *mut TbModuleVtable) -> i32 { + if host.is_null() || out.is_null() { + return TB_BAD_ARGUMENT; + } + let host = unsafe { *host }; + let initial_config = if host.config.len == 0 { + serde_json::Value::Null + } else { + if host.config.ptr.is_null() { + return TB_BAD_ARGUMENT; + } + match serde_json::from_slice(unsafe { + std::slice::from_raw_parts(host.config.ptr, host.config.len) + }) { + Ok(config) => config, + Err(_) => return TB_BAD_ARGUMENT, + } + }; + let host_ctx = host.host_ctx as usize; + let send = host.send; + let ready = host.ready; + let config = Arc::new(StdMutex::new(initial_config)); + let service_config = config.clone(); + let (tx, rx) = std::sync::mpsc::sync_channel::>(16); + std::thread::spawn(move || { + unsafe { ready(host_ctx as *mut c_void) }; + while let Ok(bytes) = rx.recv() { + let service_config = service_config.clone(); + std::thread::spawn(move || { + let Ok(message) = serde_json::from_slice::(&bytes) else { + return; + }; + if message.header.kind != crate::message::MessageKind::MethodCall { + return; + } + let member = message.header.member.as_ref().map(|name| name.as_str()); + let blocked = if member == Some("Configure") { + let config = message + .body + .as_array() + .and_then(|values| values.first()) + .cloned() + .unwrap_or(serde_json::Value::Null); + MEMBER_CONFIG_CALLS.fetch_add(1, Ordering::SeqCst); + if config + .get("hold_member") + .and_then(serde_json::Value::as_bool) + == Some(true) + { + BLOCKED_MEMBER + .get_or_init(|| StdMutex::new(None)) + .lock() + .expect("blocked member fixture lock") + .take() + } else { + None + } + } else { + None + }; + if let Some(blocked) = &blocked { + let _ = blocked.entered.send(()); + let _ = blocked.release.recv(); + } + let body = match member { + Some("ReadConfig") => service_config.lock().unwrap().clone(), + Some("Configure") => { + let config = message + .body + .as_array() + .and_then(|values| values.first()) + .cloned() + .unwrap_or(serde_json::Value::Null); + *service_config.lock().unwrap() = config.clone(); + config + } + _ => serde_json::Value::Null, + }; + let reply = Message::method_return(&message.header, body); + let reply = serde_json::to_vec(&reply).expect("fixture reply serializes"); + let _ = unsafe { send(host_ctx as *mut c_void, reply.as_ptr(), reply.len()) }; + if let Some(blocked) = blocked { + let _ = blocked.finished.send(()); + } + }); + } + }); + let state = Box::into_raw(Box::new(FixtureState { tx, config })); + unsafe { + *out = TbModuleVtable { + size: std::mem::size_of::() as u32, + _reserved: 0, + module_ctx: state.cast(), + deliver: fixture_deliver, + shutdown: fixture_shutdown, + reinitialize: Some(fixture_reinitialize), + }; + } + TB_OK +} + +unsafe extern "C" fn blocked_fixture_init( + host: *const TbHostVtable, + out: *mut TbModuleVtable, +) -> i32 { + let started = INITIALIZER_STARTED + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() + .take(); + let release = INITIALIZER_RELEASE + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() + .take(); + let Some((started, release)) = started.zip(release) else { + return TB_BAD_ARGUMENT; + }; + let _ = started.send(()); + if release.recv().is_err() { + return TB_BAD_ARGUMENT; + } + unsafe { fixture_init(host, out) } +} + +struct Quiet; +impl FailureSink for Quiet { + fn capture(&self, _: &crate::module::failure::FailureReport<'_>) {} +} + +struct FixtureAccess { + _host: Arc, + connection: Connection, + broker_task: tokio::task::JoinHandle>, + reporter: FailureReporter, + registered: bool, + enabled: bool, + unattested_service: Option, + use_unattested_proxy: bool, + /// The retry scenario holds a concurrent first call inside initialization. + skip_ready_wait: bool, + member_proxy_timeout: StdMutex>, + forced_reinitialize_failure: Option, + proxy_failure: Option, + initializing_left: StdMutex, + reinitialize_calls: Arc, + fault_calls: Arc, + initializing_notifier: StdMutex>>, + initializing_gate: StdMutex>>, + ensure_loaded_calls: AtomicUsize, + fail_ensure_loaded_at: Option, +} + +impl Drop for FixtureAccess { + fn drop(&mut self) { + self.broker_task.abort(); + } +} + +impl ModuleAccess for FixtureAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (self.registered && id == ID).then_some(&RECORD) + } + fn enabled(&self) -> bool { + self.enabled + } + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + fn ensure_loaded<'a>(&'a self, _: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async move { + let call = self.ensure_loaded_calls.fetch_add(1, Ordering::SeqCst) + 1; + if self.fail_ensure_loaded_at == Some(call) { + self.reporter + .report(ID, RECORD.version, Reason::ResolutionFailed); + return Err("module resolution failed".to_owned()); + } + if self.skip_ready_wait { + return Ok(()); + } + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if self._host.list().iter().any(|module| { + matches!(&module.state, ModuleState::Ready | ModuleState::Serving) + }) { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .map_err(|_| "fixture module did not become ready".to_owned())?; + Ok(()) + }) + } + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + Box::pin(async move { + if let Some(failure) = self.proxy_failure { + return Err(failure); + } + let destination = if self.use_unattested_proxy { + UNATTESTED_BUS + } else { + bus_name + }; + let proxy = self + .connection + .proxy(destination, object_path, bus_name) + .map_err(|_| ProxyFailure::Contract)?; + Ok(self + .member_proxy_timeout + .lock() + .expect("member proxy timeout lock") + .take() + .map_or(proxy.clone(), |timeout| proxy.with_timeout(timeout))) + }) + } + fn reinitialize<'a>( + &'a self, + id: &'a str, + config: serde_json::Value, + ) -> AccessFuture<'a, Result<(), ReconfigureFailure>> { + Box::pin(async move { + self.reinitialize_calls.fetch_add(1, Ordering::SeqCst); + if let Some(failure) = self.forced_reinitialize_failure { + return Err(failure); + } + let return_initializing = { + let mut left = self + .initializing_left + .lock() + .expect("initializing attempt lock"); + if *left > 0 { + *left -= 1; + true + } else { + false + } + }; + if return_initializing { + if let Some(notifier) = self + .initializing_notifier + .lock() + .expect("initializing notifier lock") + .take() + { + let _ = notifier.send(()); + } + return Err(ReconfigureFailure::Initializing); + } + let outcome = self + .connection + .reinitialize_module(id, config) + .await + .map(|_| ()) + .map_err(|error| crate::module::resolve::access::reconfigure_failure(&error)); + if outcome == Err(ReconfigureFailure::Initializing) { + if let Some(notifier) = self + .initializing_notifier + .lock() + .expect("initializing notifier lock") + .take() + { + let _ = notifier.send(()); + } + let gate = self + .initializing_gate + .lock() + .expect("initializing gate lock") + .take(); + if let Some(gate) = gate { + let _ = gate.await; + } + } + outcome + }) + } + + fn mark_faulted(&self, _: &str) { + self.fault_calls.fetch_add(1, Ordering::SeqCst); + } +} + +fn fixture_manifest() -> ModuleManifest { + ModuleManifest { + schema: MANIFEST_SCHEMA, + module: ModuleIdentity { + name: ID.to_owned(), + version: crate::version::Version::new(0, 0, 0), + description: String::new(), + homepage: None, + license: String::new(), + }, + bus_name: BusName::new(BUS).unwrap(), + object_path: ObjectPath::new(PATH).unwrap(), + provides: vec![], + requires: vec![], + environment: vec![], + capabilities: vec![], + lazy_init: false, + worker_threads: 1, + on_panic: PanicPolicy::Detach, + } +} + +async fn fixture() -> FixtureAccess { + fixture_with_sink(Arc::new(Quiet)).await +} + +async fn fixture_with_sink(sink: Arc) -> FixtureAccess { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = Arc::new(ModuleHost::new(broker)); + host.attach_linked_with_config( + crate::module::LinkedModule { + descriptor: TbAbiDescriptor::current(ID, "0.0.0"), + manifest: fixture_manifest(), + init: fixture_init, + init_with_setup: None, + }, + serde_json::Value::Null, + ) + .unwrap(); + let connection = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + FixtureAccess { + _host: host, + connection, + broker_task, + reporter: FailureReporter::new(sink), + registered: true, + enabled: true, + unattested_service: None, + use_unattested_proxy: false, + skip_ready_wait: false, + member_proxy_timeout: StdMutex::new(None), + forced_reinitialize_failure: None, + proxy_failure: None, + initializing_left: StdMutex::new(0), + reinitialize_calls: Arc::new(AtomicUsize::new(0)), + fault_calls: Arc::new(AtomicUsize::new(0)), + initializing_notifier: StdMutex::new(None), + initializing_gate: StdMutex::new(None), + ensure_loaded_calls: AtomicUsize::new(0), + fail_ensure_loaded_at: None, + } +} + +async fn blocked_member_fixture( + timeout: Option, +) -> ( + Arc, + Connection, + std::sync::mpsc::Receiver<()>, + std::sync::mpsc::SyncSender<()>, + std::sync::mpsc::Receiver<()>, +) { + let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + let (finished_tx, finished_rx) = std::sync::mpsc::sync_channel(1); + *BLOCKED_MEMBER + .get_or_init(|| StdMutex::new(None)) + .lock() + .expect("blocked member fixture lock") = Some(BlockedMember { + entered: entered_tx, + release: release_rx, + finished: finished_tx, + }); + MEMBER_CONFIG_CALLS.store(0, Ordering::SeqCst); + let access = fixture().await; + ModuleAccess::ensure_loaded(&access, ID) + .await + .expect("linked fixture reaches serving before the controlled call"); + *access + .member_proxy_timeout + .lock() + .expect("member proxy timeout lock") = timeout; + let connection = access.connection.clone(); + let module = Arc::new( + ReconfigurableModule::new(access, ID).with_strategy(Reconfigure::Member("Configure")), + ); + (module, connection, entered_rx, release_tx, finished_rx) +} + +async fn lazy_fixture( + block_initialization: bool, +) -> ( + Arc, + Connection, + tokio::task::JoinHandle>, +) { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = Arc::new(ModuleHost::new(broker)); + let mut manifest = fixture_manifest(); + manifest.lazy_init = true; + host.attach_linked_with_config( + crate::module::LinkedModule { + descriptor: TbAbiDescriptor::current(ID, "0.0.0"), + manifest, + init: if block_initialization { + blocked_fixture_init + } else { + fixture_init + }, + init_with_setup: None, + }, + serde_json::Value::Null, + ) + .unwrap(); + let connection = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + (host, connection, broker_task) +} + +#[tokio::test] +async fn reinitialize_rejects_initializing_but_updates_deferred_lazy_config() { + let _test_guard = FIXTURE_LOCK.lock().await; + let (started_tx, started_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + *INITIALIZER_STARTED + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() = Some(started_tx); + *INITIALIZER_RELEASE + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() = Some(release_rx); + let (host, connection, broker_task) = lazy_fixture(true).await; + let proxy = connection.proxy(BUS, PATH, BUS).unwrap(); + let in_flight = + tokio::spawn(async move { proxy.call::("ReadConfig", ()).await }); + tokio::task::spawn_blocking(move || started_rx.recv()) + .await + .unwrap() + .unwrap(); + + let initializing = connection + .reinitialize_module(ID, serde_json::json!({ "route": "new" })) + .await + .unwrap_err(); + assert!(matches!( + initializing, + crate::Error::ModuleUnavailable { ref state, .. } + if state == "initializing" + )); + release_tx.send(()).unwrap(); + in_flight.await.unwrap().unwrap(); + broker_task.abort(); + drop(host); + + let (host, connection, broker_task) = lazy_fixture(false).await; + let resolved = connection + .reinitialize_module(ID, serde_json::json!({ "route": "new" })) + .await + .unwrap(); + assert_eq!(resolved.state, ModuleState::Resolved); + let proxy = connection.proxy(BUS, PATH, BUS).unwrap(); + assert_eq!( + proxy + .call::("ReadConfig", ()) + .await + .unwrap(), + serde_json::json!({ "route": "new" }) + ); + broker_task.abort(); + drop(host); +} + +#[tokio::test] +async fn reconfiguration_retries_a_wire_initializing_result_after_the_linked_module_is_ready() { + let _test_guard = FIXTURE_LOCK.lock().await; + let (started_tx, started_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + *INITIALIZER_STARTED + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() = Some(started_tx); + *INITIALIZER_RELEASE + .get_or_init(|| StdMutex::new(None)) + .lock() + .unwrap() = Some(release_rx); + let (host, connection, broker_task) = lazy_fixture(true).await; + let initial_proxy = connection.proxy(BUS, PATH, BUS).unwrap(); + let in_flight = tokio::spawn(async move { + initial_proxy + .call::("ReadConfig", ()) + .await + }); + tokio::task::spawn_blocking(move || started_rx.recv()) + .await + .unwrap() + .unwrap(); + + let (notifying_tx, notifying_rx) = tokio::sync::oneshot::channel(); + let (gate_tx, gate_rx) = tokio::sync::oneshot::channel(); + let calls = Arc::new(AtomicUsize::new(0)); + let access = FixtureAccess { + _host: host, + connection, + broker_task, + reporter: FailureReporter::new(Arc::new(Quiet)), + registered: true, + enabled: true, + unattested_service: None, + use_unattested_proxy: false, + skip_ready_wait: true, + member_proxy_timeout: StdMutex::new(None), + forced_reinitialize_failure: None, + proxy_failure: None, + initializing_left: StdMutex::new(0), + reinitialize_calls: calls.clone(), + fault_calls: Arc::new(AtomicUsize::new(0)), + initializing_notifier: StdMutex::new(Some(notifying_tx)), + initializing_gate: StdMutex::new(Some(gate_rx)), + ensure_loaded_calls: AtomicUsize::new(0), + fail_ensure_loaded_at: None, + }; + let module = + Arc::new(ReconfigurableModule::new(access, ID).retry_while_initializing(2, Duration::ZERO)); + let config = serde_json::json!({ "route": "after-init" }); + let retrying_module = module.clone(); + let retrying_config = config.clone(); + let retrying = tokio::spawn(async move { retrying_module.proxy(&retrying_config).await }); + notifying_rx.await.unwrap(); + release_tx.send(()).unwrap(); + in_flight.await.unwrap().unwrap(); + gate_tx.send(()).unwrap(); + + let proxy = retrying.await.unwrap().unwrap(); + assert_eq!(read_config(&proxy).await, config); + assert_eq!(calls.load(Ordering::SeqCst), 2); +} + +struct UnattestedService(Arc); + +#[async_trait::async_trait] +impl crate::Interface for UnattestedService { + fn name(&self) -> crate::InterfaceName { + crate::InterfaceName::new(BUS).unwrap() + } + + fn members(&self) -> Vec { + vec![crate::MemberName::new("Configure").unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(serde_json::Value::Null) + } +} + +async fn fixture_with_unattested_destination() -> (FixtureAccess, Arc) { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = Arc::new(ModuleHost::new(broker)); + host.attach_linked_with_config( + crate::module::LinkedModule { + descriptor: TbAbiDescriptor::current(ID, "0.0.0"), + manifest: fixture_manifest(), + init: fixture_init, + init_with_setup: None, + }, + serde_json::Value::Null, + ) + .unwrap(); + let connection = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let unattested_service = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + unattested_service + .request_name(UNATTESTED_BUS) + .await + .unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + unattested_service + .serve_at( + ObjectPath::new(PATH).unwrap(), + UnattestedService(calls.clone()), + ) + .await + .unwrap(); + ( + FixtureAccess { + _host: host, + connection, + broker_task, + reporter: FailureReporter::new(Arc::new(Quiet)), + registered: true, + enabled: true, + unattested_service: Some(unattested_service), + use_unattested_proxy: true, + skip_ready_wait: false, + member_proxy_timeout: StdMutex::new(None), + forced_reinitialize_failure: None, + proxy_failure: None, + initializing_left: StdMutex::new(0), + reinitialize_calls: Arc::new(AtomicUsize::new(0)), + fault_calls: Arc::new(AtomicUsize::new(0)), + initializing_notifier: StdMutex::new(None), + initializing_gate: StdMutex::new(None), + ensure_loaded_calls: AtomicUsize::new(0), + fail_ensure_loaded_at: None, + }, + calls, + ) +} + +async fn read_config(proxy: &Proxy) -> serde_json::Value { + proxy.call("ReadConfig", ()).await.unwrap() +} + +#[test] +fn fingerprints_retain_only_a_digest() { + let config = serde_json::json!({ "token": "sensitive-value" }); + let fingerprint = ConfigFingerprint::of(&config).unwrap(); + assert_eq!(fingerprint.0.len(), 32); + assert!(fingerprint == ConfigFingerprint::of(&config).unwrap()); + assert!(fingerprint != ConfigFingerprint::of(&serde_json::json!({})).unwrap()); +} + +#[tokio::test] +async fn changed_configuration_reinitializes_and_equal_configuration_does_not() { + let _test_guard = FIXTURE_LOCK.lock().await; + let access = fixture().await; + let calls = access.reinitialize_calls.clone(); + let module = ReconfigurableModule::new(access, ID); + let first = serde_json::json!({ "route": "one" }); + let proxy = module.proxy(&first).await.unwrap(); + assert_eq!(read_config(&proxy).await, first); + assert_eq!(calls.load(Ordering::SeqCst), 1); + let same = module + .proxy(&serde_json::json!({ "route": "one" })) + .await + .unwrap(); + assert_eq!( + read_config(&same).await, + serde_json::json!({ "route": "one" }) + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + let changed = serde_json::json!({ "route": "two" }); + let proxy = module.proxy(&changed).await.unwrap(); + assert_eq!(read_config(&proxy).await, changed); + assert_eq!(calls.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn recorded_load_configuration_is_reused_until_invalidated() { + let _test_guard = FIXTURE_LOCK.lock().await; + let access = fixture().await; + let calls = access.reinitialize_calls.clone(); + let module = ReconfigurableModule::new(access, ID); + module.record_loaded_with(&serde_json::Value::Null).unwrap(); + let proxy = module.proxy(&serde_json::Value::Null).await.unwrap(); + assert_eq!(read_config(&proxy).await, serde_json::Value::Null); + assert_eq!(calls.load(Ordering::SeqCst), 0); + module.invalidate(); + module.proxy(&serde_json::Value::Null).await.unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn a_failed_change_forgets_the_old_fingerprint_before_a_revert() { + let _test_guard = FIXTURE_LOCK.lock().await; + let access = fixture().await; + let calls = access.reinitialize_calls.clone(); + let module = ReconfigurableModule::new(access, ID); + let original = serde_json::json!({ "route": "original" }); + module.proxy(&original).await.unwrap(); + let rejected = serde_json::json!({ "reject": true }); + assert_eq!( + module.proxy(&rejected).await.unwrap_err(), + ModuleCallError::ModuleFault + ); + let reverted = module.proxy(&original).await.unwrap(); + assert_eq!(read_config(&reverted).await, original); + assert_eq!(calls.load(Ordering::SeqCst), 3); +} + +#[tokio::test] +async fn reconfiguration_failure_reports_only_registry_identity_and_closed_reason() { + let _test_guard = FIXTURE_LOCK.lock().await; + let sink = Arc::new(RecordingSink::default()); + let mut access = fixture_with_sink(sink.clone()).await; + access.forced_reinitialize_failure = Some(ReconfigureFailure::Failed); + let module = ReconfigurableModule::new(access, ID); + let config = serde_json::json!({ "credential": "private-test-value" }); + + assert_eq!( + module.proxy(&config).await.unwrap_err(), + ModuleCallError::ModuleFault + ); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(ID.to_owned(), "0.0.0".to_owned(), Reason::ModuleFault)] + ); +} + +#[tokio::test] +async fn missing_and_disabled_reconfiguration_report_closed_registry_reasons() { + let _test_guard = FIXTURE_LOCK.lock().await; + let missing_sink = Arc::new(RecordingSink::default()); + let mut missing = fixture_with_sink(missing_sink.clone()).await; + missing.registered = false; + let missing = ReconfigurableModule::new(missing, ID); + assert_eq!( + missing.proxy(&serde_json::Value::Null).await.unwrap_err(), + ModuleCallError::Unavailable + ); + assert_eq!( + missing_sink.0.lock().unwrap().as_slice(), + &[( + "unregistered".to_owned(), + "unknown".to_owned(), + Reason::UnknownModule + )] + ); + + let disabled_sink = Arc::new(RecordingSink::default()); + let mut disabled = fixture_with_sink(disabled_sink.clone()).await; + disabled.enabled = false; + let disabled = ReconfigurableModule::new(disabled, ID); + assert_eq!( + disabled.proxy(&serde_json::Value::Null).await.unwrap_err(), + ModuleCallError::Unavailable + ); + assert_eq!( + disabled_sink.0.lock().unwrap().as_slice(), + &[(ID.to_owned(), "0.0.0".to_owned(), Reason::Disabled)] + ); +} + +#[tokio::test] +async fn proxy_failure_after_configuration_reports_transport_failure() { + let _test_guard = FIXTURE_LOCK.lock().await; + let sink = Arc::new(RecordingSink::default()); + let mut access = fixture_with_sink(sink.clone()).await; + access.proxy_failure = Some(ProxyFailure::Transport); + let module = ReconfigurableModule::new(access, ID); + + assert_eq!( + module + .proxy(&serde_json::json!({ "route": "transport-failure" })) + .await + .unwrap_err(), + ModuleCallError::TransportFailed + ); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[(ID.to_owned(), "0.0.0".to_owned(), Reason::TransportFailed)] + ); +} + +#[tokio::test] +async fn resolver_load_failure_is_reported_once_with_its_resolution_reason() { + let sink = Arc::new(RecordingSink::default()); + let reporter = Arc::new(FailureReporter::new(sink.clone())); + let resolver = crate::module::Resolver::new(crate::module::ResolverInputs { + enabled: true, + install_dir: None, + allow_download: false, + bundled_dir: None, + sources: std::collections::HashMap::new(), + linked: Arc::new(crate::module::LinkedRegistry::new()), + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![&FAILED_RESOLVER_RECORD], + terminal_marker: String::new(), + runtime: Arc::new(|| { + Box::pin(async { Err(crate::Error::transport("runtime not configured")) }) + }), + module_config: Arc::new(|_| serde_json::Value::Null), + async_module_config: None, + reporter, + }); + let module = ReconfigurableModule::new(resolver, FAILED_RESOLVER_RECORD.id); + + assert_eq!( + module.proxy(&serde_json::Value::Null).await.unwrap_err(), + ModuleCallError::Unavailable + ); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[( + FAILED_RESOLVER_RECORD.id.to_owned(), + FAILED_RESOLVER_RECORD.version.to_owned(), + Reason::ResolutionFailed, + )] + ); +} + +#[tokio::test] +async fn resolver_reconfigures_a_lazy_linked_module_before_its_first_call() { + let runtime: &'static crate::module::ModuleRuntime = Box::leak(Box::new( + crate::module::ModuleRuntime::start(&crate::module::RuntimeNames::default(), None).unwrap(), + )); + let mut manifest = fixture_manifest(); + manifest.lazy_init = true; + runtime + .host() + .attach_linked_with_config( + crate::module::LinkedModule { + descriptor: TbAbiDescriptor::current(ID, "0.0.0"), + manifest, + init: fixture_init, + init_with_setup: None, + }, + serde_json::Value::Null, + ) + .unwrap(); + let resolver = crate::module::Resolver::new(crate::module::ResolverInputs { + enabled: true, + install_dir: None, + allow_download: false, + bundled_dir: None, + sources: std::collections::HashMap::new(), + linked: Arc::new(crate::module::LinkedRegistry::new()), + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![&RECORD], + terminal_marker: String::new(), + runtime: Arc::new(move || Box::pin(async move { Ok(runtime) })), + module_config: Arc::new(|_| serde_json::Value::Null), + async_module_config: None, + reporter: Arc::new(FailureReporter::new(Arc::new(Quiet))), + }); + resolver.ensure_loaded(ID).await.unwrap(); + assert_eq!(runtime.host().list()[0].state, ModuleState::Resolved); + let module = ReconfigurableModule::new(resolver, ID); + let config = serde_json::json!({ "credential": "private-before-first-call" }); + + let proxy = module + .proxy(&config) + .await + .expect("lazy configuration should be applied before the first business call"); + assert_eq!(read_config(&proxy).await, config); +} + +#[tokio::test] +async fn member_second_load_failure_preserves_resolution_report_reason() { + let _test_guard = FIXTURE_LOCK.lock().await; + let sink = Arc::new(RecordingSink::default()); + let mut access = fixture_with_sink(sink.clone()).await; + access.fail_ensure_loaded_at = Some(2); + let module = + ReconfigurableModule::new(access, ID).with_strategy(Reconfigure::Member("Configure")); + + assert_eq!( + module + .proxy(&serde_json::json!({ "route": "second-load" })) + .await + .unwrap_err(), + ModuleCallError::Unavailable + ); + assert_eq!( + sink.0.lock().unwrap().as_slice(), + &[( + ID.to_owned(), + RECORD.version.to_owned(), + Reason::ResolutionFailed + )] + ); +} + +#[tokio::test] +async fn member_strategy_applies_config_through_the_linked_fixture_bus() { + let _test_guard = FIXTURE_LOCK.lock().await; + let access = fixture().await; + let calls = access.reinitialize_calls.clone(); + let module = + ReconfigurableModule::new(access, ID).with_strategy(Reconfigure::Member("Configure")); + let config = serde_json::json!({ "route": "member" }); + let proxy = module.proxy(&config).await.unwrap(); + assert_eq!(read_config(&proxy).await, config); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn canceled_member_reconfigure_fences_late_completion_and_cannot_be_reopened() { + let _test_guard = FIXTURE_LOCK.lock().await; + let (module, observer, entered, release, finished) = blocked_member_fixture(None).await; + let first_config = serde_json::json!({ "route": "first", "hold_member": true }); + let first_module = module.clone(); + let first = tokio::spawn(async move { first_module.proxy(&first_config).await }); + tokio::task::spawn_blocking(move || entered.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(MEMBER_CONFIG_CALLS.load(Ordering::SeqCst), 1); + + first.abort(); + assert!(first.await.unwrap_err().is_cancelled()); + let second_config = serde_json::json!({ "route": "second" }); + assert_eq!( + module.proxy(&second_config).await.unwrap_err(), + ModuleCallError::Unavailable + ); + assert_eq!(MEMBER_CONFIG_CALLS.load(Ordering::SeqCst), 1); + + module.invalidate(); + module.record_loaded_with(&second_config).unwrap(); + assert_eq!( + module.proxy(&second_config).await.unwrap_err(), + ModuleCallError::Unavailable + ); + assert_eq!(MEMBER_CONFIG_CALLS.load(Ordering::SeqCst), 1); + + release.send(()).unwrap(); + tokio::task::spawn_blocking(move || finished.recv()) + .await + .unwrap() + .unwrap(); + let raw_proxy = observer.proxy(BUS, PATH, BUS).unwrap(); + assert_eq!( + raw_proxy + .call::("ReadConfig", ()) + .await + .unwrap(), + serde_json::json!({ "route": "first", "hold_member": true }) + ); + assert_eq!( + module.proxy(&second_config).await.unwrap_err(), + ModuleCallError::Unavailable + ); + assert_eq!(MEMBER_CONFIG_CALLS.load(Ordering::SeqCst), 1); +} + +#[tokio::test(start_paused = true)] +async fn timed_out_member_reconfigure_fences_late_completion() { + let _test_guard = FIXTURE_LOCK.lock().await; + let (module, observer, entered, release, finished) = + blocked_member_fixture(Some(Duration::from_millis(20))).await; + let first_config = serde_json::json!({ "route": "timed-out", "hold_member": true }); + let first_module = module.clone(); + let first = tokio::spawn(async move { first_module.proxy(&first_config).await }); + tokio::task::spawn_blocking(move || entered.recv()) + .await + .unwrap() + .unwrap(); + tokio::time::advance(Duration::from_millis(20)).await; + assert_eq!( + first.await.unwrap().unwrap_err(), + ModuleCallError::ModuleFault + ); + assert_eq!(MEMBER_CONFIG_CALLS.load(Ordering::SeqCst), 1); + + let second_config = serde_json::json!({ "route": "after-timeout" }); + assert_eq!( + module.proxy(&second_config).await.unwrap_err(), + ModuleCallError::Unavailable + ); + assert_eq!(MEMBER_CONFIG_CALLS.load(Ordering::SeqCst), 1); + + release.send(()).unwrap(); + tokio::task::spawn_blocking(move || finished.recv()) + .await + .unwrap() + .unwrap(); + tokio::time::resume(); + let raw_proxy = observer.proxy(BUS, PATH, BUS).unwrap(); + assert_eq!( + raw_proxy + .call::("ReadConfig", ()) + .await + .unwrap(), + serde_json::json!({ "route": "timed-out", "hold_member": true }) + ); + assert_eq!( + module.proxy(&second_config).await.unwrap_err(), + ModuleCallError::Unavailable + ); + assert_eq!(MEMBER_CONFIG_CALLS.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn member_strategy_refuses_an_unattested_recipient_before_delivery() { + let _test_guard = FIXTURE_LOCK.lock().await; + let (access, calls) = fixture_with_unattested_destination().await; + assert!(access.unattested_service.is_some()); + let module = + ReconfigurableModule::new(access, ID).with_strategy(Reconfigure::Member("Configure")); + let result = module + .proxy(&serde_json::json!({ "token": "private" })) + .await; + assert_eq!(result.unwrap_err(), ModuleCallError::ModuleFault); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn an_exclusive_guard_keeps_the_config_stable_until_the_bus_call_finishes() { + let _test_guard = FIXTURE_LOCK.lock().await; + let access = fixture().await; + let module = Arc::new(ReconfigurableModule::new(access, ID)); + let first = serde_json::json!({ "route": "first" }); + let second = serde_json::json!({ "route": "second" }); + let (proxy_ready_tx, proxy_ready_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let first_module = module.clone(); + let first_config = first.clone(); + let first_task = tokio::spawn(async move { + let mut guard = first_module.exclusive().await; + let proxy = guard.proxy(&first_config).await.unwrap(); + proxy_ready_tx.send(()).unwrap(); + let _ = release_rx.await; + read_config(&proxy).await + }); + proxy_ready_rx.await.unwrap(); + let second_module = module.clone(); + let second_config = second.clone(); + let second_task = + tokio::spawn(async move { second_module.proxy(&second_config).await.unwrap() }); + tokio::task::yield_now().await; + release_tx.send(()).unwrap(); + assert_eq!(first_task.await.unwrap(), first); + let second_proxy = second_task.await.unwrap(); + assert_eq!(read_config(&second_proxy).await, second); +} + +#[path = "reconfigure_policy_tests.rs"] +mod policy; diff --git a/crates/tinybus/src/module/release.rs b/crates/tinybus/src/module/release.rs index e4138f19..8751e9c3 100644 --- a/crates/tinybus/src/module/release.rs +++ b/crates/tinybus/src/module/release.rs @@ -15,6 +15,10 @@ pub struct ReleaseAsset<'a> { pub archive: &'a str, /// Lowercase hex SHA-256 of the archive, pinned by the host. pub sha256: &'a str, + /// Lowercase hex SHA-256 of the extracted library, from the release + /// checksum manifest. `None` keeps older registries compatible but cannot + /// authorize a strict attested call. + pub library_sha256: Option<&'a str>, } /// A first-party module a host knows how to load. diff --git a/crates/tinybus/src/module/release_tests.rs b/crates/tinybus/src/module/release_tests.rs index 639c2471..307f60bf 100644 --- a/crates/tinybus/src/module/release_tests.rs +++ b/crates/tinybus/src/module/release_tests.rs @@ -3,28 +3,34 @@ use super::*; static ASSETS: [ReleaseAsset<'static>; 2] = [ ReleaseAsset { host_key: "ubuntu-24.04-x86_64", - archive: "tinybus-linux.tar.gz", - sha256: "abc123", + archive: "tinybus-module-linux-x86_64.tar.gz", + sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + library_sha256: None, }, ReleaseAsset { host_key: "windows-2025-x86_64", - archive: "tinybus-windows.zip", - sha256: "def456", + archive: "tinybus-module-windows-x86_64.zip", + sha256: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + library_sha256: Some("123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0"), }, ]; static RECORD: ReleaseRecord = ReleaseRecord { - id: "tinybus-test", - bus_name: "ai.tinyhumans.test.TinyBus", - object_path: "/ai/tinyhumans/test/TinyBus", - version: "1.0.0", - release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v1.0.0", + id: "release-lookup", + bus_name: "ai.tinyhumans.test.ReleaseLookup", + object_path: "/ai/tinyhumans/test/ReleaseLookup", + version: "1.2.3", + release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v1.2.3", assets: &ASSETS, }; #[test] -/// Resolves assets only for an exact supported host key. -fn asset_lookup_matches_the_exact_host_key_and_rejects_unknown_hosts() { - assert_eq!(RECORD.asset_for(ASSETS[0].host_key), Some(&ASSETS[0])); - assert_eq!(RECORD.asset_for("unknown-host"), None); +fn an_asset_is_selected_only_for_its_exact_host_key() { + let asset = RECORD.asset_for("ubuntu-24.04-x86_64"); + assert_eq!(asset, Some(&ASSETS[0])); + assert_eq!(asset.map(|asset| asset.archive), Some(ASSETS[0].archive)); + assert_eq!(asset.map(|asset| asset.sha256), Some(ASSETS[0].sha256)); + assert_eq!(RECORD.asset_for(ASSETS[1].host_key), Some(&ASSETS[1])); + assert!(RECORD.asset_for("ubuntu-22.04-x86_64").is_none()); + assert!(RECORD.asset_for("unknown-host").is_none()); } diff --git a/crates/tinybus/src/module/remembered_hash.rs b/crates/tinybus/src/module/remembered_hash.rs index 81df13fd..f4a9e6ed 100644 --- a/crates/tinybus/src/module/remembered_hash.rs +++ b/crates/tinybus/src/module/remembered_hash.rs @@ -75,18 +75,18 @@ mod imp { pub(super) fn hex(file: File, age: Duration) -> io::Result<(String, bool)> { let before = identity(&file)?; let eligible = quiet_for(&before, age); - if eligible && let Some(known) = memory().lock().ok().and_then(|m| m.get(&before).cloned()) - { - return Ok((known, true)); + if eligible { + if let Some(known) = memory().lock().ok().and_then(|m| m.get(&before).cloned()) { + return Ok((known, true)); + } } let digest = crate::module::hash::file_hex(&file)?; // Hashed bytes are only attributable to this identity if the file did // not change underneath the read. - if eligible - && identity(&file).is_ok_and(|after| after == before) - && let Ok(mut memory) = memory().lock() - { - memory.insert(before, digest.clone()); + if eligible && identity(&file).is_ok_and(|after| after == before) { + if let Ok(mut memory) = memory().lock() { + memory.insert(before, digest.clone()); + } } Ok((digest, false)) } diff --git a/crates/tinybus/src/module/resolve.rs b/crates/tinybus/src/module/resolve.rs index dfa577e3..229165d7 100644 --- a/crates/tinybus/src/module/resolve.rs +++ b/crates/tinybus/src/module/resolve.rs @@ -25,18 +25,15 @@ //! outcome; two modules never queue behind each other. //! [`Resolver::ensure_loaded_within`] bounds the wait, never the resolution. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; -use std::sync::Arc; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use serde::{Deserialize, Serialize}; -use crate::Proxy; -use crate::module::LinkedModule; -use crate::module::client::{AccessFuture, ModuleAccess, ProxyFailure}; use crate::module::failure::{FailureReporter, Reason}; use crate::module::host::{ModuleInfo, ModuleState}; use crate::module::manifest::ModuleManifest; @@ -44,10 +41,49 @@ use crate::module::platform::host_candidates; use crate::module::resolution::{self, Claim, Resolution, ResolutionState, Waited}; use crate::module::runtime::ModuleRuntime; use crate::module::{ - HostCallbackBinding, ReleaseAsset, ReleasePlan, ReleaseRecord, load_first_admitted, - prune_stale_versions, + Availability, HostCallbackBinding, LinkedModule, ReleaseAsset, ReleasePlan, ReleaseRecord, + SearchPathConfig, load_first_admitted, prune_stale_versions, }; +type HostCallbackFactory = Arc< + dyn Fn( + usize, + ) -> Pin< + Box< + dyn Future, serde_json::Value), String>> + + Send, + >, + > + Send + + Sync, +>; + +#[derive(Clone)] +enum CallbackLoad { + Single(Arc, serde_json::Value), + PerAttempt { + factory: HostCallbackFactory, + selected: Arc>>>, + }, +} + +impl CallbackLoad { + async fn prepare( + &self, + attempt: usize, + ) -> Result<(Arc, serde_json::Value), String> { + match self { + Self::Single(binding, config) => Ok((Arc::clone(binding), config.clone())), + Self::PerAttempt { factory, .. } => factory(attempt).await, + } + } + + fn retain_selected(&self, binding: Arc) { + if let Self::PerAttempt { selected, .. } = self { + *selected.lock().expect("callback selection lock") = Some(binding); + } + } +} + fn recover_admission( result: Result<(), String>, modules: &[ModuleInfo], @@ -114,17 +150,22 @@ pub(crate) fn resolve( manifests: &[ModuleManifest], initially_available: &HashSet, ) -> LoadOrder { - let duplicate_modules = duplicates(manifests.iter().map(|manifest| &manifest.module.name)); - let duplicate_bus_names = duplicates(manifests.iter().map(|manifest| &manifest.bus_name)); + let duplicate_modules = duplicates( + manifests + .iter() + .map(|manifest| manifest.module.name.as_str()), + ); + let duplicate_bus_names = + duplicates(manifests.iter().map(|manifest| manifest.bus_name.as_str())); let mut unresolved = Vec::new(); let mut pending = Vec::new(); for (index, manifest) in manifests.iter().enumerate() { - if duplicate_modules.contains(&manifest.module.name) { + if duplicate_modules.contains(manifest.module.name.as_str()) { unresolved.push(( index, "two artifacts declare the same module name".to_string(), )); - } else if duplicate_bus_names.contains(&manifest.bus_name) { + } else if duplicate_bus_names.contains(manifest.bus_name.as_str()) { unresolved.push((index, "two modules claim the same bus name".to_string())); } else { pending.push(index); @@ -198,7 +239,7 @@ pub(crate) fn resolve( LoadOrder { order, unresolved } } -fn duplicates(values: impl Iterator) -> HashSet { +fn duplicates<'a>(values: impl Iterator) -> HashSet<&'a str> { let mut counts = HashMap::new(); for value in values { *counts.entry(value).or_insert(0usize) += 1; @@ -228,27 +269,6 @@ pub enum ModuleSource { Linked, } -/// A module's state as a host reports it. -/// -/// Coarser than [`super::ModuleState`]: a caller wants to know whether it can -/// use the module, not which admission step it is on. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Availability { - /// Known to this build, not loaded yet. - Available, - /// Being downloaded, verified or initialised. - Loading, - /// Serving its interface. - Ready, - /// Cannot be loaded in this process, and will not be retried. - Failed, - /// No artifact is published for this host, or modules are disabled. - Unsupported, - /// Switched off by the host's [`ModuleGate`]. - Disabled, -} - /// Why a bounded [`Resolver::ensure_loaded_within`] did not end with the module /// serving. #[derive(Debug, Clone, PartialEq, Eq)] @@ -280,12 +300,26 @@ pub type RuntimeFuture = Pin> + Send>>; /// Starts (or returns) the host's process-wide [`ModuleRuntime`], lazily, so a -/// refusal or a cached outcome never starts a broker. +/// refusal or a cached outcome never starts a broker. The SDK polls this future +/// on a dedicated startup executor, independent of any caller runtime. Tasks +/// that must outlive the provider future must be owned by the returned +/// [`ModuleRuntime`] or another retained host executor; detached tasks on this +/// temporary executor stop when startup finishes. pub type RuntimeProvider = Arc RuntimeFuture + Send + Sync>; /// Builds the configuration blob handed to a module when it is initialised. pub type ModuleConfigProvider = Arc serde_json::Value + Send + Sync>; +/// Asynchronous per-module initialisation configuration. When supplied, this +/// provider takes precedence over [`ModuleConfigProvider`] during resolution. +/// The synchronous provider remains available for hosts whose configuration +/// is already in memory. +pub type AsyncModuleConfigProvider = Arc< + dyn for<'a> Fn(&'a str) -> Pin + Send + 'a>> + + Send + + Sync, +>; + /// Decides, per module id, whether host policy switches the module off. Returns /// a short label (e.g. the setting that did it) when it is off, which the /// resolver returns as [`LoadError::Disabled`] before it resolves, reports or @@ -300,37 +334,105 @@ pub type ResolvedHook = Arc; /// `fn` so a registration carries no state. pub type LinkedFactory = fn() -> crate::Result; -impl ModuleAccess for Resolver { - fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { - Resolver::find(self, id) +/// Process-wide set of module factories compiled into a host executable. +/// Registration replaces a prior factory for the same id, allowing repeated +/// host installation to remain idempotent. +pub struct LinkedRegistry { + factories: Mutex>, +} + +impl LinkedRegistry { + /// Create an empty registry, usually for isolated host or test wiring. + #[must_use] + pub fn new() -> Self { + Self { + factories: Mutex::new(BTreeMap::new()), + } } - fn enabled(&self) -> bool { - Resolver::enabled(self) + /// The process-global registry used by a host binary. + #[must_use] + pub fn global() -> Arc { + static REGISTRY: OnceLock> = OnceLock::new(); + Arc::clone(REGISTRY.get_or_init(|| Arc::new(Self::new()))) } - fn reporter(&self) -> &FailureReporter { - Resolver::reporter(self) + fn factories(&self) -> std::sync::MutexGuard<'_, BTreeMap<&'static str, LinkedFactory>> { + self.factories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) } - fn ensure_loaded<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { - Box::pin(Resolver::ensure_loaded(self, id)) + /// Register a factory for a module id. A later registration replaces it. + pub fn register(&self, id: &'static str, factory: LinkedFactory) { + let replaced = self.factories().insert(id, factory).is_some(); + tracing::debug!(module = id, replaced, "linked module factory registered"); } - fn proxy<'a>( - &'a self, - bus_name: &'a str, - object_path: &'a str, - ) -> AccessFuture<'a, Result> { - Box::pin(async move { - let runtime = self.runtime().await.map_err(|_| ProxyFailure::Transport)?; - runtime - .proxy(bus_name, object_path) - .map_err(|_| ProxyFailure::Contract) - }) + /// Whether this registry has a factory for `id`. + #[must_use] + pub fn is_registered(&self, id: &str) -> bool { + self.factories().contains_key(id) } + + /// Return the registered factory for `id`, if one exists. + #[must_use] + pub fn get(&self, id: &str) -> Option { + self.factories().get(id).copied() + } +} + +impl Default for LinkedRegistry { + fn default() -> Self { + Self::new() + } +} + +static BUNDLED_RELEASES: OnceLock = OnceLock::new(); + +/// Register the installer-owned directory containing release archives. +/// Its files still pass the ordinary digest and module admission checks. +pub fn set_bundled_releases_dir(path: PathBuf) -> Result<(), PathBuf> { + BUNDLED_RELEASES.set(path) +} + +/// Resolve the installer-owned archive directory from the registered path, +/// the explicitly named environment variable, or `bundled-modules/` beside +/// the current executable, in that order. Only existing directories qualify. +#[must_use] +pub fn resolve_bundled_dir(env_var_name: &str) -> Option { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(Path::to_path_buf)); + resolve_bundled_dir_from( + BUNDLED_RELEASES.get().cloned(), + std::env::var_os(env_var_name).map(PathBuf::from), + exe_dir, + ) } +fn resolve_bundled_dir_from( + registered: Option, + from_env: Option, + exe_dir: Option, +) -> Option { + let found = [ + registered, + from_env, + exe_dir.map(|dir| dir.join("bundled-modules")), + ] + .into_iter() + .flatten() + .find(|dir| dir.is_dir()); + if let Some(dir) = &found { + tracing::debug!(path = %dir.display(), "bundled module directory resolved"); + } + found +} + +#[path = "resolve_access.rs"] +pub(super) mod access; + /// Plain inputs a host builds a [`Resolver`] from. pub struct ResolverInputs { /// Whether modules are enabled at all. @@ -344,9 +446,11 @@ pub struct ResolverInputs { /// Per-module artifact sources. A module absent here, or `Pinned`, loads the /// pinned release. pub sources: HashMap, - /// Factories for modules linked into the host, consulted for a - /// [`ModuleSource::Linked`] source. - pub linked: HashMap, + /// Registry of factories for `Linked` sources. The host binary uses + /// [`LinkedRegistry::global`]; tests and embedders may inject their own. + pub linked: Arc, + /// Search path policy for the module host. `None` means no implicit paths. + pub search_paths: Option, /// Library file stems the search-path rung must not open, e.g. the /// libraries of modules the host has switched off. pub search_skip: Vec, @@ -363,6 +467,9 @@ pub struct ResolverInputs { pub runtime: RuntimeProvider, /// Per-module initialisation configuration. pub module_config: ModuleConfigProvider, + /// Optional asynchronous configuration provider for secret-backed hosts. + /// When present, this is awaited only for a module actually being loaded. + pub async_module_config: Option, /// Terminal-outcome reporting. Share one across the process. pub reporter: Arc, } @@ -389,6 +496,13 @@ impl Resolver { } } + async fn module_config(&self, id: &str) -> serde_json::Value { + match &self.inner.async_module_config { + Some(provider) => provider(id).await, + None => (self.inner.module_config)(id), + } + } + /// The record for `id`, if the registry has one. #[must_use] pub fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { @@ -413,7 +527,11 @@ impl Resolver { /// /// Whatever the host's provider returns when the runtime cannot start. pub async fn runtime(&self) -> crate::Result<&'static ModuleRuntime> { - (self.inner.runtime)().await + let runtime = (self.inner.runtime)().await?; + runtime + .host() + .configure_search_path_config(self.inner.search_paths.clone()); + Ok(runtime) } /// Report a terminal outcome for `record`, once per process. @@ -439,32 +557,112 @@ impl Resolver { /// /// A message fit for a user or a model when the module cannot be loaded. pub async fn ensure_loaded(&self, id: &str) -> Result<(), String> { - self.ensure_loaded_within(id, None) + self.ensure_loaded_inner(id, None, None) .await .map_err(LoadError::into_message) } - /// Resolve a local override while binding one prepared host callback set - /// to the exact admitted module before initialization. The configuration is - /// frozen with that binding. This path deliberately refuses linked, - /// search-path, and release-cache sources until their admission paths can - /// preserve the same one-use peer binding; use `ensure_loaded` for ordinary - /// modules, which receive no callback authority. + /// Resolve a local artifact while binding one prepared host callback set to + /// the exact registry identity before initialization. The capability is + /// retained by the admitted module for its lifetime. Linked, search-path, + /// and release-cache sources fail closed until their admission paths can + /// preserve the same identity binding. /// - /// The binding must have been prepared by this resolver's runtime host and - /// all declared callback interfaces must already be served. A callback - /// resolution cannot join a generic in-flight or already-loaded module. + /// A callback load cannot join a generic in-flight or already-loaded + /// resolution. Its binding must belong to this resolver's host and all + /// declared endpoints must already be served. + /// + /// # Errors + /// + /// Returns a safe load error if the module is unavailable, the binding is + /// invalid, or the configured source cannot preserve callback identity. pub async fn ensure_loaded_with_host_callbacks( &self, id: &str, binding: Arc, - config: serde_json::Value, + module_config: serde_json::Value, ) -> Result<(), String> { - self.ensure_loaded_inner(id, None, Some((binding, config))) + self.ensure_loaded_inner(id, None, Some(CallbackLoad::Single(binding, module_config))) .await .map_err(LoadError::into_message) } + /// Resolve a pinned release while preparing a fresh callback binding for + /// each candidate artifact. Failed candidates consume and revoke only their + /// own binding; the returned capability is retained by the exact admitted + /// artifact. Linked, search-path and generic in-flight loads fail closed. + pub async fn ensure_loaded_with_host_callback_factory( + &self, + id: &str, + factory: F, + ) -> Result, String> + where + F: Fn(usize) -> Fut + Send + Sync + 'static, + Fut: Future, serde_json::Value), String>> + + Send + + 'static, + { + let factory: HostCallbackFactory = Arc::new(move |attempt| Box::pin(factory(attempt))); + self.ensure_loaded_with_host_callback_factory_inner(id, factory) + .await + } + + async fn ensure_loaded_with_host_callback_factory_inner( + &self, + id: &str, + factory: HostCallbackFactory, + ) -> Result, String> { + let selected = Arc::new(Mutex::new(None)); + self.ensure_loaded_inner( + id, + None, + Some(CallbackLoad::PerAttempt { + factory, + selected: Arc::clone(&selected), + }), + ) + .await + .map_err(LoadError::into_message)?; + + if let Some(binding) = selected.lock().expect("callback selection lock").clone() { + return Ok(binding); + } + let record = self + .find(id) + .ok_or_else(|| format!("unknown module '{id}'"))?; + let runtime = self + .runtime() + .await + .map_err(|_| format!("module '{id}' is unavailable: the module bus could not start"))?; + runtime + .host() + .retained_host_callback_binding(record.bus_name) + .ok_or_else(|| format!("module '{id}' has no retained callback binding")) + } + + /// Resolve, initialize and wait for a module without sending a business + /// message. Strict attested calls use this host-owned barrier before + /// querying the broker for a recipient proof. + pub async fn prepare_attested(&self, id: &str) -> Result<(), String> { + self.ensure_loaded(id).await?; + let record = self + .find(id) + .ok_or_else(|| "module is unavailable".to_string())?; + let runtime = self + .runtime() + .await + .map_err(|_| "module bus is unavailable".to_string())?; + // Keep native initialization on the process-lifetime module runtime. + // A caller's deadline may cancel its wait, but must not cancel the + // initializer or strand the module between `INITIALIZING` and `READY`. + let host = runtime.host(); + runtime + .spawn(async move { host.ensure_ready(record.bus_name).await }) + .await + .map_err(|_| "module readiness task did not finish".to_string())? + .map_err(|_| "module did not reach READY".to_string()) + } + /// [`Self::ensure_loaded`] with a bound on how long this caller waits. /// /// The bound is on the *wait*, never on the resolution: a caller that gives @@ -487,7 +685,7 @@ impl Resolver { &self, id: &str, within: Option, - callbacks: Option<(Arc, serde_json::Value)>, + callbacks: Option, ) -> Result<(), LoadError> { if !self.inner.enabled { if let Some(record) = self.find(id) { @@ -509,50 +707,112 @@ impl Resolver { return Err(LoadError::Disabled(label)); } - if let Some((binding, _)) = callbacks.as_ref() { - if !matches!( - self.inner.sources.get(id), - Some(ModuleSource::LocalFile { .. }) - ) { - return Err(LoadError::Failed(format!( - "module '{id}' host callbacks require a verified local-file source; \ - release-cache and search-path callback loading are unavailable" - ))); - } + if let Some(callbacks) = callbacks.as_ref() { let runtime = self.runtime().await.map_err(|_| { LoadError::Failed(format!( "module '{id}' is unavailable: the module bus could not start" )) })?; - runtime - .host() - .validate_host_callback_binding(binding) - .map_err(|error| { - LoadError::Failed(format!( - "module '{id}' callback binding is invalid: {error}" - )) - })?; - if runtime - .host() - .list() - .iter() - .any(|info| info.manifest.bus_name.as_str() == record.bus_name) - { - return Err(LoadError::Failed(format!( - "module '{id}' is already loaded without this callback binding" - ))); + match callbacks { + CallbackLoad::Single(binding, _) => { + if !matches!( + self.inner.sources.get(id), + Some(ModuleSource::LocalFile { .. }) + ) { + return Err(LoadError::Failed(format!( + "module '{id}' host callbacks require a verified local-file source" + ))); + } + if runtime + .host() + .list() + .iter() + .any(|info| info.manifest.bus_name.as_str() == record.bus_name) + { + if runtime + .host() + .retains_host_callback_binding(record.bus_name, binding) + { + return Ok(()); + } + return Err(LoadError::Failed(format!( + "module '{id}' is already loaded without this callback binding" + ))); + } + runtime + .host() + .validate_host_callback_binding(binding) + .map_err(|error| { + LoadError::Failed(format!( + "module '{id}' callback binding is invalid: {error}" + )) + })?; + } + CallbackLoad::PerAttempt { selected, .. } => { + if !matches!( + self.inner.sources.get(id), + None | Some(ModuleSource::Pinned | ModuleSource::LocalFile { .. }) + ) { + return Err(LoadError::Failed(format!( + "module '{id}' callback source cannot preserve admitted identity" + ))); + } + if runtime + .host() + .list() + .iter() + .any(|info| info.manifest.bus_name.as_str() == record.bus_name) + { + let binding = runtime + .host() + .retained_host_callback_binding(record.bus_name) + .ok_or_else(|| { + LoadError::Failed(format!( + "module '{id}' is already loaded without a callback binding" + )) + })?; + *selected.lock().expect("callback selection lock") = Some(binding); + return Ok(()); + } + } } } let receiver = match resolution::global().claim(id) { Claim::Done(Resolution::Ready) => { - return if callbacks.is_some() { - Err(LoadError::Failed(format!( - "module '{id}' is already loaded without this callback binding" - ))) - } else { - self.still_serving(record).await - }; + if let Some(CallbackLoad::Single(binding, _)) = callbacks.as_ref() { + let runtime = self.runtime().await.map_err(|_| { + LoadError::Failed(format!( + "module '{id}' is unavailable: the module bus could not start" + )) + })?; + if runtime + .host() + .retains_host_callback_binding(record.bus_name, binding) + { + return self.still_serving(record).await; + } + return Err(LoadError::Failed(format!( + "module '{id}' was loaded without the requested callback binding" + ))); + } + if let Some(CallbackLoad::PerAttempt { selected, .. }) = callbacks.as_ref() { + let runtime = self.runtime().await.map_err(|_| { + LoadError::Failed(format!( + "module '{id}' is unavailable: the module bus could not start" + )) + })?; + let binding = runtime + .host() + .retained_host_callback_binding(record.bus_name) + .ok_or_else(|| { + LoadError::Failed(format!( + "module '{id}' was loaded without a callback binding" + )) + })?; + *selected.lock().expect("callback selection lock") = Some(binding); + } + return self.still_serving(record).await; } Claim::Done(Resolution::Failed(reason)) => { // The original resolution may have completed before a reporting @@ -570,21 +830,66 @@ impl Resolver { receiver } Claim::Run { sender, receiver } => { - self.start_resolution(record, sender, callbacks.clone()) - .await; + // Runtime startup can itself be slow, and a task spawned on the + // caller's executor would be cancelled if that executor shuts + // down. Give this one startup attempt its own executor thread; + // once the module runtime exists, start_resolution transfers + // the actual load work to that process-owned runtime. + let resolver = self.clone(); + let task_sender = sender.clone(); + let startup_callbacks = callbacks.clone(); + let thread = std::thread::Builder::new() + .name("tinybus-resolution-startup".to_string()) + .spawn(move || { + let startup_runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + match startup_runtime { + Ok(runtime) => { + runtime.block_on(resolver.start_resolution( + record, + task_sender, + startup_callbacks, + )); + } + Err(error) => { + let reason = format!( + "module '{}' is unavailable: the module startup executor could not start ({error})", + record.id + ); + resolver.report(record, Reason::ResolutionFailed); + resolution::global().complete( + record.id, + Resolution::Failed(reason), + task_sender, + ); + } + } + }); + if let Err(error) = thread { + let reason = format!( + "module '{}' is unavailable: the module startup executor could not start ({error})", + record.id + ); + self.report(record, Reason::ResolutionFailed); + resolution::global().complete(record.id, Resolution::Failed(reason), sender); + } receiver } }; match resolution::global().wait(id, receiver, within).await { Waited::Ready => { self.still_serving(record).await?; - if let Some((binding, _)) = callbacks.as_ref() { + if let Some(CallbackLoad::Single(binding, _)) = callbacks.as_ref() { let runtime = self.runtime().await.map_err(|_| { LoadError::Failed(format!( "module '{id}' is unavailable: the module bus could not start" )) })?; - if !runtime.host().retains_host_callback_binding(binding) { + if !runtime + .host() + .retains_host_callback_binding(record.bus_name, binding) + { return Err(LoadError::Failed(format!( "module '{id}' was loaded without the requested callback binding" ))); @@ -680,15 +985,16 @@ impl Resolver { ) } - /// Run the resolution for `record` as a process-lifetime task and record its - /// outcome. A caller runtime that shuts down mid-load must not cancel a - /// resolution other callers are waiting on. Without a module runtime there is - /// nothing to load into, so the outcome is recorded inline instead. + /// Run the resolution for `record` on its startup executor and record its + /// outcome. Once the module runtime is available, the resolution work moves + /// there so caller runtime teardown cannot cancel work other callers await. + /// Without a module runtime there is nothing to load into, so the outcome is + /// recorded inline instead. async fn start_resolution( &self, record: &'static ReleaseRecord, sender: tokio::sync::watch::Sender>, - callbacks: Option<(Arc, serde_json::Value)>, + callbacks: Option, ) { let this = self.clone(); let id = record.id; @@ -739,15 +1045,15 @@ impl Resolver { async fn ladder( &self, record: &'static ReleaseRecord, - callbacks: Option<(Arc, serde_json::Value)>, + callbacks: Option, ) -> Result<(), String> { - self.load_rungs(record, callbacks).await?; let runtime = self.runtime().await.map_err(|_| { format!( "module '{}' is unavailable: the module bus could not start", record.id ) })?; + self.load_rungs(runtime, record, callbacks).await?; match runtime.host().settle(record.bus_name, SETUP_DEADLINE).await { Some(info) => setup_outcome(&info, record.id), None => Ok(()), @@ -757,23 +1063,17 @@ impl Resolver { /// Load: loaded, override, search path, release cache, prune. async fn load_rungs( &self, + runtime: &'static ModuleRuntime, record: &'static ReleaseRecord, - callbacks: Option<(Arc, serde_json::Value)>, + callbacks: Option, ) -> Result<(), String> { - let runtime = self.runtime().await.map_err(|_| { - format!( - "module '{}' is unavailable: the module bus could not start", - record.id - ) - })?; - - // Already serving: loaded from the search path at boot, or by an earlier - // explicit local load. + // Already admitted under this exact registry identity: loaded from the + // search path at boot, or by an earlier explicit local load. if runtime .host() .list() .iter() - .any(|info| info.manifest.bus_name.as_str() == record.bus_name) + .any(|info| is_live_match(info, record)) { if callbacks.is_some() { return Err(format!( @@ -795,11 +1095,12 @@ impl Resolver { match self.inner.sources.get(record.id) { Some(ModuleSource::LocalFile { path }) => { let path = PathBuf::from(path); - let result = if let Some((binding, module_config)) = callbacks.clone() { + let result = if let Some(callbacks) = callbacks.as_ref() { + let (binding, module_config) = callbacks.prepare(0).await?; let host = runtime.host(); - let id = record.id; let handle = tokio::runtime::Handle::current(); - tokio::task::spawn_blocking(move || { + let retained_binding = Arc::clone(&binding); + let result = tokio::task::spawn_blocking(move || { handle.block_on(async move { host.load_file_with_host_callbacks_for( path, @@ -810,18 +1111,30 @@ impl Resolver { ) .await .map_err(|error| { - format!("module '{id}' callback load failed: {error}") + format!("module '{}' callback load failed: {error}", record.id) })?; host.retain_host_callback_binding(record.bus_name, binding) .map_err(|error| { - format!("module '{id}' callback retention failed: {error}") + format!( + "module '{}' callback retention failed: {error}", + record.id + ) }) }) }) .await - .map_err(|error| format!("module callback loader did not finish: {error}"))? + .map_err(|error| { + format!( + "module '{}' callback loader did not finish: {error}", + record.id + ) + })?; + if result.is_ok() { + callbacks.retain_selected(retained_binding); + } + result } else { - let module_config = (self.inner.module_config)(record.id); + let module_config = self.module_config(record.id).await; self.blocking(move || { Self::load_local(runtime, &path, record.id, module_config) }) @@ -836,8 +1149,8 @@ impl Resolver { record.id )); } - let factory = self.inner.linked.get(record.id).copied(); - let module_config = (self.inner.module_config)(record.id); + let factory = self.inner.linked.get(record.id); + let module_config = self.module_config(record.id).await; let result = self .blocking(move || Self::load_linked(runtime, record, factory, module_config)) .await; @@ -846,13 +1159,6 @@ impl Resolver { Some(ModuleSource::Pinned) | None => {} } - if callbacks.is_some() { - return Err(format!( - "module '{}' host callbacks require a verified local-file source; release-cache and search-path callback loading are unavailable", - record.id - )); - } - // The search path tinybus itself honours. A refused search-path artifact // is ordinary (most directories hold nothing), so errors are dropped and // only an enabled, nonterminal match on the registry identity counts. @@ -869,6 +1175,12 @@ impl Resolver { .await .unwrap_or(false); if found_on_search_path { + if callbacks.is_some() { + return Err(format!( + "module '{}' was loaded from a search path without the requested callback binding", + record.id + )); + } return Ok(()); } // A direct host load can win admission while the search-path rung is @@ -879,6 +1191,12 @@ impl Resolver { .iter() .any(|info| is_live_match(info, record)) { + if callbacks.is_some() { + return Err(format!( + "module '{}' was loaded without the requested callback binding", + record.id + )); + } return Ok(()); } @@ -893,11 +1211,39 @@ impl Resolver { return recover_admission(error, &runtime.host().list(), record); }; let allow_download = self.inner.allow_download; - let module_config = (self.inner.module_config)(record.id); + let module_config = self.module_config(record.id).await; let cache_root = root.clone(); let bundled = self.inner.bundled_dir.clone(); - let result = self - .blocking(move || { + let result = if let Some(callbacks) = callbacks.as_ref() { + let selected = callbacks; + let assets: Vec> = host_candidates() + .iter() + .filter_map(|key| record.asset_for(key)) + .copied() + .collect(); + let plan = ReleasePlan { + id: record.id, + version: record.version, + release_url: record.release_url, + assets: &assets, + install_root: &cache_root, + bundled_root: bundled.as_deref(), + allow_download, + }; + let (info, binding) = + crate::module::first_admitted::load_first_admitted_with_host_callbacks( + runtime.host(), + &plan, + |attempt| selected.prepare(attempt), + record.bus_name, + record.object_path, + ) + .await?; + selected.retain_selected(binding); + let _ = info; + Ok(()) + } else { + self.blocking(move || { Self::load_cached( runtime, record, @@ -907,7 +1253,8 @@ impl Resolver { bundled.as_deref(), ) }) - .await; + .await + }; if let Err(error) = result { return recover_admission(Err(error), &runtime.host().list(), record); } @@ -1058,3 +1405,11 @@ impl Resolver { #[cfg(test)] #[path = "resolve_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "resolve_test_support.rs"] +mod test_support; + +#[cfg(test)] +#[path = "resolve_attested_tests.rs"] +mod attested_tests; diff --git a/crates/tinybus/src/module/resolve_access.rs b/crates/tinybus/src/module/resolve_access.rs new file mode 100644 index 00000000..7b4f0cf8 --- /dev/null +++ b/crates/tinybus/src/module/resolve_access.rs @@ -0,0 +1,103 @@ +//! Adapter from the module resolver to configuration-aware module access. + +use super::Resolver; +use crate::Proxy; +use crate::module::ReleaseRecord; +use crate::module::client::{ + AccessFuture, ModuleAccess, ProxyFailure, ReadinessFailure, ReconfigureFailure, +}; +use crate::module::failure::FailureReporter; +use crate::module::resolution; + +pub(in crate::module) fn reconfigure_failure(error: &crate::Error) -> ReconfigureFailure { + match error { + crate::Error::ModuleUnavailable { state, .. } + if state.eq_ignore_ascii_case("initializing") => + { + ReconfigureFailure::Initializing + } + crate::Error::ModuleUnavailable { .. } => ReconfigureFailure::Unavailable, + crate::Error::MethodFailed { name, .. } if name == crate::Error::MODULE_UNAVAILABLE => { + ReconfigureFailure::Unavailable + } + _ => ReconfigureFailure::Failed, + } +} + +impl ModuleAccess for Resolver { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + Resolver::find(self, id) + } + + fn enabled(&self) -> bool { + Resolver::enabled(self) + } + + fn reporter(&self) -> &FailureReporter { + Resolver::reporter(self) + } + + fn ensure_loaded<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(Resolver::ensure_loaded(self, id)) + } + + fn ensure_loaded_within<'a>( + &'a self, + id: &'a str, + within: Option, + ) -> AccessFuture<'a, Result<(), ReadinessFailure>> { + Box::pin(async move { + match Resolver::ensure_loaded_within(self, id, within).await { + Ok(()) => Ok(()), + Err(crate::module::LoadError::StillLoading) => Err(ReadinessFailure::TimedOut), + Err( + crate::module::LoadError::Failed(_) | crate::module::LoadError::Disabled(_), + ) => Err(ReadinessFailure::Unavailable), + } + }) + } + + fn prepare_attested<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(Resolver::prepare_attested(self, id)) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + Box::pin(async move { + let runtime = self.runtime().await.map_err(|_| ProxyFailure::Transport)?; + runtime + .proxy(bus_name, object_path) + .map_err(|_| ProxyFailure::Contract) + }) + } + + fn reinitialize<'a>( + &'a self, + id: &'a str, + config: serde_json::Value, + ) -> AccessFuture<'a, Result<(), ReconfigureFailure>> { + Box::pin(async move { + let runtime = self + .runtime() + .await + .map_err(|_| ReconfigureFailure::Unavailable)?; + runtime + .connection() + .reinitialize_module(id, config) + .await + .map(|_| ()) + .map_err(|error| reconfigure_failure(&error)) + }) + } + + fn mark_faulted(&self, id: &str) { + resolution::global().mark_faulted(id, "module reconfiguration failed".to_owned()); + } +} + +#[cfg(test)] +#[path = "resolve_access_tests.rs"] +mod tests; diff --git a/crates/tinybus/src/module/resolve_access_tests.rs b/crates/tinybus/src/module/resolve_access_tests.rs new file mode 100644 index 00000000..79768e70 --- /dev/null +++ b/crates/tinybus/src/module/resolve_access_tests.rs @@ -0,0 +1,213 @@ +use super::*; +use crate::module::abi::{TB_BAD_ARGUMENT, TB_OK, TbAbiDescriptor, TbHostVtable, TbModuleVtable}; +use crate::module::failure::{FailureReport, FailureSink}; +use crate::module::manifest::{MANIFEST_SCHEMA, ModuleIdentity, ModuleManifest, PanicPolicy}; +use crate::module::resolution::{self, Claim, Resolution, ResolutionState}; +use crate::module::runtime::{ModuleRuntime, RuntimeNames}; +use crate::module::{LinkedModule, ReleaseRecord, ResolverInputs}; +use crate::name::{BusName, ObjectPath}; +use crate::version::Version; +use std::collections::HashMap; +use std::ffi::c_void; +use std::sync::{Arc, OnceLock}; + +const ID: &str = "resolveraccessreinit"; +const BUS: &str = "ai.tinyhumans.test.ResolverAccess"; +const PATH: &str = "/ai/tinyhumans/test/ResolverAccess"; +static RECORD: ReleaseRecord = ReleaseRecord { + id: ID, + bus_name: BUS, + object_path: PATH, + version: "0.0.0", + release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v0.0.0", + assets: &[], +}; + +struct Quiet; +impl FailureSink for Quiet { + fn capture(&self, _: &FailureReport<'_>) {} +} + +fn runtime() -> &'static ModuleRuntime { + static RUNTIME: OnceLock = OnceLock::new(); + RUNTIME.get_or_init(|| ModuleRuntime::start(&RuntimeNames::default(), None).unwrap()) +} + +fn resolver(runtime_ready: bool) -> Resolver { + let runtime: crate::module::RuntimeProvider = if runtime_ready { + Arc::new(|| Box::pin(async { Ok(runtime()) })) + } else { + Arc::new(|| Box::pin(async { Err(crate::Error::transport("runtime unavailable")) })) + }; + Resolver::new(ResolverInputs { + enabled: true, + install_dir: None, + allow_download: false, + bundled_dir: None, + sources: HashMap::new(), + linked: Arc::new(crate::module::LinkedRegistry::new()), + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![&RECORD], + terminal_marker: String::new(), + runtime, + module_config: Arc::new(|_| serde_json::Value::Null), + async_module_config: None, + reporter: Arc::new(FailureReporter::new(Arc::new(Quiet))), + }) +} + +fn manifest() -> ModuleManifest { + ModuleManifest { + schema: MANIFEST_SCHEMA, + module: ModuleIdentity { + name: ID.to_owned(), + version: Version::new(0, 0, 0), + description: String::new(), + homepage: None, + license: String::new(), + }, + bus_name: BusName::new(BUS).unwrap(), + object_path: ObjectPath::new(PATH).unwrap(), + provides: vec![], + requires: vec![], + environment: vec![], + capabilities: vec![], + lazy_init: false, + worker_threads: 1, + on_panic: PanicPolicy::Detach, + } +} + +unsafe extern "C" fn deliver(_: *mut c_void, _: *const u8, _: usize) -> i32 { + TB_OK +} + +unsafe extern "C" fn shutdown(_: *mut c_void, _: u64) -> i32 { + TB_OK +} + +unsafe extern "C" fn reinitialize(_: *mut c_void, _: *const u8, _: usize) -> i32 { + TB_OK +} + +unsafe extern "C" fn initialize(host: *const TbHostVtable, out: *mut TbModuleVtable) -> i32 { + if host.is_null() || out.is_null() { + return TB_BAD_ARGUMENT; + } + let host = unsafe { *host }; + let module_ctx = Box::into_raw(Box::new(0_u8)).cast(); + unsafe { (host.ready)(host.host_ctx) }; + unsafe { + *out = TbModuleVtable { + size: std::mem::size_of::() as u32, + _reserved: 0, + module_ctx, + deliver, + shutdown, + reinitialize: Some(reinitialize), + }; + } + TB_OK +} + +#[tokio::test] +async fn resolver_access_forwards_runtime_proxy_lifecycle_and_fault_operations() { + let resolver = resolver(true); + let access: &dyn ModuleAccess = &resolver; + assert_eq!(access.find(ID).map(|record| record.id), Some(ID)); + assert!(access.enabled()); + assert!(std::ptr::eq(access.reporter(), resolver.reporter())); + assert!(access.ensure_loaded("not-in-registry").await.is_err()); + assert!(access.proxy(BUS, PATH).await.is_ok()); + assert_eq!( + access.proxy("not a bus name", PATH).await.unwrap_err(), + ProxyFailure::Contract + ); + + let runtime = runtime(); + runtime + .host() + .attach_linked_with_config( + LinkedModule { + descriptor: TbAbiDescriptor::current(ID, "0.0.0"), + manifest: manifest(), + init: initialize, + init_with_setup: None, + }, + serde_json::Value::Null, + ) + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if runtime.host().list().iter().any(|info| { + info.name == ID + && matches!( + &info.state, + crate::module::ModuleState::Ready | crate::module::ModuleState::Serving + ) + }) { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + access + .reinitialize(ID, serde_json::json!({ "route": "replacement" })) + .await + .unwrap(); + assert_eq!( + access + .reinitialize("missingnotloaded", serde_json::Value::Null) + .await, + Err(ReconfigureFailure::Failed) + ); + + let faulted_id = "resolveraccessfaulted"; + let Claim::Run { sender, .. } = resolution::global().claim(faulted_id) else { + panic!("new test id must claim the unresolved slot"); + }; + resolution::global().complete(faulted_id, Resolution::Ready, sender); + access.mark_faulted(faulted_id); + assert_eq!( + resolution::global().peek(faulted_id), + ResolutionState::Failed("module reconfiguration failed".to_owned()) + ); +} + +#[tokio::test] +async fn runtime_failures_map_to_proxy_transport_and_reconfigure_unavailable() { + let resolver = resolver(false); + let access: &dyn ModuleAccess = &resolver; + assert_eq!( + access.proxy(BUS, PATH).await.unwrap_err(), + ProxyFailure::Transport + ); + assert_eq!( + access.reinitialize(ID, serde_json::Value::Null).await, + Err(ReconfigureFailure::Unavailable) + ); +} + +#[test] +fn resolver_access_reconfigure_failure_uses_only_closed_error_state() { + assert_eq!( + reconfigure_failure(&crate::Error::ModuleUnavailable { + module: ID.to_owned(), + state: "faulted".to_owned(), + detail: "terminal".to_owned(), + }), + ReconfigureFailure::Unavailable + ); + assert_eq!( + reconfigure_failure(&crate::Error::MethodFailed { + name: "ai.tinyhumans.tinybus.Error.Other".to_owned(), + message: "module initializing".to_owned(), + }), + ReconfigureFailure::Failed + ); +} diff --git a/crates/tinybus/src/module/resolve_attested_tests.rs b/crates/tinybus/src/module/resolve_attested_tests.rs new file mode 100644 index 00000000..9bf01e28 --- /dev/null +++ b/crates/tinybus/src/module/resolve_attested_tests.rs @@ -0,0 +1,207 @@ +use super::*; + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +#[ignore = "requires TINYBUS_TEST_MODULE to point at the built module_clock cdylib"] +async fn a_local_artifact_is_loaded_and_reported_ready() { + let artifact = PathBuf::from(std::env::var_os("TINYBUS_TEST_MODULE").unwrap()); + let runtime = + crate::module::ModuleRuntime::start(&crate::module::RuntimeNames::default(), None).unwrap(); + + Resolver::load_local(&runtime, &artifact, "clock", serde_json::Value::Null).unwrap(); + + let loaded = runtime.host().list(); + assert!( + loaded.iter().any(|info| { + info.manifest.bus_name.as_str() == "ai.tinyhumans.openhuman.Clock" + && info.manifest.object_path.as_str() == "/ai/tinyhumans/openhuman/Clock" + }), + "loaded modules: {loaded:#?}" + ); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +#[ignore = "requires TINYBUS_TEST_MODULE to point at the built module_clock cdylib"] +async fn the_first_strict_call_prepares_a_real_lazy_resolver_module_without_a_probe() { + use crate::module::{ModuleAccess, ModuleCallError, ModuleClient, ProxyFailure}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let library_source = PathBuf::from(std::env::var_os("TINYBUS_TEST_MODULE").unwrap()); + let install = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let host_key = crate::module::platform::host_candidates() + .into_iter() + .next() + .expect("test host has a published module platform key"); + let module_id: &'static str = Box::leak("resolver-attested-lazy".to_string().into_boxed_str()); + let archive_name: &'static str = + Box::leak("clock-attested-test.tar.gz".to_string().into_boxed_str()); + let version = "0.1.7"; + let bus_name = "ai.tinyhumans.openhuman.Clock"; + let object_path = "/ai/tinyhumans/openhuman/Clock"; + let host_key: &'static str = Box::leak(host_key.into_boxed_str()); + let library_name = library_source + .file_name() + .and_then(|name| name.to_str()) + .unwrap() + .to_string(); + let cache_dir = + crate::module::artifact_dir(install.path(), module_id, version, host_key).unwrap(); + let extraction = cache_dir.join("assets"); + std::fs::create_dir_all(&extraction).unwrap(); + let library_path = extraction.join(&library_name); + std::fs::copy(&library_source, &library_path).unwrap(); + let library_sha256 = crate::module::sha256_file(&library_path).unwrap(); + std::fs::write( + extraction.join("modules.toml"), + format!("{library_name:?} = \"{library_sha256}\"\n"), + ) + .unwrap(); + + let archive_path = cache_dir.join(archive_name); + let encoder = flate2::write::GzEncoder::new( + std::fs::File::create(&archive_path).unwrap(), + flate2::Compression::default(), + ); + let mut archive = tar::Builder::new(encoder); + for (name, bytes) in [ + ( + format!("assets/{library_name}"), + std::fs::read(&library_path).unwrap(), + ), + ( + "assets/modules.toml".to_string(), + std::fs::read(extraction.join("modules.toml")).unwrap(), + ), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive.append_data(&mut header, name, &bytes[..]).unwrap(); + } + archive.into_inner().unwrap().finish().unwrap(); + let archive_sha256 = crate::module::sha256_file(&archive_path).unwrap(); + crate::module::cache::write_digest_marker(&cache_dir, archive_name, &archive_sha256).unwrap(); + + let assets = Box::leak( + vec![crate::module::ReleaseAsset { + host_key, + archive: archive_name, + sha256: Box::leak(archive_sha256.into_boxed_str()), + library_sha256: Some(Box::leak(library_sha256.into_boxed_str())), + }] + .into_boxed_slice(), + ); + let record = Box::leak(Box::new(ReleaseRecord { + id: module_id, + bus_name, + object_path, + version, + release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v0.1.7", + assets, + })); + // Other ignored loader tests attach module_clock to a process-shared + // runtime under the same well-known bus name. Give this strict-call test + // its own host so parallel test execution cannot race that admission. + let runtime: &'static crate::module::ModuleRuntime = Box::leak(Box::new( + crate::module::ModuleRuntime::start(&crate::module::RuntimeNames::default(), None).unwrap(), + )); + let sink = Arc::new(super::test_support::Recording::default()); + let resolver = Resolver::new(ResolverInputs { + enabled: true, + install_dir: Some(install.path().to_path_buf()), + allow_download: false, + bundled_dir: None, + sources: HashMap::from([(module_id.to_string(), ModuleSource::Pinned)]), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![record], + terminal_marker: "This is terminal for the running process".to_string(), + runtime: Arc::new(move || Box::pin(async move { Ok(runtime) })), + module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, + reporter: Arc::new(crate::module::FailureReporter::new(sink)), + }); + + struct CountedResolver { + resolver: Resolver, + preparations: Arc, + preparation_error: Arc>>, + } + + impl ModuleAccess for CountedResolver { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + self.resolver.find(id) + } + + fn enabled(&self) -> bool { + self.resolver.enabled() + } + + fn reporter(&self) -> &FailureReporter { + self.resolver.reporter() + } + + fn ensure_loaded<'a>( + &'a self, + id: &'a str, + ) -> crate::module::AccessFuture<'a, Result<(), String>> { + Box::pin(self.resolver.ensure_loaded(id)) + } + + fn prepare_attested<'a>( + &'a self, + id: &'a str, + ) -> crate::module::AccessFuture<'a, Result<(), String>> { + self.preparations.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + let result = self.resolver.prepare_attested(id).await; + if let Err(error) = &result { + *self + .preparation_error + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(error.clone()); + } + result + }) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> crate::module::AccessFuture<'a, Result> { + Box::pin(async move { + self.resolver + .runtime() + .await + .map_err(|_| ProxyFailure::Transport)? + .proxy(bus_name, object_path) + .map_err(|_| ProxyFailure::Contract) + }) + } + } + + let preparations = Arc::new(AtomicUsize::new(0)); + let preparation_error = Arc::new(std::sync::Mutex::new(None)); + let client = ModuleClient::new(CountedResolver { + resolver: resolver.clone(), + preparations: preparations.clone(), + preparation_error: preparation_error.clone(), + }); + let response: std::result::Result = + client.call_attested(record.id, "Now", ()).await; + assert!( + response.is_ok(), + "{response:?}; prepare error: {:?}", + *preparation_error + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + ); + assert_eq!(preparations.load(Ordering::SeqCst), 1); + assert_eq!(resolver.state_of(record.id), Availability::Ready); +} diff --git a/crates/tinybus/src/module/resolve_test_support.rs b/crates/tinybus/src/module/resolve_test_support.rs new file mode 100644 index 00000000..5aa2c4e2 --- /dev/null +++ b/crates/tinybus/src/module/resolve_test_support.rs @@ -0,0 +1,87 @@ +//! Shared fixtures for resolver tests. + +use super::*; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::module::failure::{FailureReport, FailureSink}; +use crate::module::runtime::RuntimeNames; + +#[derive(Default)] +pub(super) struct Recording(pub(super) Mutex>); + +impl FailureSink for Recording { + fn capture(&self, report: &FailureReport<'_>) { + self.0 + .lock() + .unwrap() + .push((report.module.to_string(), report.reason)); + } +} + +pub(super) fn shared_runtime() -> &'static ModuleRuntime { + static RUNTIME: std::sync::OnceLock = std::sync::OnceLock::new(); + RUNTIME.get_or_init(|| ModuleRuntime::start(&RuntimeNames::default(), None).unwrap()) +} + +/// The resolution table is process-global, so each test owns a distinct id. +pub(super) fn record(id: &'static str) -> &'static ReleaseRecord { + Box::leak(Box::new(ReleaseRecord { + id, + bus_name: Box::leak(format!("ai.tinyhumans.test.{id}").into_boxed_str()), + object_path: "/ai/tinyhumans/test/Module", + version: "0.0.0", + release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v0.0.0", + assets: &[], + })) +} + +pub(super) struct Fixture { + pub(super) sink: Arc, + pub(super) config_calls: Arc, + pub(super) runtime_calls: Arc, + pub(super) resolver: Resolver, +} + +pub(super) fn fixture( + record: &'static ReleaseRecord, + enabled: bool, + sources: HashMap, + install_dir: Option, +) -> Fixture { + let sink = Arc::new(Recording::default()); + let config_calls = Arc::new(AtomicUsize::new(0)); + let runtime_calls = Arc::new(AtomicUsize::new(0)); + let counted = config_calls.clone(); + let started = runtime_calls.clone(); + let resolver = Resolver::new(ResolverInputs { + enabled, + install_dir, + allow_download: false, + bundled_dir: None, + sources, + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![record], + terminal_marker: "This is terminal for the running process".to_string(), + runtime: Arc::new(move || { + started.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(shared_runtime()) }) + }), + module_config: Arc::new(move |_| { + counted.fetch_add(1, Ordering::SeqCst); + serde_json::json!({}) + }), + async_module_config: None, + reporter: Arc::new(FailureReporter::new(sink.clone())), + }); + Fixture { + sink, + config_calls, + runtime_calls, + resolver, + } +} diff --git a/crates/tinybus/src/module/resolve_tests.rs b/crates/tinybus/src/module/resolve_tests.rs index 1a5f7fe1..5026249d 100644 --- a/crates/tinybus/src/module/resolve_tests.rs +++ b/crates/tinybus/src/module/resolve_tests.rs @@ -4,6 +4,86 @@ use crate::module::manifest::{ }; use crate::{BusName, InterfaceName, InterfaceVersion, ObjectPath, Version}; +#[tokio::test] +async fn resolver_awaits_async_module_configuration_only_for_the_requested_module() { + use super::test_support::{Recording, record, shared_runtime}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let record = record("resolver_async_module_config"); + let sources = HashMap::from([(record.id.to_string(), ModuleSource::Linked)]); + let async_calls = Arc::new(AtomicUsize::new(0)); + let sync_calls = Arc::new(AtomicUsize::new(0)); + let async_counter = Arc::clone(&async_calls); + let sync_counter = Arc::clone(&sync_calls); + let async_module_config: AsyncModuleConfigProvider = Arc::new(move |id| { + let counter = Arc::clone(&async_counter); + let id = id.to_owned(); + Box::pin(async move { + assert_eq!(id, "resolver_async_module_config"); + counter.fetch_add(1, Ordering::SeqCst); + serde_json::json!({ "loaded_async": true }) + }) + }); + let resolver = Resolver::new(ResolverInputs { + enabled: true, + install_dir: None, + allow_download: false, + bundled_dir: None, + sources, + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![record], + terminal_marker: String::new(), + runtime: Arc::new(|| Box::pin(async { Ok(shared_runtime()) })), + module_config: Arc::new(move |_| { + sync_counter.fetch_add(1, Ordering::SeqCst); + serde_json::json!({ "loaded_sync": true }) + }), + async_module_config: Some(async_module_config), + reporter: Arc::new(FailureReporter::new(Arc::new(Recording::default()))), + }); + + let error = resolver.ensure_loaded(record.id).await.unwrap_err(); + assert!(error.contains("linked factory"), "{error}"); + assert_eq!(async_calls.load(Ordering::SeqCst), 1); + assert_eq!(sync_calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn reconfiguration_classifies_only_closed_lifecycle_wire_names() { + let cases = [ + ( + crate::Error::ModuleUnavailable { + module: "ignored".into(), + state: "initializing".into(), + detail: "arbitrary detail is ignored".into(), + }, + crate::module::ReconfigureFailure::Initializing, + ), + ( + crate::Error::MethodFailed { + name: crate::Error::MODULE_UNAVAILABLE.into(), + message: "arbitrary remote prose is ignored".into(), + }, + crate::module::ReconfigureFailure::Unavailable, + ), + ( + crate::Error::MethodFailed { + name: "ai.tinyhumans.tinybus.Error.Other".into(), + message: "module initializing".into(), + }, + crate::module::ReconfigureFailure::Failed, + ), + ]; + for (error, expected) in cases { + assert_eq!(access::reconfigure_failure(&error), expected); + } +} + fn module(name: &str, provides: &[&str], requires: &[(&str, bool)]) -> ModuleManifest { let version = Version::new(1, 0, 0); ModuleManifest { @@ -137,39 +217,976 @@ fn two_modules_claiming_one_bus_name_are_both_reported_and_neither_is_initialize } mod resolver { - use super::super::*; - use crate::module::failure::{FailureReport, FailureSink}; - use crate::module::runtime::RuntimeNames; - use std::sync::Mutex; - use std::sync::atomic::{AtomicUsize, Ordering}; + use super::super::test_support::{Fixture, Recording, fixture, record, shared_runtime}; + + struct ResolverCallback(crate::BusName); + + #[async_trait::async_trait] + impl crate::Interface for ResolverCallback { + fn name(&self) -> crate::InterfaceName { + "org.example.Callback".parse().unwrap() + } + + fn members(&self) -> Vec { + vec!["Observe".parse().unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Ok(serde_json::json!(self.0)) + } + } + + async fn callback_fixture_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + LOCK.lock().await + } + + #[tokio::test] + async fn callback_resolution_rejects_a_binding_from_another_host_without_claiming_the_slot() { + use crate::module::HostCallbackEndpoint; + use crate::module::runtime::{ModuleRuntime, RuntimeNames}; + + let record = record("resolver_foreign_callback_binding"); + let runtime = shared_runtime(); + let foreign_runtime = ModuleRuntime::start( + &RuntimeNames { + thread: "tinybus-test-foreign-callback".to_owned(), + worker: "tinybus-test-foreign-callback-worker".to_owned(), + }, + None, + ) + .unwrap(); + let binding = Arc::new( + foreign_runtime + .host() + .prepare_host_callbacks(vec![HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .unwrap(), + ); + let f = fixture( + record, + true, + HashMap::from([( + record.id.to_string(), + ModuleSource::LocalFile { + path: "/unused/verified-module.so".to_owned(), + }, + )]), + None, + ); + + let error = f + .resolver + .ensure_loaded_with_host_callbacks(record.id, binding, serde_json::json!({})) + .await + .unwrap_err(); + assert!(error.contains("another host"), "{error}"); + assert_eq!(f.resolver.state_of(record.id), Availability::Available); - #[derive(Default)] - struct Recording(Mutex>); + // A rejected capability cannot reserve the process-global slot or + // prevent the normal resolver path from deciding the module outcome. + f.resolver.ensure_loaded(record.id).await.unwrap_err(); + assert_eq!(f.resolver.state_of(record.id), Availability::Failed); + assert_eq!( + runtime + .host() + .list() + .iter() + .filter(|info| { info.manifest.bus_name.as_str() == record.bus_name }) + .count(), + 0 + ); + } - impl FailureSink for Recording { - fn capture(&self, report: &FailureReport<'_>) { - self.0 - .lock() - .unwrap() - .push((report.module.to_string(), report.reason)); + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_local_callback_module_is_admitted_with_its_single_binding() { + let _fixture_guard = callback_fixture_lock().await; + let Some(source) = + std::env::var_os("TINYBUS_TEST_HOST_CALLBACK_MODULE").map(std::path::PathBuf::from) + else { + return; + }; + let working_dir = std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap(); + let artifact_dir = tempfile::tempdir_in(working_dir).unwrap(); + let artifact_dir = std::fs::canonicalize(artifact_dir.path()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&artifact_dir, std::fs::Permissions::from_mode(0o700)) + .unwrap(); } + let artifact = artifact_dir.join(source.file_name().unwrap()); + std::fs::copy(&source, &artifact).unwrap(); + let digest = + crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); + std::fs::write( + artifact_dir.join("modules.toml"), + format!( + "{:?} = {digest:?}\n", + artifact.file_name().unwrap().to_str().unwrap() + ), + ) + .unwrap(); + let record = Box::leak(Box::new(crate::module::ReleaseRecord { + id: "resolver_single_callback_success", + bus_name: "org.example.EagerCallback", + object_path: "/org/example/EagerCallback", + version: "0.0.0", + release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v0.0.0", + assets: &[], + })); + let runtime: &'static crate::module::ModuleRuntime = Box::leak(Box::new( + crate::module::ModuleRuntime::start( + &crate::module::RuntimeNames { + thread: "single-callback-local-test".into(), + worker: "single-callback-local-worker".into(), + }, + None, + ) + .unwrap(), + )); + let binding = Arc::new( + runtime + .host() + .prepare_host_callbacks(vec![crate::module::HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .unwrap(), + ); + binding + .serve_at( + "/callback".parse().unwrap(), + ResolverCallback("org.example.Callback".parse().unwrap()), + ) + .await + .unwrap(); + let resolver = Resolver::new(ResolverInputs { + enabled: true, + install_dir: None, + allow_download: false, + bundled_dir: None, + sources: HashMap::from([( + record.id.to_string(), + ModuleSource::LocalFile { + path: artifact.to_string_lossy().into_owned(), + }, + )]), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![record], + terminal_marker: String::new(), + runtime: Arc::new(move || Box::pin(async move { Ok(runtime) })), + module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, + reporter: Arc::new(FailureReporter::new(Arc::new(Recording::default()))), + }); + + resolver + .ensure_loaded_with_host_callbacks( + record.id, + Arc::clone(&binding), + serde_json::json!({ "host": binding.unique_name().as_str() }), + ) + .await + .unwrap(); + // A repeated single-binding request is accepted only when the + // resolver's completed result still retains this exact binding. + resolver + .ensure_loaded_with_host_callbacks( + record.id, + Arc::clone(&binding), + serde_json::json!({ "host": binding.unique_name().as_str() }), + ) + .await + .unwrap(); + + assert_eq!(resolver.state_of(record.id), Availability::Ready); + assert!( + runtime + .host() + .retains_host_callback_binding(record.bus_name, &binding) + ); + } + + #[tokio::test] + async fn failed_local_callback_load_does_not_retain_its_binding() { + use crate::module::HostCallbackEndpoint; + + let record = record("resolver_failed_local_callback_load"); + let runtime = shared_runtime(); + let binding = Arc::new( + runtime + .host() + .prepare_host_callbacks(vec![HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .unwrap(), + ); + binding + .serve_at( + "/callback".parse().unwrap(), + ResolverCallback("org.example.Callback".parse().unwrap()), + ) + .await + .unwrap(); + let resolver = fixture( + record, + true, + HashMap::from([( + record.id.to_string(), + ModuleSource::LocalFile { + path: "/unused/missing-callback-module.so".to_owned(), + }, + )]), + None, + ) + .resolver; + + let error = resolver + .ensure_loaded_with_host_callbacks( + record.id, + Arc::clone(&binding), + serde_json::json!({}), + ) + .await + .unwrap_err(); + + assert!(error.contains("callback load failed"), "{error}"); + assert_eq!(resolver.state_of(record.id), Availability::Failed); + assert!( + !runtime + .host() + .retains_host_callback_binding(record.bus_name, &binding) + ); } - fn shared_runtime() -> &'static ModuleRuntime { - static RUNTIME: std::sync::OnceLock = std::sync::OnceLock::new(); - RUNTIME.get_or_init(|| ModuleRuntime::start(&RuntimeNames::default(), None).unwrap()) + #[tokio::test] + async fn callback_factory_failure_is_cached_without_retrying_or_loading() { + let host_key = Box::leak( + crate::module::platform::host_candidates() + .into_iter() + .next() + .expect("this host has a release candidate") + .into_boxed_str(), + ); + let assets = Box::leak( + vec![ReleaseAsset { + host_key, + archive: "callback-factory.tar.gz", + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + library_sha256: None, + }] + .into_boxed_slice(), + ); + let record = Box::leak(Box::new(crate::module::ReleaseRecord { + id: "resolver_callback_factory_failure", + bus_name: "ai.tinyhumans.test.resolver_callback_factory_failure", + object_path: "/ai/tinyhumans/test/Module", + version: "0.0.0", + release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v0.0.0", + assets, + })); + let install_dir = tempfile::tempdir().unwrap(); + let resolver = fixture( + record, + true, + HashMap::new(), + Some(install_dir.path().to_path_buf()), + ) + .resolver; + let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let factory_attempts = Arc::clone(&attempts); + + let error = resolver + .ensure_loaded_with_host_callback_factory(record.id, move |_| { + let attempts = Arc::clone(&factory_attempts); + async move { + attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err("callback setup was refused".to_owned()) + } + }) + .await + .err() + .expect("the callback factory refusal should fail resolution"); + + assert!(error.starts_with("callback setup was refused"), "{error}"); + assert!(error.contains("restart the app to try again"), "{error}"); + assert_eq!(resolver.state_of(record.id), Availability::Failed); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1); + let cached_error = resolver + .ensure_loaded_with_host_callback_factory(record.id, |_| async { + panic!("a cached callback failure must not rerun its factory") + }) + .await + .err() + .expect("the cached callback refusal should remain a failure"); + assert_eq!(cached_error, error); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!( + !shared_runtime() + .host() + .list() + .iter() + .any(|info| info.manifest.bus_name.as_str() == record.bus_name) + ); } - /// The resolution table is process-global, so each test owns a distinct id. - fn record(id: &'static str) -> &'static ReleaseRecord { - Box::leak(Box::new(ReleaseRecord { - id, - bus_name: Box::leak(format!("ai.tinyhumans.test.{id}").into_boxed_str()), - object_path: "/ai/tinyhumans/test/Module", - version: "0.0.0", - release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v0.0.0", - assets: &[], - })) + #[tokio::test] + async fn callback_apis_reject_linked_sources_before_preparing_authority() { + use crate::module::HostCallbackEndpoint; + + let record = record("resolver_callback_linked_source"); + let resolver = fixture( + record, + true, + HashMap::from([(record.id.to_string(), ModuleSource::Linked)]), + None, + ) + .resolver; + let binding = Arc::new( + shared_runtime() + .host() + .prepare_host_callbacks(vec![HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .unwrap(), + ); + + let single_error = resolver + .ensure_loaded_with_host_callbacks(record.id, binding, serde_json::json!({})) + .await + .unwrap_err(); + assert!( + single_error.contains("verified local-file source"), + "{single_error}" + ); + + let factory_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls = Arc::clone(&factory_calls); + let factory_error = resolver + .ensure_loaded_with_host_callback_factory(record.id, move |_| { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async { + Err("callback authority must not be prepared for a linked source".to_owned()) + } + }) + .await + .err() + .expect("linked modules cannot carry resolver callback authority"); + assert!( + factory_error.contains("callback source cannot preserve admitted identity"), + "{factory_error}" + ); + assert_eq!(factory_calls.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(resolver.state_of(record.id), Availability::Available); + } + + #[tokio::test] + async fn callback_load_rejects_a_preloaded_module_without_its_binding() { + let record = record("resolver_callback_preloaded_without_binding"); + let runtime = shared_runtime(); + let mut manifest = super::module(record.id, &[], &[]); + manifest.bus_name = crate::BusName::new(record.bus_name).unwrap(); + manifest.object_path = crate::ObjectPath::new(record.object_path).unwrap(); + runtime + .host() + .attach_linked_with_config( + crate::module::LinkedModule { + descriptor: crate::module::abi::TbAbiDescriptor::current(record.id, "1.0.0"), + manifest, + init: initialize_raced_module, + init_with_setup: None, + }, + serde_json::Value::Null, + ) + .unwrap(); + let f = fixture( + record, + true, + HashMap::from([( + record.id.to_string(), + ModuleSource::LocalFile { + path: "/unused/callback-module.so".to_owned(), + }, + )]), + None, + ); + + let error = f + .resolver + .ensure_loaded_with_host_callback_factory(record.id, |_| async { + panic!("a preloaded module without a binding must reject the factory") + }) + .await + .err() + .expect("the preloaded module must fail closed without its binding"); + + assert!( + error.contains("already loaded without a callback binding"), + "{error}" + ); + assert_eq!(f.resolver.state_of(record.id), Availability::Available); + } + + #[tokio::test] + async fn callback_load_fails_closed_when_admission_races_the_search_path() { + let record = record("resolver_callback_search_race"); + let mut f = fixture(record, true, HashMap::new(), None); + f.resolver.after_initial_host_check = Some(Arc::new(|runtime, record| { + let mut manifest = super::module(record.id, &[], &[]); + manifest.bus_name = crate::BusName::new(record.bus_name).unwrap(); + manifest.object_path = crate::ObjectPath::new(record.object_path).unwrap(); + runtime + .host() + .attach_linked_with_config( + crate::module::LinkedModule { + descriptor: crate::module::abi::TbAbiDescriptor::current( + record.id, "1.0.0", + ), + manifest, + init: initialize_raced_module, + init_with_setup: None, + }, + serde_json::Value::Null, + ) + .unwrap(); + })); + let factory_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls = Arc::clone(&factory_calls); + + let error = f + .resolver + .ensure_loaded_with_host_callback_factory(record.id, move |_| { + let calls = Arc::clone(&calls); + async move { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err("callback factory should not run after admission".to_owned()) + } + }) + .await + .err() + .expect("a concurrent unbound admission must fail closed"); + + assert!( + error.contains("was loaded without the requested callback binding"), + "{error}" + ); + assert_eq!(factory_calls.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(f.resolver.state_of(record.id), Availability::Failed); + } + + #[tokio::test] + async fn cached_ready_callback_resolution_requires_its_retained_binding() { + use crate::module::HostCallbackEndpoint; + + let record = record("resolver_cached_callback_without_binding"); + match resolution::global().claim(record.id) { + Claim::Run { sender, .. } => { + resolution::global().complete(record.id, Resolution::Ready, sender); + } + _ => panic!("test id unexpectedly had a resolution"), + } + let runtime = shared_runtime(); + let binding = Arc::new( + runtime + .host() + .prepare_host_callbacks(vec![HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .unwrap(), + ); + binding + .serve_at( + "/callback".parse().unwrap(), + ResolverCallback("org.example.Callback".parse().unwrap()), + ) + .await + .unwrap(); + let resolver = fixture( + record, + true, + HashMap::from([( + record.id.to_string(), + ModuleSource::LocalFile { + path: "/unused/cached-callback-module.so".to_owned(), + }, + )]), + None, + ) + .resolver; + + let error = match resolver + .ensure_loaded_with_host_callbacks(record.id, binding, serde_json::Value::Null) + .await + { + Ok(()) => panic!("a cached ready slot cannot substitute for its callback binding"), + Err(error) => error, + }; + + assert!( + error.contains("was loaded without the requested callback binding"), + "{error}" + ); + } + + #[tokio::test] + async fn callback_apis_hide_runtime_startup_details_and_do_not_claim_resolution() { + use crate::module::HostCallbackEndpoint; + + let record = record("resolver_callback_runtime_failure"); + let resolver = Resolver::new(ResolverInputs { + enabled: true, + install_dir: None, + allow_download: false, + bundled_dir: None, + sources: HashMap::new(), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![record], + terminal_marker: String::new(), + runtime: Arc::new(|| { + Box::pin(async { Err(crate::Error::failed("private runtime detail")) }) + }), + module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, + reporter: Arc::new(FailureReporter::new(Arc::new(Recording::default()))), + }); + let binding = Arc::new( + shared_runtime() + .host() + .prepare_host_callbacks(vec![HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .unwrap(), + ); + + let single_error = resolver + .ensure_loaded_with_host_callbacks(record.id, binding, serde_json::json!({})) + .await + .unwrap_err(); + assert!( + single_error.contains("the module bus could not start"), + "{single_error}" + ); + assert!(!single_error.contains("private runtime detail")); + + let factory_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls = Arc::clone(&factory_calls); + let factory_error = resolver + .ensure_loaded_with_host_callback_factory(record.id, move |_| { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async { Err("the factory must not run".to_owned()) } + }) + .await + .err() + .expect("runtime startup failure should be reported"); + assert!( + factory_error.contains("the module bus could not start"), + "{factory_error}" + ); + assert!(!factory_error.contains("private runtime detail")); + assert_eq!(factory_calls.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(resolver.state_of(record.id), Availability::Available); + } + + #[tokio::test] + async fn callback_resolution_does_not_join_a_generic_in_flight_load() { + use crate::module::HostCallbackEndpoint; + use std::sync::{Condvar, Mutex}; + + let record = record("resolver_callback_does_not_join_generic_load"); + let runtime = shared_runtime(); + let binding = Arc::new( + runtime + .host() + .prepare_host_callbacks(vec![HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .unwrap(), + ); + binding + .serve_at( + "/callback".parse().unwrap(), + ResolverCallback("org.example.Callback".parse().unwrap()), + ) + .await + .unwrap(); + let f = fixture( + record, + true, + HashMap::from([( + record.id.to_string(), + ModuleSource::LocalFile { + path: "/unused/verified-module.so".to_owned(), + }, + )]), + None, + ); + let entered = Arc::new((Mutex::new(false), Condvar::new())); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let hook_entered = entered.clone(); + let hook_release = release.clone(); + let mut resolver = f.resolver.clone(); + resolver.after_initial_host_check = Some(Arc::new(move |_, _| { + let (lock, changed) = &*hook_entered; + *lock.lock().unwrap() = true; + changed.notify_one(); + let (lock, changed) = &*hook_release; + let mut ready = lock.lock().unwrap(); + while !*ready { + ready = changed.wait(ready).unwrap(); + } + })); + + let generic_resolver = resolver.clone(); + let generic_load = + tokio::spawn(async move { generic_resolver.ensure_loaded(record.id).await }); + let waiting = entered.clone(); + tokio::task::spawn_blocking(move || { + let (lock, changed) = &*waiting; + let mut entered = lock.lock().unwrap(); + while !*entered { + entered = changed.wait(entered).unwrap(); + } + }) + .await + .unwrap(); + + let error = resolver + .ensure_loaded_with_host_callbacks(record.id, binding, serde_json::json!({})) + .await + .unwrap_err(); + assert!( + error.contains("already resolving without this callback binding"), + "{error}" + ); + assert_eq!(resolver.state_of(record.id), Availability::Loading); + + let (lock, changed) = &*release; + *lock.lock().unwrap() = true; + changed.notify_one(); + assert!(generic_load.await.unwrap().is_err()); + assert_eq!(resolver.state_of(record.id), Availability::Failed); + } + + #[tokio::test] + async fn callback_resolution_rejects_a_module_already_loaded_without_its_binding() { + use crate::module::HostCallbackEndpoint; + + let record = record("resolver_callback_rejects_preloaded_generic_module"); + let runtime = shared_runtime(); + let binding = Arc::new( + runtime + .host() + .prepare_host_callbacks(vec![HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .unwrap(), + ); + binding + .serve_at( + "/callback".parse().unwrap(), + ResolverCallback("org.example.Callback".parse().unwrap()), + ) + .await + .unwrap(); + let mut manifest = super::module(record.id, &[], &[]); + manifest.bus_name = crate::BusName::new(record.bus_name).unwrap(); + manifest.object_path = crate::ObjectPath::new(record.object_path).unwrap(); + runtime + .host() + .attach_linked_with_config( + crate::module::LinkedModule { + descriptor: crate::module::abi::TbAbiDescriptor::current(record.id, "1.0.0"), + manifest, + init: initialize_raced_module, + init_with_setup: None, + }, + serde_json::Value::Null, + ) + .unwrap(); + + let f = fixture( + record, + true, + HashMap::from([( + record.id.to_string(), + ModuleSource::LocalFile { + path: "/unused/verified-module.so".to_owned(), + }, + )]), + None, + ); + let error = f + .resolver + .ensure_loaded_with_host_callbacks(record.id, binding, serde_json::json!({})) + .await + .unwrap_err(); + assert!( + error.contains("already loaded without this callback binding"), + "{error}" + ); + assert_eq!(f.resolver.state_of(record.id), Availability::Available); + assert_eq!( + runtime + .host() + .list() + .iter() + .filter(|info| { info.manifest.bus_name.as_str() == record.bus_name }) + .count(), + 1 + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn resolver_retains_the_callback_binding_for_its_exact_admitted_module() { + use crate::module::HostCallbackEndpoint; + use std::path::PathBuf; + + let _fixture_guard = callback_fixture_lock().await; + let Some(source) = std::env::var_os("TINYBUS_TEST_HOST_CALLBACK_MODULE").map(PathBuf::from) + else { + return; + }; + // Keep the bundled cache below the checkout: macOS's system temp path + // can include symlinked ancestors, which ModuleHost correctly refuses. + let working_dir = std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap(); + let root = tempfile::tempdir_in(working_dir).unwrap(); + let root_path = std::fs::canonicalize(root.path()).unwrap(); + let host_key = Box::leak( + crate::module::platform::host_candidates() + .into_iter() + .next() + .expect("this host has a candidate") + .into_boxed_str(), + ); + let asset = ReleaseAsset { + host_key, + archive: "callback-module.tar.gz", + sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + library_sha256: None, + }; + let assets = Box::leak(vec![asset].into_boxed_slice()); + let module_dir = + crate::module::artifact_dir(&root_path, "tinybus", "0.0.0", asset.host_key).unwrap(); + #[cfg(windows)] + crate::module::windows_acl::create_private_dir_all(&module_dir).unwrap(); + #[cfg(not(windows))] + std::fs::create_dir_all(&module_dir).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&module_dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let artifact = module_dir.join(format!( + "libcallback.{}", + crate::module::cache::library_extension() + )); + std::fs::copy(&source, &artifact).unwrap(); + let library_hash = + crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); + std::fs::write( + module_dir.join(format!("{}.sha256", asset.archive)), + format!("{}\n", asset.sha256), + ) + .unwrap(); + std::fs::write( + module_dir.join("modules.toml"), + format!( + "{:?} = {library_hash:?}\n", + artifact.file_name().unwrap().to_str().unwrap() + ), + ) + .unwrap(); + let record = Box::leak(Box::new(ReleaseRecord { + id: "tinybus", + bus_name: "org.example.EagerCallback", + object_path: "/org/example/EagerCallback", + version: "0.0.0", + release_url: "https://github.com/tinyhumansai/tinybus/releases/tag/v0.0.0", + assets, + })); + // The fixture module's bus name is fixed, and the shared runtime can + // already hold it from the other callback tests, so this test owns its + // runtime. + let runtime: &'static crate::module::ModuleRuntime = Box::leak(Box::new( + crate::module::ModuleRuntime::start( + &crate::module::RuntimeNames { + thread: "retained-callback-binding-test".into(), + worker: "retained-callback-binding-worker".into(), + }, + None, + ) + .unwrap(), + )); + let resolver = Resolver::new(ResolverInputs { + enabled: true, + install_dir: Some(root_path.clone()), + allow_download: false, + bundled_dir: Some(root_path), + sources: HashMap::new(), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![record], + terminal_marker: String::new(), + runtime: Arc::new(move || Box::pin(async move { Ok(runtime) })), + module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, + reporter: Arc::new(FailureReporter::new(Arc::new( + test_support::Recording::default(), + ))), + }); + let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let prepare_attempts = Arc::clone(&attempts); + let binding = resolver + .ensure_loaded_with_host_callback_factory(record.id, move |_| { + let attempts = Arc::clone(&prepare_attempts); + async move { + attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let binding = Arc::new( + runtime + .host() + .prepare_host_callbacks(vec![HostCallbackEndpoint { + path: "/callback".parse().unwrap(), + interface: "org.example.Callback".parse().unwrap(), + members: vec!["Observe".parse().unwrap()], + }]) + .await + .map_err(|error| error.to_string())?, + ); + binding + .serve_at( + "/callback".parse().unwrap(), + ResolverCallback("org.example.Callback".parse().unwrap()), + ) + .await + .map_err(|error| error.to_string())?; + let config = serde_json::json!({ "host": binding.unique_name() }); + Ok((binding, config)) + } + }) + .await + .unwrap(); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1); + let guard = binding.guard(); + let info = runtime + .host() + .list() + .into_iter() + .find(|info| info.manifest.bus_name.as_str() == record.bus_name) + .expect("the requested registry identity should be loaded"); + assert_eq!(info.manifest.object_path.as_str(), record.object_path); + assert!( + runtime + .host() + .retains_host_callback_binding(record.bus_name, &binding) + ); + let peer = guard + .module_peer() + .expect("the callback is bound to a peer"); + assert!(guard.authorizes(&crate::CallContext::brokered(Some(&peer), true))); + + // Repeated resolution with this same retained capability is idempotent; + // a different or ordinary load cannot borrow its authority. + let repeated = resolver + .ensure_loaded_with_host_callback_factory(record.id, |_| async { + panic!("an already loaded callback binding is reused") + }) + .await + .unwrap(); + assert!(Arc::ptr_eq(&binding, &repeated)); + + // A resolution that is ready in the process-wide table but was loaded + // into another runtime cannot lend this resolver a callback binding. + let other_runtime: &'static crate::module::ModuleRuntime = Box::leak(Box::new( + crate::module::ModuleRuntime::start( + &crate::module::RuntimeNames { + thread: "retained-callback-other-test".into(), + worker: "retained-callback-other-worker".into(), + }, + None, + ) + .unwrap(), + )); + let elsewhere = fixture_with_runtime( + record, + true, + HashMap::new(), + None, + Arc::new(move || Box::pin(async move { Ok(other_runtime) })), + ) + .resolver; + let error = elsewhere + .ensure_loaded_with_host_callback_factory(record.id, |_| async { + panic!("a ready resolution never prepares a new callback binding") + }) + .await + .err() + .expect("a ready module in another runtime has no retained binding"); + assert!( + error.contains("loaded without a callback binding"), + "{error}" + ); + + let direct_resolver = fixture_with_runtime( + record, + true, + HashMap::from([( + record.id.to_string(), + ModuleSource::LocalFile { + path: source.to_string_lossy().into_owned(), + }, + )]), + None, + Arc::new(move || Box::pin(async move { Ok(runtime) })), + ) + .resolver; + direct_resolver + .ensure_loaded_with_host_callbacks( + record.id, + Arc::clone(&binding), + serde_json::json!({}), + ) + .await + .expect("the direct callback API reuses the retained binding"); } fn admitted_info(record: &ReleaseRecord, state: ModuleState) -> ModuleInfo { @@ -233,6 +1250,7 @@ mod resolver { ), manifest, init: initialize_raced_module, + init_with_setup: None, }) } @@ -247,6 +1265,7 @@ mod resolver { ), manifest, init: initialize_raced_module, + init_with_setup: None, }) } @@ -261,9 +1280,101 @@ mod resolver { ), manifest, init: initialize_raced_module, + init_with_setup: None, + }) + } + + fn linked_success_module() -> crate::Result { + let mut manifest = super::module("resolver_linked_success", &[], &[]); + manifest.bus_name = crate::BusName::new("ai.tinyhumans.test.resolver_linked_success")?; + manifest.object_path = crate::ObjectPath::new("/ai/tinyhumans/test/Module")?; + Ok(LinkedModule { + descriptor: crate::module::abi::TbAbiDescriptor::current( + "resolver_linked_success", + "1.0.0", + ), + manifest, + init: initialize_raced_module, + init_with_setup: None, + }) + } + + fn linked_conflicting_name_module() -> crate::Result { + let mut manifest = super::module("resolver_linked_attach_failure", &[], &[]); + manifest.bus_name = crate::BusName::new("ai.tinyhumans.test.conflicting_module")?; + manifest.object_path = crate::ObjectPath::new("/ai/tinyhumans/test/Conflict")?; + Ok(LinkedModule { + descriptor: crate::module::abi::TbAbiDescriptor::current( + "resolver_linked_attach_failure", + "1.0.0", + ), + manifest, + init: initialize_raced_module, + init_with_setup: None, }) } + fn linked_attach_target_module() -> crate::Result { + let mut manifest = super::module("resolver_linked_attach_failure", &[], &[]); + manifest.bus_name = + crate::BusName::new("ai.tinyhumans.test.resolver_linked_attach_failure")?; + manifest.object_path = crate::ObjectPath::new("/ai/tinyhumans/test/Module")?; + Ok(LinkedModule { + descriptor: crate::module::abi::TbAbiDescriptor::current( + "resolver_linked_attach_failure", + "1.0.0", + ), + manifest, + init: initialize_raced_module, + init_with_setup: None, + }) + } + + #[tokio::test] + async fn a_loaded_module_with_the_registered_bus_but_wrong_object_path_does_not_satisfy_the_initial_fast_path() + { + let record = record("resolver_wrong_object_path_fast_path"); + let runtime = shared_runtime(); + let mut manifest = super::module(record.id, &[], &[]); + manifest.bus_name = crate::BusName::new(record.bus_name).unwrap(); + manifest.object_path = crate::ObjectPath::new("/ai/tinyhumans/test/WrongObject").unwrap(); + runtime + .host() + .attach_linked_with_config( + LinkedModule { + descriptor: crate::module::abi::TbAbiDescriptor::current(record.id, "1.0.0"), + manifest, + init: initialize_raced_module, + init_with_setup: None, + }, + serde_json::Value::Null, + ) + .unwrap(); + + let preloaded = runtime + .host() + .list() + .into_iter() + .find(|info| info.manifest.bus_name.as_str() == record.bus_name) + .expect("the wrong-path fixture should be admitted under the requested bus name"); + assert_eq!( + preloaded.manifest.object_path.as_str(), + "/ai/tinyhumans/test/WrongObject" + ); + + let f = fixture(record, true, HashMap::new(), None); + let result = f.resolver.ensure_loaded(record.id).await; + + assert!( + result.is_err(), + "a same-bus module with another object path must not resolve the pinned record" + ); + assert!(runtime.host().list().iter().any(|info| { + info.manifest.bus_name.as_str() == record.bus_name + && info.manifest.object_path.as_str() == "/ai/tinyhumans/test/WrongObject" + })); + } + #[test] fn a_duplicate_admission_error_recovers_when_the_requested_module_is_initializing() { let record = record("resolver_raced_admission"); @@ -290,7 +1401,8 @@ mod resolver { allow_download: false, bundled_dir: None, sources, - linked: HashMap::from([(record.id.to_string(), raced_module as LinkedFactory)]), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, search_skip: Vec::new(), gate: None, on_resolved: None, @@ -298,6 +1410,7 @@ mod resolver { terminal_marker: "This is terminal for the running process".into(), runtime: Arc::new(|| Box::pin(async { Ok(shared_runtime()) })), module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, reporter: Arc::new(FailureReporter::new(f.sink.clone())), }); f.resolver.after_initial_host_check = Some(Arc::new(|runtime, _| { @@ -315,6 +1428,145 @@ mod resolver { assert_eq!(f.resolver.state_of(record.id), Availability::Ready); } + #[tokio::test] + async fn a_registered_linked_module_is_loaded_and_reported_ready() { + let record = record("resolver_linked_success"); + let sources = HashMap::from([(record.id.to_string(), ModuleSource::Linked)]); + let f = fixture(record, true, sources, None); + f.resolver + .inner + .linked + .register(record.id, linked_success_module); + + f.resolver.ensure_loaded(record.id).await.unwrap(); + + assert_eq!(f.resolver.state_of(record.id), Availability::Ready); + assert_eq!(f.resolver.status_of(record), (Availability::Ready, None)); + assert!(shared_runtime().host().list().iter().any(|info| { + info.manifest.bus_name.as_str() == record.bus_name + && info.manifest.object_path.as_str() == record.object_path + && matches!(info.state, ModuleState::Ready | ModuleState::Serving) + })); + } + + #[tokio::test] + async fn a_linked_attach_collision_returns_a_terminal_failure() { + let record = record("resolver_linked_attach_failure"); + let runtime = shared_runtime(); + runtime + .host() + .attach_linked_with_config( + linked_conflicting_name_module().unwrap(), + serde_json::Value::Null, + ) + .unwrap(); + let resolver = fixture( + record, + true, + HashMap::from([(record.id.to_string(), ModuleSource::Linked)]), + None, + ) + .resolver; + resolver + .inner + .linked + .register(record.id, linked_attach_target_module); + + let error = resolver.ensure_loaded(record.id).await.unwrap_err(); + + assert!( + error.contains("could not be attached from the linked executable"), + "{error}" + ); + assert_eq!(resolver.state_of(record.id), Availability::Failed); + } + + #[tokio::test] + async fn a_blocking_loader_runtime_failure_is_terminal_and_retryable_only_after_restart() { + let record = record("resolver_blocking_runtime_failure"); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let runtime_calls = Arc::clone(&calls); + let resolver = Resolver::new(ResolverInputs { + enabled: true, + install_dir: None, + allow_download: false, + bundled_dir: None, + sources: HashMap::from([( + record.id.to_string(), + ModuleSource::LocalFile { + path: "/unused/blocked-module.so".to_owned(), + }, + )]), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![record], + terminal_marker: "This is terminal for the running process".to_owned(), + runtime: Arc::new(move || { + let calls = Arc::clone(&runtime_calls); + Box::pin(async move { + if calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { + Ok(shared_runtime()) + } else { + Err(crate::Error::failed("runtime temporarily unavailable")) + } + }) + }), + module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, + reporter: Arc::new(FailureReporter::new(Arc::new(Recording::default()))), + }); + + let error = resolver.ensure_loaded(record.id).await.unwrap_err(); + + assert!(error.contains("the module bus could not start"), "{error}"); + assert!(error.contains("restart the app to try again"), "{error}"); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); + assert_eq!(resolver.state_of(record.id), Availability::Failed); + } + + #[tokio::test] + async fn blocking_work_marks_runtime_startup_errors_as_terminal() { + let record = record("resolver_blocking_runtime_provider_failure"); + let resolver = Resolver::new(ResolverInputs { + enabled: true, + install_dir: None, + allow_download: false, + bundled_dir: None, + sources: HashMap::new(), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![record], + terminal_marker: "This is terminal for the running process".to_owned(), + runtime: Arc::new(|| { + Box::pin(async { Err(crate::Error::failed("runtime startup refused")) }) + }), + module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, + reporter: Arc::new(FailureReporter::new(Arc::new(Recording::default()))), + }); + + let error = resolver + .blocking(|| Ok(())) + .await + .expect_err("blocking work must not run without its runtime"); + + assert!( + error.contains("module bus could not start") + && error.contains("runtime startup refused"), + "{error}" + ); + assert!( + error.contains("This is terminal for the running process"), + "{error}" + ); + } + #[tokio::test] async fn ensure_loaded_rechecks_host_after_search_before_touching_the_release_cache() { let record = record("resolver_search_raced_module"); @@ -356,7 +1608,8 @@ mod resolver { allow_download: false, bundled_dir: None, sources: HashMap::new(), - linked: HashMap::new(), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, search_skip: Vec::new(), gate: None, on_resolved: None, @@ -370,6 +1623,7 @@ mod resolver { .unwrap(); serde_json::json!({}) }), + async_module_config: None, reporter: Arc::new(FailureReporter::new(sink)), }); @@ -429,17 +1683,14 @@ mod resolver { } } - struct Fixture { - sink: Arc, - config_calls: Arc, - runtime_calls: Arc, - resolver: Resolver, - } + use super::super::*; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; - struct ResolverCallback(Arc); + struct CountingCallback(Arc); #[async_trait::async_trait] - impl crate::Interface for ResolverCallback { + impl crate::Interface for CountingCallback { fn name(&self) -> crate::InterfaceName { "org.example.Callback".parse().unwrap() } @@ -456,21 +1707,6 @@ mod resolver { } } - fn fixture( - record: &'static ReleaseRecord, - enabled: bool, - sources: HashMap, - install_dir: Option, - ) -> Fixture { - fixture_with_runtime( - record, - enabled, - sources, - install_dir, - Arc::new(|| Box::pin(async { Ok(shared_runtime()) })), - ) - } - fn fixture_with_runtime( record: &'static ReleaseRecord, enabled: bool, @@ -493,7 +1729,8 @@ mod resolver { allow_download: false, bundled_dir: None, sources, - linked: HashMap::new(), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, search_skip: Vec::new(), gate: None, on_resolved: None, @@ -504,6 +1741,7 @@ mod resolver { counted.fetch_add(1, Ordering::SeqCst); serde_json::json!({}) }), + async_module_config: None, reporter: Arc::new(FailureReporter::new(sink.clone())), }); Fixture { @@ -522,6 +1760,7 @@ mod resolver { descriptor: crate::module::abi::TbAbiDescriptor::current(record.id, "1.0.0"), manifest, init: initialize_raced_module, + init_with_setup: None, } } @@ -554,7 +1793,7 @@ mod resolver { binding .serve_at( "/callback".parse().unwrap(), - ResolverCallback(Arc::new(AtomicUsize::new(0))), + CountingCallback(Arc::new(AtomicUsize::new(0))), ) .await .unwrap(); @@ -612,7 +1851,7 @@ mod resolver { binding .serve_at( "/callback".parse().unwrap(), - ResolverCallback(Arc::new(AtomicUsize::new(0))), + CountingCallback(Arc::new(AtomicUsize::new(0))), ) .await .unwrap(); @@ -664,7 +1903,7 @@ mod resolver { binding .serve_at( "/callback".parse().unwrap(), - ResolverCallback(Arc::new(AtomicUsize::new(0))), + CountingCallback(Arc::new(AtomicUsize::new(0))), ) .await .unwrap(); @@ -742,7 +1981,7 @@ mod resolver { binding .serve_at( "/callback".parse().unwrap(), - ResolverCallback(calls.clone()), + CountingCallback(calls.clone()), ) .await .unwrap(); @@ -822,7 +2061,7 @@ mod resolver { refused_binding .serve_at( "/callback".parse().unwrap(), - ResolverCallback(calls.clone()), + CountingCallback(calls.clone()), ) .await .unwrap(); @@ -881,7 +2120,7 @@ mod resolver { binding .serve_at( "/callback".parse().unwrap(), - ResolverCallback(calls.clone()), + CountingCallback(calls.clone()), ) .await .unwrap(); @@ -964,6 +2203,48 @@ mod resolver { assert!(detail.unwrap().contains("restart the app")); } + #[test] + fn status_of_distinguishes_a_resolution_that_is_still_loading() { + let record = record("resolver-loading-status"); + let f = fixture(record, true, HashMap::new(), None); + let Claim::Run { sender, .. } = resolution::global().claim(record.id) else { + panic!("this test owns the process-global resolution id"); + }; + + assert_eq!(f.resolver.status_of(record), (Availability::Loading, None)); + + resolution::global().complete( + record.id, + Resolution::Failed("test cleanup".to_owned()), + sender, + ); + } + + #[test] + fn invocation_failure_reports_are_not_deduplicated() { + let record = record("resolver-invocation-reports"); + let f = fixture(record, true, HashMap::new(), None); + + f.resolver.report_invocation(record, Reason::ModuleFault); + f.resolver.report_invocation(record, Reason::ModuleFault); + + assert_eq!( + f.sink.0.lock().unwrap().as_slice(), + &[ + (record.id.to_string(), Reason::ModuleFault), + (record.id.to_string(), Reason::ModuleFault), + ] + ); + } + + #[test] + fn still_loading_has_a_retry_message_for_non_bounded_callers() { + assert_eq!( + LoadError::StillLoading.into_message(), + "the module is still loading; try again shortly" + ); + } + #[test] fn disabled_configuration_wins_over_a_loaded_module() { let record = record("resolver-disabled-status"); @@ -1065,7 +2346,8 @@ mod resolver { allow_download: false, bundled_dir: None, sources: HashMap::new(), - linked: HashMap::new(), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, search_skip: Vec::new(), gate: Some(Arc::new(|id| { (id == "resolver-gated").then_some("test-switch") @@ -1078,6 +2360,7 @@ mod resolver { Box::pin(async { Ok(shared_runtime()) }) }), module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, reporter: Arc::new(FailureReporter::new(sink.clone())), }); let refused = f @@ -1122,10 +2405,15 @@ mod resolver { allow_download: false, bundled_dir: None, sources, - linked: HashMap::from([( - record.id.to_string(), - (|| Err(crate::Error::Transport("secret path /home/x".into()))) as LinkedFactory, - )]), + linked: { + fn failing_factory() -> crate::Result { + Err(crate::Error::Transport("secret path /home/x".into())) + } + let registry = Arc::new(LinkedRegistry::new()); + registry.register(record.id, failing_factory); + registry + }, + search_paths: None, search_skip: Vec::new(), gate: None, on_resolved: None, @@ -1133,6 +2421,7 @@ mod resolver { terminal_marker: String::new(), runtime: Arc::new(|| Box::pin(async { Ok(shared_runtime()) })), module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, reporter: Arc::new(FailureReporter::new(f.sink.clone())), }); let error = f.resolver.ensure_loaded(record.id).await.unwrap_err(); @@ -1155,7 +2444,8 @@ mod resolver { allow_download: false, bundled_dir: None, sources: HashMap::new(), - linked: HashMap::new(), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, search_skip: Vec::new(), gate: None, on_resolved: Some(Arc::new(move |id| recorded.lock().unwrap().push(id.into()))), @@ -1163,6 +2453,7 @@ mod resolver { terminal_marker: String::new(), runtime: Arc::new(|| Box::pin(async { Ok(shared_runtime()) })), module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, reporter: Arc::new(FailureReporter::new(sink)), }); let _ = resolver.ensure_loaded(record.id).await; @@ -1184,18 +2475,22 @@ mod resolver { descriptor: crate::module::abi::TbAbiDescriptor::current("impostor", "1.0.0"), manifest: super::module("Impostor", &[], &[]), init: never_initialised, + init_with_setup: None, }) } let record = record("resolver-linked-impostor"); let sources = HashMap::from([(record.id.to_string(), ModuleSource::Linked)]); let mut f = fixture(record, true, sources.clone(), None); + let linked = Arc::new(LinkedRegistry::new()); + linked.register(record.id, impostor); f.resolver = Resolver::new(ResolverInputs { enabled: true, install_dir: None, allow_download: false, bundled_dir: None, sources, - linked: HashMap::from([(record.id.to_string(), impostor as LinkedFactory)]), + linked, + search_paths: None, search_skip: Vec::new(), gate: None, on_resolved: None, @@ -1203,6 +2498,7 @@ mod resolver { terminal_marker: String::new(), runtime: Arc::new(|| Box::pin(async { Ok(shared_runtime()) })), module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, reporter: Arc::new(FailureReporter::new(f.sink.clone())), }); let error = f.resolver.ensure_loaded(record.id).await.unwrap_err(); @@ -1230,7 +2526,8 @@ mod resolver { allow_download: false, bundled_dir: None, sources: HashMap::new(), - linked: HashMap::new(), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, search_skip: Vec::new(), gate: Some(Arc::new(|_| Some("test-switch"))), on_resolved: None, @@ -1238,6 +2535,7 @@ mod resolver { terminal_marker: String::new(), runtime: Arc::new(|| Box::pin(async { Ok(shared_runtime()) })), module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, reporter: Arc::new(FailureReporter::new(f.sink.clone())), }); let (state, detail) = gated.status_of(record); @@ -1255,7 +2553,8 @@ mod resolver { allow_download: false, bundled_dir: None, sources: HashMap::new(), - linked: HashMap::new(), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, search_skip: Vec::new(), gate: None, on_resolved: Some(Arc::new(|_| panic!("hook bug"))), @@ -1263,6 +2562,7 @@ mod resolver { terminal_marker: String::new(), runtime: Arc::new(|| Box::pin(async { Ok(shared_runtime()) })), module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, reporter: Arc::new(FailureReporter::new(sink)), }); let outcome = resolver @@ -1318,7 +2618,8 @@ mod resolver { allow_download: false, bundled_dir: None, sources: HashMap::new(), - linked: HashMap::new(), + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, search_skip: Vec::new(), gate: None, on_resolved: None, @@ -1328,6 +2629,7 @@ mod resolver { Box::pin(async { Err(crate::Error::Transport("no runtime".to_string())) }) }), module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, reporter: Arc::new(FailureReporter::new(f.sink.clone())), }); assert_eq!( @@ -1411,6 +2713,7 @@ mod resolver { descriptor: crate::module::abi::TbAbiDescriptor::current($id, "1.0.0"), manifest, init: $init, + init_with_setup: None, }) } }; @@ -1423,7 +2726,12 @@ mod resolver { allow_download: false, bundled_dir: None, sources: HashMap::from([(record.id.to_string(), ModuleSource::Linked)]), - linked: HashMap::from([(record.id.to_string(), factory)]), + linked: { + let registry = Arc::new(LinkedRegistry::new()); + registry.register(record.id, factory); + registry + }, + search_paths: None, search_skip: Vec::new(), gate: None, on_resolved: None, @@ -1431,6 +2739,7 @@ mod resolver { terminal_marker: "This is terminal for the running process".into(), runtime: Arc::new(|| Box::pin(async { Ok(shared_runtime()) })), module_config: Arc::new(|_| serde_json::json!({})), + async_module_config: None, reporter: Arc::new(FailureReporter::new(Arc::new(Recording::default()))), }) } @@ -1574,6 +2883,141 @@ mod resolver { } } +#[test] +fn bundled_directory_lookup_uses_registered_then_env_then_executable_sibling() { + let root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let registered = root.path().join("registered"); + let from_env = root.path().join("environment"); + let exe_dir = root.path().join("bin"); + let sibling = exe_dir.join("bundled-modules"); + for directory in [®istered, &from_env, &sibling] { + std::fs::create_dir_all(directory).unwrap(); + } + + assert_eq!( + resolve_bundled_dir_from( + Some(registered.clone()), + Some(from_env.clone()), + Some(exe_dir.clone()), + ), + Some(registered) + ); + assert_eq!( + resolve_bundled_dir_from(None, Some(from_env.clone()), Some(exe_dir.clone())), + Some(from_env) + ); + assert_eq!( + resolve_bundled_dir_from(None, None, Some(exe_dir)), + Some(sibling) + ); +} + +#[test] +fn bundled_directory_lookup_skips_missing_higher_precedence_candidates() { + let root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let missing_registered = root.path().join("not-installed"); + let valid_env = root.path().join("environment"); + std::fs::create_dir_all(&valid_env).unwrap(); + + assert_eq!( + resolve_bundled_dir_from(Some(missing_registered), Some(valid_env.clone()), None), + Some(valid_env) + ); +} + +#[test] +fn bundled_directory_lookup_reads_the_explicit_environment_variable_name() { + let root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let from_env = root.path().join("environment"); + std::fs::create_dir_all(&from_env).unwrap(); + let env_var = "TINYBUS_TEST_BUNDLED_RELEASES"; + unsafe { std::env::set_var(env_var, &from_env) }; + + let resolved_from_env = resolve_bundled_dir(env_var); + unsafe { std::env::remove_var(env_var) }; + assert_eq!(resolved_from_env, Some(from_env)); + + let registered = root.path().join("registered"); + std::fs::create_dir_all(®istered).unwrap(); + assert!(set_bundled_releases_dir(registered.clone()).is_ok()); + assert_eq!(resolve_bundled_dir(env_var), Some(registered)); +} + +#[test] +fn linked_registry_replaces_factories_and_returns_the_last_registration() { + fn first_factory() -> crate::Result { + Err(crate::Error::failed("first factory")) + } + fn replacement_factory() -> crate::Result { + Err(crate::Error::failed("replacement factory")) + } + + let registry = LinkedRegistry::new(); + assert!(!registry.is_registered("registry-test")); + assert!(registry.get("registry-test").is_none()); + + registry.register("registry-test", first_factory); + assert!(registry.is_registered("registry-test")); + let first = (registry.get("registry-test").unwrap())(); + assert!(matches!(first, Err(error) if error.to_string().contains("first factory"))); + + registry.register("registry-test", replacement_factory); + let replacement = (registry.get("registry-test").unwrap())(); + assert!(matches!(replacement, Err(error) if error.to_string().contains("replacement factory"))); + + let global = LinkedRegistry::global(); + let same_global = LinkedRegistry::global(); + global.register("global-registry-test", first_factory); + assert!(same_global.is_registered("global-registry-test")); + let global_result = (same_global.get("global-registry-test").unwrap())(); + assert!(matches!(global_result, Err(error) if error.to_string().contains("first factory"))); + + let default_registry = LinkedRegistry::default(); + assert!(!default_registry.is_registered("registry-test")); +} + +struct ResolverSearchSink; + +impl crate::module::FailureSink for ResolverSearchSink { + fn capture(&self, _: &crate::module::FailureReport<'_>) {} +} + +#[tokio::test] +async fn resolver_inputs_configure_the_runtime_host_search_paths() { + let root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let search_dir = root.path().join("resolver-search"); + std::fs::create_dir(&search_dir).unwrap(); + let env_var = "TINYBUS_TEST_RESOLVER_SEARCH_PATH"; + unsafe { std::env::set_var(env_var, &search_dir) }; + let runtime: &'static crate::module::ModuleRuntime = Box::leak(Box::new( + crate::module::ModuleRuntime::start(&crate::module::RuntimeNames::default(), None).unwrap(), + )); + let resolver = Resolver::new(ResolverInputs { + enabled: true, + install_dir: None, + allow_download: false, + bundled_dir: None, + sources: HashMap::new(), + linked: Arc::new(LinkedRegistry::new()), + search_paths: Some(SearchPathConfig::new(env_var, "test-host/modules")), + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: Vec::new(), + terminal_marker: String::new(), + runtime: Arc::new(move || Box::pin(async move { Ok(runtime) })), + module_config: Arc::new(|_| serde_json::Value::Null), + async_module_config: None, + reporter: Arc::new(FailureReporter::new(Arc::new(ResolverSearchSink))), + }); + + let resolved_runtime = resolver.runtime().await.unwrap(); + let paths = resolved_runtime.host().search_paths(); + unsafe { std::env::remove_var(env_var) }; + + assert_eq!(paths.first(), Some(&search_dir)); +} + fn info_in(state: ModuleState) -> ModuleInfo { ModuleInfo { name: "probe".into(), diff --git a/crates/tinybus/src/module/runtime.rs b/crates/tinybus/src/module/runtime.rs index a0680218..386a75b8 100644 --- a/crates/tinybus/src/module/runtime.rs +++ b/crates/tinybus/src/module/runtime.rs @@ -29,18 +29,44 @@ use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use crate::broker::Broker; +use crate::connection::ObjectRegistration; use crate::module::ModuleHost; -use crate::transport::memory::MemoryBus; -use crate::{Connection, Proxy}; +use crate::transport::memory::{MemoryBus, MemoryTransport}; +use crate::{Connection, Interface, Proxy}; + +/// A per-attachment setup callback for a statically linked module. +/// +/// Unlike the module's export-time setup function, this value belongs to one +/// admission only and can capture host state without process-global slots. +pub type LinkedSetup = + Box Pin> + Send>> + Send>; + +/// Rust-only entrypoint emitted by `tinybus_module::module_export_static!`. +/// +/// Dynamic modules continue to use the stable C ABI [`crate::module::abi::TbModuleInit`]. +/// This entrypoint is only for linked exports and replaces that export's +/// default setup callback for one initialization. +#[doc(hidden)] +pub type LinkedModuleInitWithSetup = unsafe fn( + *const crate::module::abi::TbHostVtable, + *mut crate::module::abi::TbModuleVtable, + LinkedSetup, +) -> i32; /// Work run once on the new runtime, before the first module can load. /// /// A host uses it to install the callback services its modules call back into, /// so they are being served before any module resolves. -pub type RuntimeSetup = - Box Pin> + Send>> + Send>; +pub type RuntimeSetup = Box< + dyn for<'a> FnOnce( + Connection, + &'a ModuleHost, + ) -> Pin> + Send + 'a>> + + Send, +>; /// Thread names for a [`ModuleRuntime`], so a host's threads are recognisable. #[derive(Debug, Clone)] @@ -60,13 +86,12 @@ impl Default for RuntimeNames { } } -/// Optional configuration for the module runtime. +/// Options for the module runtime's dedicated Tokio worker threads. #[derive(Debug, Clone, Default)] pub struct RuntimeOptions { - /// Stack size for each Tokio worker thread. + /// Override the stack size of each Tokio worker thread. /// - /// `None` preserves Tokio's platform default. Hosts that load modules with - /// stack-heavy initialization can set a larger value. + /// `None` preserves Tokio's default stack size. pub thread_stack_size: Option, } @@ -78,6 +103,103 @@ pub struct ModuleRuntime { connection: Connection, /// Handle for work that must outlive the caller's runtime. handle: tokio::runtime::Handle, + host_objects: std::sync::Arc>, +} + +pub(super) struct HostObjectRegistry { + broker: Broker, + connection: Connection, + names: std::collections::HashMap, + #[cfg(test)] + before_name_request: Option, + #[cfg(test)] + after_name_claim: Option, +} + +#[cfg(test)] +#[derive(Clone)] +struct HostObjectRegistrationBarrier { + entered: std::sync::Arc, + release: std::sync::Arc, +} + +struct HostNameLease { + registrations: usize, + release_when_empty: bool, +} + +/// Owns one host object export and its share of a bus-name claim. +/// +/// Dropping the guard schedules cleanup on its [`ModuleRuntime`]'s dedicated +/// runtime, so it is safe to drop from synchronous code or another runtime. +pub struct HostObjectGuard { + registry: std::sync::Arc>, + handle: tokio::runtime::Handle, + name: crate::BusName, + registration: Option, +} + +impl Drop for HostObjectGuard { + fn drop(&mut self) { + let Some(registration) = self.registration.take() else { + return; + }; + let registry = self.registry.clone(); + let name = self.name.clone(); + let handle = self.handle.clone(); + self.handle.spawn(async move { + remove_host_object(registry, name, registration, handle).await; + }); + } +} + +impl HostObjectGuard { + /// Withdraw this exact registration and wait for the local broker to remove it. + pub(super) async fn remove(mut self) { + let Some(registration) = self.registration.take() else { + return; + }; + remove_host_object( + self.registry.clone(), + self.name.clone(), + registration, + self.handle.clone(), + ) + .await; + } +} + +async fn remove_host_object( + registry: std::sync::Arc>, + name: crate::BusName, + registration: ObjectRegistration, + handle: tokio::runtime::Handle, +) { + let mut state = registry.lock().await; + let detached = state.connection.take_if_owned(®istration).await; + let release = if let Some(lease) = state.names.get_mut(&name) { + lease.registrations = lease.registrations.saturating_sub(1); + if lease.registrations == 0 { + let release = lease.release_when_empty; + state.names.remove(&name); + release + } else { + false + } + } else { + false + }; + if release { + if let Some(unique) = state.connection.unique_name() { + let _ = state.broker.release_host_name(&unique, &name); + } + } + drop(state); + if let Some(interface) = detached { + // Interface destructors are user code. Keep this parent cleanup task + // alive to publish completion even if the destructor unwinds. + let _ = handle.spawn(async move { drop(interface) }).await; + } } impl ModuleRuntime { @@ -95,12 +217,15 @@ impl ModuleRuntime { Self::start_with_options(names, setup, RuntimeOptions::default()) } - /// Start a runtime with explicit configuration and wait until it is ready. + /// Start a runtime with explicit worker-thread options. + /// + /// The process-lifetime behavior and setup ordering match [`Self::start`]. + /// Options only override values supplied as `Some`. /// - /// This has the same lifetime and error behavior as [`Self::start`]. The - /// options entry point lets hosts that load stack-heavy native modules - /// increase the Tokio worker-thread stack while keeping the default API - /// and behavior unchanged. + /// # Errors + /// + /// Returns an error if the thread or Tokio runtime cannot be built, the + /// in-memory transport cannot be connected, or `setup` fails. pub fn start_with_options( names: &RuntimeNames, setup: Option, @@ -187,25 +312,161 @@ impl ModuleRuntime { pub fn proxy(&self, bus_name: &str, object_path: &str) -> crate::Result { self.connection.proxy(bus_name, object_path, bus_name) } + + /// Serve one ordinary host interface on the module runtime's bus. + /// + /// Same-name registrations share one name claim. Each guard owns only its + /// exact interface registration, so dropping an older guard cannot remove + /// a later replacement made through this helper or [`Connection::serve_at`]. + /// A name already owned by this connection is preserved after the final + /// guard is dropped; a name claimed by another peer is refused. + /// + /// This is an ordinary host object registration. It does not attest this + /// peer or grant the interface confidential-call authority. + pub async fn serve_host_object( + &self, + name: crate::BusName, + path: crate::ObjectPath, + interface: impl Interface, + ) -> crate::Result { + let registry = self.host_objects.clone(); + let handle = self.handle.clone(); + let (response, result) = tokio::sync::oneshot::channel(); + self.handle.spawn(async move { + let guard = + serve_host_object_transaction(registry, handle, name, path, interface).await; + // If the caller canceled, dropping this failed send drops the guard; + // its cleanup is still owned by the dedicated runtime. + let _ = response.send(guard); + }); + result.await.map_err(|_| crate::Error::ConnectionClosed)? + } + + pub(super) fn lease_host_object_registry( + &self, + ) -> std::sync::Arc> { + self.host_objects.clone() + } +} + +pub(super) async fn serve_host_object_transaction( + registry: std::sync::Arc>, + handle: tokio::runtime::Handle, + name: crate::BusName, + path: crate::ObjectPath, + interface: impl Interface, +) -> crate::Result { + let mut state = registry.lock().await; + let own_unique = state.connection.unique_name(); + let unique = own_unique.as_ref().ok_or(crate::Error::ConnectionClosed)?; + // Preserve the public connection's fail-closed behavior for an already + // closed runtime connection. The following atomic local claim, rather + // than this observation, determines whether this helper acquired the name. + state.connection.name_owner(name.as_str()).await?; + #[cfg(test)] + if let Some(barrier) = state.before_name_request.clone() { + barrier.entered.add_permits(1); + let _permit = barrier + .release + .acquire() + .await + .expect("name-request barrier"); + } + let newly_claimed = state + .broker + .claim_host_name(unique, name.clone()) + .map_err(|_| crate::Error::failed("host object bus name is already owned"))?; + let release_when_empty = newly_claimed + || state + .names + .get(&name) + .is_some_and(|lease| lease.release_when_empty); + #[cfg(test)] + if let Some(barrier) = state.after_name_claim.clone() { + barrier.entered.add_permits(1); + let _permit = barrier + .release + .acquire() + .await + .expect("registration barrier"); + } + // Run the user-provided interface registration in an owned child task so + // a panic in `Interface::name` becomes a registration error here. The + // transaction still owns the name claim and can roll it back below. + let registration_connection = state.connection.clone(); + let registration_task = handle.spawn(async move { + registration_connection + .serve_at_owned(path, interface) + .await + }); + let registration = match registration_task.await { + Ok(registration) => registration, + Err(_) => Err(crate::Error::failed( + "host object interface registration task failed", + )), + }; + let registration = match registration { + Ok(registration) => registration, + Err(error) => { + if release_when_empty { + if let Some(lease) = state.names.get_mut(&name) { + lease.release_when_empty = true; + } else { + if let Some(unique) = state.connection.unique_name() { + let _ = state.broker.release_host_name(&unique, &name); + } + } + } + return Err(error); + } + }; + let lease = state.names.entry(name.clone()).or_insert(HostNameLease { + registrations: 0, + release_when_empty, + }); + lease.release_when_empty |= release_when_empty; + lease.registrations += 1; + Ok(HostObjectGuard { + registry: registry.clone(), + handle, + name, + registration: Some(registration), + }) } async fn build(setup: Option) -> crate::Result { let transport = MemoryBus::new(); - let broker = Broker::new(); + let broker = Broker::new_restricted_module_control(); // The broker task is deliberately not retained: it lives as long as the // process, and an abort handle must never be called, because a module whose // transport disappears faults and cannot be reloaded without a restart. broker.spawn(transport.clone()); - let host = ModuleHost::new(broker); - let connection = Connection::connect(transport.connect().await?).await?; + // This runtime is the private host for the modules it admits. Select its + // own controller before constructing the loader so context-bearing module + // setup receives an attachment-scoped trusted-host capability. + let (broker_transport, controller_transport) = MemoryTransport::pair(); + broker.attach_module_controller(Arc::new(broker_transport))?; + let connection = Connection::connect(Box::new(controller_transport)).await?; + + let host = ModuleHost::new(broker.clone()); + let host_objects = std::sync::Arc::new(tokio::sync::Mutex::new(HostObjectRegistry { + broker, + connection: connection.clone(), + names: std::collections::HashMap::new(), + #[cfg(test)] + before_name_request: None, + #[cfg(test)] + after_name_claim: None, + })); if let Some(setup) = setup { - setup(connection.clone()).await?; + setup(connection.clone(), &host).await?; } Ok(ModuleRuntime { host, connection, handle: tokio::runtime::Handle::current(), + host_objects, }) } diff --git a/crates/tinybus/src/module/runtime_tests.rs b/crates/tinybus/src/module/runtime_tests.rs index 1939629a..ff9a415e 100644 --- a/crates/tinybus/src/module/runtime_tests.rs +++ b/crates/tinybus/src/module/runtime_tests.rs @@ -2,12 +2,111 @@ use super::*; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +struct HostObjectValue(&'static str); + +struct PanickingHostObjectName; + +struct PanickingHostObjectDrop(Arc); + +impl Drop for PanickingHostObjectDrop { + fn drop(&mut self) { + assert!( + !self.0.load(Ordering::Acquire), + "retired callback destructor panic" + ); + } +} + +#[async_trait::async_trait] +impl crate::Interface for HostObjectValue { + fn name(&self) -> crate::InterfaceName { + "org.example.ModuleRuntimeHostObject".parse().unwrap() + } + + fn members(&self) -> Vec { + vec!["Value".parse().unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Ok(serde_json::json!(self.0)) + } +} + +#[async_trait::async_trait] +impl crate::Interface for PanickingHostObjectName { + fn name(&self) -> crate::InterfaceName { + panic!("fixture interface name panic") + } + + fn members(&self) -> Vec { + vec!["Value".parse().unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Ok(serde_json::Value::Null) + } +} + +#[async_trait::async_trait] +impl crate::Interface for PanickingHostObjectDrop { + fn name(&self) -> crate::InterfaceName { + "org.example.ModuleRuntimeHostObject".parse().unwrap() + } + + fn members(&self) -> Vec { + vec!["Value".parse().unwrap()] + } + + async fn call( + &self, + _: &crate::MemberName, + _: serde_json::Value, + ) -> crate::Result { + Ok(serde_json::json!("old")) + } +} + +async fn wait_for_owner(connection: &Connection, name: &str, expected: Option) { + for _ in 0..1_000 { + if connection.name_owner(name).await.unwrap() == expected { + return; + } + tokio::task::yield_now().await; + } + panic!("bus name owner did not reach the expected state"); +} + +async fn wait_for_host_name_lease_cleanup(runtime: &ModuleRuntime, name: &crate::BusName) { + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if !runtime.host_objects.lock().await.names.contains_key(name) { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("host object guard cleanup did not finish"); +} + #[tokio::test] async fn setup_runs_before_the_runtime_is_handed_out() { let ran = Arc::new(AtomicBool::new(false)); let flag = ran.clone(); - let setup: RuntimeSetup = Box::new(move |_connection| { + let setup: RuntimeSetup = Box::new(move |_connection, host| { Box::pin(async move { + // Setup runs before the runtime is handed out, but it must already + // have access to the loader so it can prepare host services before + // module initialization starts. + assert!(host.list().is_empty()); flag.store(true, Ordering::SeqCst); Ok(()) }) @@ -18,12 +117,41 @@ async fn setup_runs_before_the_runtime_is_handed_out() { assert!(runtime.connection().unique_name().is_some()); } +#[tokio::test] +async fn runtime_options_set_the_worker_stack_size_and_keep_the_worker_name() { + let names = RuntimeNames { + thread: "tinybus-options-runtime".into(), + worker: "tinybus-options-worker".into(), + }; + let runtime = ModuleRuntime::start_with_options( + &names, + None, + RuntimeOptions { + thread_stack_size: Some(4 * 1024 * 1024), + }, + ) + .unwrap(); + + let worker_name = runtime + .spawn(async { std::thread::current().name().map(str::to_owned) }) + .await + .unwrap(); + assert_eq!(worker_name.as_deref(), Some(names.worker.as_str())); +} + #[tokio::test] async fn a_failing_setup_fails_the_start() { - let setup: RuntimeSetup = - Box::new(|_| Box::pin(async { Err(crate::Error::Transport("setup".to_string())) })); + let ran = Arc::new(AtomicBool::new(false)); + let was_run = ran.clone(); + let setup: RuntimeSetup = Box::new(move |_connection, _host| { + Box::pin(async move { + was_run.store(true, Ordering::SeqCst); + Err(crate::Error::Transport("setup".to_string())) + }) + }); let result = ModuleRuntime::start(&RuntimeNames::default(), Some(setup)); assert!(result.is_err()); + assert!(ran.load(Ordering::SeqCst)); } #[tokio::test] @@ -51,6 +179,599 @@ async fn work_outlives_the_caller_runtime_and_blocking_reports_errors() { assert!(panicked.unwrap_err().contains("did not finish")); } +#[tokio::test] +async fn host_object_guards_share_names_and_only_remove_their_owned_registration() { + let runtime = ModuleRuntime::start(&RuntimeNames::default(), None).unwrap(); + let name: crate::BusName = "ai.tinyhumans.test.RuntimeHostObject".parse().unwrap(); + let first_path: crate::ObjectPath = "/runtime/first".parse().unwrap(); + let second_path: crate::ObjectPath = "/runtime/second".parse().unwrap(); + let first = runtime + .serve_host_object(name.clone(), first_path.clone(), HostObjectValue("old")) + .await + .unwrap(); + assert!( + runtime + .connection() + .attestation(runtime.connection().unique_name().unwrap()) + .await + .unwrap() + .is_none(), + "ordinary host objects do not attest their connection" + ); + let second = runtime + .serve_host_object(name.clone(), second_path.clone(), HostObjectValue("second")) + .await + .unwrap(); + let replacement = runtime + .serve_host_object(name.clone(), first_path.clone(), HostObjectValue("new")) + .await + .unwrap(); + + drop(first); + let old_guard_proxy = runtime + .connection() + .proxy( + name.as_str(), + first_path.as_str(), + "org.example.ModuleRuntimeHostObject", + ) + .unwrap(); + let replaced: serde_json::Value = old_guard_proxy.call("Value", ()).await.unwrap(); + assert_eq!(replaced, serde_json::json!("new")); + wait_for_owner( + runtime.connection(), + name.as_str(), + runtime.connection().unique_name(), + ) + .await; + + let remaining_proxy = runtime + .connection() + .proxy( + name.as_str(), + second_path.as_str(), + "org.example.ModuleRuntimeHostObject", + ) + .unwrap(); + let remaining: serde_json::Value = remaining_proxy.call("Value", ()).await.unwrap(); + assert_eq!(remaining, serde_json::json!("second")); + + runtime + .connection() + .serve_at(first_path.clone(), HostObjectValue("direct replacement")) + .await + .unwrap(); + drop(replacement); + let direct_replacement = runtime + .connection() + .proxy( + runtime.connection().unique_name().unwrap().as_str(), + first_path.as_str(), + "org.example.ModuleRuntimeHostObject", + ) + .unwrap(); + let value: serde_json::Value = direct_replacement.call("Value", ()).await.unwrap(); + assert_eq!(value, serde_json::json!("direct replacement")); + wait_for_owner( + runtime.connection(), + name.as_str(), + runtime.connection().unique_name(), + ) + .await; + + drop(second); + wait_for_owner(runtime.connection(), name.as_str(), None).await; + let removed: crate::Result = remaining_proxy.call("Value", ()).await; + assert!(removed.is_err()); + let direct: serde_json::Value = direct_replacement.call("Value", ()).await.unwrap(); + assert_eq!(direct, serde_json::json!("direct replacement")); +} + +#[tokio::test] +async fn replacing_a_host_object_contains_the_retired_callback_drop_panic() { + let runtime = ModuleRuntime::start(&RuntimeNames::default(), None).unwrap(); + let name: crate::BusName = "ai.tinyhumans.test.RuntimeHostObjectDropPanic" + .parse() + .unwrap(); + let path: crate::ObjectPath = "/runtime/drop_panic_replacement".parse().unwrap(); + let armed = Arc::new(AtomicBool::new(false)); + let first = runtime + .serve_host_object( + name.clone(), + path.clone(), + PanickingHostObjectDrop(armed.clone()), + ) + .await + .unwrap(); + armed.store(true, Ordering::Release); + + let replacement = runtime + .serve_host_object(name.clone(), path.clone(), HostObjectValue("new")) + .await + .expect("retiring a panicking callback must preserve the new registration"); + let proxy = runtime + .connection() + .proxy( + name.as_str(), + path.as_str(), + "org.example.ModuleRuntimeHostObject", + ) + .unwrap(); + let value: serde_json::Value = proxy.call("Value", ()).await.unwrap(); + assert_eq!(value, serde_json::json!("new")); + + drop(first); + let value: serde_json::Value = proxy.call("Value", ()).await.unwrap(); + assert_eq!(value, serde_json::json!("new")); + drop(replacement); + wait_for_owner(runtime.connection(), name.as_str(), None).await; + assert!(proxy.call::("Value", ()).await.is_err()); +} + +#[tokio::test] +async fn dropping_a_host_object_guard_outside_a_tokio_runtime_releases_its_name() { + let runtime = ModuleRuntime::start(&RuntimeNames::default(), None).unwrap(); + let name: crate::BusName = "ai.tinyhumans.test.DropOutsideRuntime".parse().unwrap(); + let path: crate::ObjectPath = "/runtime/drop_outside".parse().unwrap(); + let guard = runtime + .serve_host_object(name.clone(), path, HostObjectValue("value")) + .await + .unwrap(); + std::thread::spawn(move || drop(guard)).join().unwrap(); + wait_for_owner(runtime.connection(), name.as_str(), None).await; +} + +#[tokio::test] +async fn dropping_a_host_object_guard_preserves_a_name_owned_before_registration() { + let runtime = ModuleRuntime::start(&RuntimeNames::default(), None).unwrap(); + let name: crate::BusName = "ai.tinyhumans.test.PreownedHostObject".parse().unwrap(); + let path: crate::ObjectPath = "/runtime/preowned".parse().unwrap(); + runtime + .connection() + .request_name(name.as_str()) + .await + .unwrap(); + let owner = runtime.connection().unique_name(); + let guard = runtime + .serve_host_object(name.clone(), path, HostObjectValue("value")) + .await + .unwrap(); + drop(guard); + wait_for_owner(runtime.connection(), name.as_str(), owner).await; +} + +#[tokio::test] +async fn a_name_collision_leaves_no_host_object_registration_behind() { + let name: crate::BusName = "ai.tinyhumans.test.HostObjectCollision".parse().unwrap(); + let path: crate::ObjectPath = "/runtime/collision".parse().unwrap(); + let runtime = ModuleRuntime::start(&RuntimeNames::default(), None).unwrap(); + let manifest = crate::module::manifest::ModuleManifest { + schema: crate::module::manifest::MANIFEST_SCHEMA, + module: crate::module::manifest::ModuleIdentity { + name: "host-object-collision".to_string(), + version: crate::version::Version::new(0, 1, 0), + description: String::new(), + homepage: None, + license: String::new(), + }, + bus_name: name.clone(), + object_path: path.clone(), + provides: Vec::new(), + requires: Vec::new(), + environment: Vec::new(), + capabilities: Vec::new(), + lazy_init: true, + worker_threads: 1, + on_panic: crate::module::manifest::PanicPolicy::Detach, + }; + let descriptor = crate::module::abi::TbAbiDescriptor::current("host-object-collision", "0.1.0"); + runtime + .host() + .attach_linked_with_config( + crate::module::LinkedModule { + descriptor, + manifest: manifest.clone(), + init: collision_module_init, + init_with_setup: None, + }, + serde_json::Value::Null, + ) + .unwrap(); + let owner = runtime + .connection() + .name_owner(name.as_str()) + .await + .unwrap(); + assert!(owner.is_some()); + assert!( + runtime + .serve_host_object(name.clone(), path.clone(), HostObjectValue("must not leak")) + .await + .is_err() + ); + wait_for_owner(runtime.connection(), name.as_str(), owner).await; + let proxy = runtime + .connection() + .proxy( + name.as_str(), + path.as_str(), + "org.example.ModuleRuntimeHostObject", + ) + .unwrap(); + let leaked: crate::Result = proxy.call("Value", ()).await; + assert!(leaked.is_err()); +} + +#[tokio::test] +async fn a_name_claim_race_preserves_the_peer_that_wins_before_registration() { + let runtime = Arc::new(ModuleRuntime::start(&RuntimeNames::default(), None).unwrap()); + let name: crate::BusName = "ai.tinyhumans.test.HostObjectNameRace".parse().unwrap(); + let path: crate::ObjectPath = "/runtime/name_race".parse().unwrap(); + let barrier = HostObjectRegistrationBarrier { + entered: Arc::new(tokio::sync::Semaphore::new(0)), + release: Arc::new(tokio::sync::Semaphore::new(0)), + }; + runtime.host_objects.lock().await.before_name_request = Some(barrier.clone()); + + let serving_runtime = runtime.clone(); + let serving_name = name.clone(); + let serving_path = path.clone(); + let serving = tokio::spawn(async move { + serving_runtime + .serve_host_object( + serving_name, + serving_path, + HostObjectValue("must not register"), + ) + .await + }); + let entered = + tokio::time::timeout(std::time::Duration::from_secs(1), barrier.entered.acquire()) + .await + .expect("host registration did not reach the name-request race") + .unwrap(); + + let manifest = crate::module::manifest::ModuleManifest { + schema: crate::module::manifest::MANIFEST_SCHEMA, + module: crate::module::manifest::ModuleIdentity { + name: "host-object-name-race".to_string(), + version: crate::version::Version::new(0, 1, 0), + description: String::new(), + homepage: None, + license: String::new(), + }, + bus_name: name.clone(), + object_path: path.clone(), + provides: Vec::new(), + requires: Vec::new(), + environment: Vec::new(), + capabilities: Vec::new(), + lazy_init: true, + worker_threads: 1, + on_panic: crate::module::manifest::PanicPolicy::Detach, + }; + runtime + .host() + .attach_linked_with_config( + crate::module::LinkedModule { + descriptor: crate::module::abi::TbAbiDescriptor::current( + "host-object-name-race", + "0.1.0", + ), + manifest, + init: collision_module_init, + init_with_setup: None, + }, + serde_json::Value::Null, + ) + .unwrap(); + let winning_owner = runtime + .connection() + .name_owner(name.as_str()) + .await + .unwrap() + .expect("the linked peer should win the name claim"); + + barrier.release.add_permits(1); + drop(entered); + assert!(serving.await.unwrap().is_err()); + runtime.host_objects.lock().await.before_name_request = None; + + assert_eq!( + runtime + .connection() + .name_owner(name.as_str()) + .await + .unwrap(), + Some(winning_owner), + "failed host registration must not release the peer's name" + ); + assert!(!runtime.host_objects.lock().await.names.contains_key(&name)); + let proxy = runtime + .connection() + .proxy( + runtime.connection().unique_name().unwrap().as_str(), + path.as_str(), + "org.example.ModuleRuntimeHostObject", + ) + .unwrap(); + let leaked: crate::Result = proxy.call("Value", ()).await; + assert!( + leaked.is_err(), + "the failed registration must not publish an object" + ); +} + +#[tokio::test] +async fn a_same_connection_name_claim_that_wins_before_registration_is_preserved() { + let runtime = Arc::new(ModuleRuntime::start(&RuntimeNames::default(), None).unwrap()); + let name: crate::BusName = "ai.tinyhumans.test.SamePeerNameRace".parse().unwrap(); + let path: crate::ObjectPath = "/runtime/same_peer_name_race".parse().unwrap(); + let barrier = HostObjectRegistrationBarrier { + entered: Arc::new(tokio::sync::Semaphore::new(0)), + release: Arc::new(tokio::sync::Semaphore::new(0)), + }; + runtime.host_objects.lock().await.before_name_request = Some(barrier.clone()); + + let serving_runtime = runtime.clone(); + let serving_name = name.clone(); + let serving_path = path.clone(); + let serving = tokio::spawn(async move { + serving_runtime + .serve_host_object(serving_name, serving_path, HostObjectValue("direct owner")) + .await + }); + let entered = + tokio::time::timeout(std::time::Duration::from_secs(1), barrier.entered.acquire()) + .await + .expect("host registration did not reach the name-request race") + .unwrap(); + + runtime + .connection() + .request_name(name.as_str()) + .await + .unwrap(); + let owner = runtime.connection().unique_name(); + barrier.release.add_permits(1); + drop(entered); + let guard = serving + .await + .unwrap() + .expect("helper registers the interface"); + runtime.host_objects.lock().await.before_name_request = None; + + let proxy = runtime + .connection() + .proxy( + name.as_str(), + path.as_str(), + "org.example.ModuleRuntimeHostObject", + ) + .unwrap(); + let value: serde_json::Value = proxy.call("Value", ()).await.unwrap(); + assert_eq!(value, serde_json::json!("direct owner")); + drop(guard); + wait_for_host_name_lease_cleanup(&runtime, &name).await; + + assert_eq!( + runtime + .connection() + .name_owner(name.as_str()) + .await + .unwrap(), + owner, + "dropping a helper guard must preserve an earlier same-peer claim" + ); + let removed: crate::Result = proxy.call("Value", ()).await; + assert!( + removed.is_err(), + "dropping the guard still removes its object" + ); + runtime + .connection() + .release_name(name.as_str()) + .await + .unwrap(); + wait_for_owner(runtime.connection(), name.as_str(), None).await; +} + +#[tokio::test] +async fn interface_name_panic_rolls_back_a_new_host_object_name_claim() { + let runtime = ModuleRuntime::start(&RuntimeNames::default(), None).unwrap(); + let name: crate::BusName = "ai.tinyhumans.test.PanickingHostObjectName" + .parse() + .unwrap(); + let path: crate::ObjectPath = "/runtime/panicking_name".parse().unwrap(); + + assert!( + runtime + .serve_host_object(name.clone(), path, PanickingHostObjectName) + .await + .is_err() + ); + assert_eq!( + runtime + .connection() + .name_owner(name.as_str()) + .await + .unwrap(), + None, + "failed registration must release the name it just claimed" + ); +} + +#[tokio::test] +async fn failed_shared_registration_preserves_the_existing_host_object_guard() { + let runtime = ModuleRuntime::start(&RuntimeNames::default(), None).unwrap(); + let name: crate::BusName = "ai.tinyhumans.test.SharedHostObjectRegistration" + .parse() + .unwrap(); + let retained_path: crate::ObjectPath = "/runtime/shared_retained".parse().unwrap(); + let failed_path: crate::ObjectPath = "/runtime/shared_failed".parse().unwrap(); + let owner = runtime.connection().unique_name(); + let retained = runtime + .serve_host_object( + name.clone(), + retained_path.clone(), + HostObjectValue("retained"), + ) + .await + .unwrap(); + + assert!( + runtime + .serve_host_object(name.clone(), failed_path.clone(), PanickingHostObjectName) + .await + .is_err() + ); + assert_eq!( + runtime + .connection() + .name_owner(name.as_str()) + .await + .unwrap(), + owner, + "failed shared registration must preserve the existing name lease" + ); + let retained_proxy = runtime + .connection() + .proxy( + name.as_str(), + retained_path.as_str(), + "org.example.ModuleRuntimeHostObject", + ) + .unwrap(); + let value: serde_json::Value = retained_proxy.call("Value", ()).await.unwrap(); + assert_eq!(value, serde_json::json!("retained")); + + drop(retained); + wait_for_owner(runtime.connection(), name.as_str(), None).await; + let failed_proxy = runtime + .connection() + .proxy( + runtime.connection().unique_name().unwrap().as_str(), + failed_path.as_str(), + "org.example.ModuleRuntimeHostObject", + ) + .unwrap(); + let leaked: crate::Result = failed_proxy.call("Value", ()).await; + assert!(leaked.is_err()); +} + +#[tokio::test] +async fn failed_registration_preserves_a_name_owned_before_the_helper() { + let runtime = ModuleRuntime::start(&RuntimeNames::default(), None).unwrap(); + let name: crate::BusName = "ai.tinyhumans.test.PreownedHostObjectFailure" + .parse() + .unwrap(); + let path: crate::ObjectPath = "/runtime/preowned_failure".parse().unwrap(); + runtime + .connection() + .request_name(name.as_str()) + .await + .unwrap(); + let owner = runtime.connection().unique_name(); + + assert!( + runtime + .serve_host_object(name.clone(), path, PanickingHostObjectName) + .await + .is_err() + ); + assert_eq!( + runtime + .connection() + .name_owner(name.as_str()) + .await + .unwrap(), + owner, + "failed registration must retain the name claimed before the helper" + ); + assert!(!runtime.host_objects.lock().await.names.contains_key(&name)); + + runtime + .connection() + .release_name(name.as_str()) + .await + .unwrap(); + wait_for_owner(runtime.connection(), name.as_str(), None).await; +} + +#[tokio::test] +async fn a_closed_runtime_connection_refuses_host_object_registration() { + let runtime = ModuleRuntime::start(&RuntimeNames::default(), None).unwrap(); + let name: crate::BusName = "ai.tinyhumans.test.ClosedHostObjectConnection" + .parse() + .unwrap(); + let path: crate::ObjectPath = "/runtime/closed_connection".parse().unwrap(); + runtime.connection().close().await.unwrap(); + + assert!( + runtime + .serve_host_object(name.clone(), path, HostObjectValue("unreachable")) + .await + .is_err() + ); + assert!(!runtime.host_objects.lock().await.names.contains_key(&name)); +} + +#[tokio::test] +async fn canceling_after_name_claim_rolls_back_on_the_module_runtime() { + let runtime = Arc::new(ModuleRuntime::start(&RuntimeNames::default(), None).unwrap()); + let name: crate::BusName = "ai.tinyhumans.test.CancelAfterClaim".parse().unwrap(); + let path: crate::ObjectPath = "/runtime/cancel_after_claim".parse().unwrap(); + let barrier = HostObjectRegistrationBarrier { + entered: Arc::new(tokio::sync::Semaphore::new(0)), + release: Arc::new(tokio::sync::Semaphore::new(0)), + }; + { + let mut state = runtime.host_objects.lock().await; + state.after_name_claim = Some(barrier.clone()); + } + + let serving_runtime = runtime.clone(); + let serving_name = name.clone(); + let serving_path = path.clone(); + let serving = tokio::spawn(async move { + serving_runtime + .serve_host_object(serving_name, serving_path, HostObjectValue("value")) + .await + }); + let entered = + tokio::time::timeout(std::time::Duration::from_secs(1), barrier.entered.acquire()) + .await + .expect("name claim was not reached") + .unwrap(); + let owner = runtime + .connection() + .name_owner(name.as_str()) + .await + .unwrap(); + assert_eq!(owner, runtime.connection().unique_name()); + + serving.abort(); + assert!(matches!(serving.await, Err(error) if error.is_cancelled())); + drop(entered); + barrier.release.add_permits(1); + wait_for_owner(runtime.connection(), name.as_str(), None).await; + runtime.host_objects.lock().await.after_name_claim = None; + + let guard = runtime + .serve_host_object(name.clone(), path, HostObjectValue("reclaimed")) + .await + .unwrap(); + drop(guard); + wait_for_owner(runtime.connection(), name.as_str(), None).await; +} + +unsafe extern "C" fn collision_module_init( + _: *const crate::module::abi::TbHostVtable, + _: *mut crate::module::abi::TbModuleVtable, +) -> i32 { + crate::module::abi::TB_BAD_ARGUMENT +} + #[tokio::test] async fn configured_worker_stack_supports_deep_synchronous_work() { const CHILD: &str = "TINYBUS_CONFIGURED_STACK_TEST_CHILD"; diff --git a/crates/tinybus/src/module/service_slot.rs b/crates/tinybus/src/module/service_slot.rs new file mode 100644 index 00000000..e3bf74ae --- /dev/null +++ b/crates/tinybus/src/module/service_slot.rs @@ -0,0 +1,192 @@ +//! Serialized lifecycle for one process-wide module-backed service. +//! +//! The host supplies the service key and its open, close, and operation +//! callbacks. TinyBus owns only the keyed slot and its lifecycle ordering; it +//! does not depend on a host shutdown registry or service policy. + +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; + +type CloseFuture = Pin> + Send + 'static>>; +type StoredCloser = Box CloseFuture + Send + 'static>; + +/// A host-compatible asynchronous shutdown hook accepted by [`ModuleServiceSlot::on_shutdown`]. +pub type ShutdownHook = + Box Pin + Send + 'static>> + Send + Sync + 'static>; + +struct Entry { + key: K, + service: S, + close: StoredCloser, +} + +/// Own one keyed module service and serialize operations against replacement +/// and close. +/// +/// A matching key reuses the installed service. A changed key closes and +/// removes the old service before attempting to open its replacement. An +/// explicit close removes the entry before awaiting the host callback, so a +/// failed or cancelled callback cannot leave the slot logically occupied. +pub struct ModuleServiceSlot { + entry: tokio::sync::Mutex>>, + shutdown_registered: AtomicBool, +} + +impl ModuleServiceSlot { + /// Create an empty service slot. + pub fn new() -> Self { + Self { + entry: tokio::sync::Mutex::new(None), + shutdown_registered: AtomicBool::new(false), + } + } + + /// Open or reuse the service for `key`, then run one operation while the + /// slot lock remains held. + /// + /// The installed entry keeps the close callback supplied when it was + /// opened. If the key changes, that stored callback is invoked and its + /// error is discarded; opening the replacement determines the result. + /// The operation receives mutable access so hosts can keep service-scoped + /// caches alongside the module handle. + pub async fn with( + &self, + key: K, + open: Open, + close: Close, + operation: Operation, + ) -> Result + where + K: PartialEq + Send, + S: Send, + Open: FnOnce() -> OpenFuture + Send, + OpenFuture: Future> + Send, + Close: FnOnce(S) -> CloseFutureType + Send + 'static, + CloseFutureType: Future> + Send + 'static, + Operation: for<'service> FnOnce( + &'service mut S, + ) + -> Pin> + Send + 'service>> + + Send, + { + let mut entry = self.entry.lock().await; + if entry.as_ref().is_some_and(|current| current.key != key) { + if let Some(old) = entry.take() { + let _ = (old.close)(old.service).await; + } + } + + if entry.is_none() { + let service = open().await?; + let stored_close: StoredCloser = Box::new(move |service| { + let future: CloseFuture = Box::pin(close(service)); + future + }); + *entry = Some(Entry { + key, + service, + close: stored_close, + }); + } + + let current = entry + .as_mut() + .expect("a successful open installs the requested service"); + operation(&mut current.service).await + } + + /// Close the installed service, if any. + /// + /// The entry is removed before awaiting the callback. Its error is returned + /// to the caller, but does not restore the entry. Closing an empty slot is + /// a successful no-op. + pub async fn close(&self) -> Result<(), E> { + let mut entry = self.entry.lock().await; + let Some(old) = entry.take() else { + return Ok(()); + }; + (old.close)(old.service).await + } + + /// Run a host-owned explicit close against the installed service while + /// serializing it with operations, replacement, and other closes. + /// + /// An empty slot is a successful no-op and does not call `close`. If the + /// callback returns an error or this future is cancelled, the entry and + /// its stored closer remain installed. Cancellation does not establish + /// whether a remote module acknowledged the close, so the host owns the + /// decision about whether another attempt is safe. Once the callback + /// returns success, the entry is retired without invoking its stored + /// closer; the callback owns the close operation and its acknowledgement. + /// + /// Unlike [`Self::close`], this method borrows the service so the host can + /// perform policy-specific cleanup before the slot drops it. The callback + /// future may borrow the service for its duration. + pub async fn try_close_with(&self, close: Close) -> Result<(), E> + where + K: Send, + S: Send, + Close: for<'service> FnOnce( + &'service mut S, + ) + -> Pin> + Send + 'service>> + + Send, + { + let mut entry = self.entry.lock().await; + let Some(current) = entry.as_mut() else { + return Ok(()); + }; + + close(&mut current.service).await?; + let _retired = entry.take(); + Ok(()) + } + + /// Register one host-provided shutdown callback for this slot. + /// + /// The host supplies its own registrar, such as its process shutdown hook + /// registry. The registered callback closes the slot and ignores a close + /// error because the shutdown hook has no error return. Registration may be + /// repeated after the callback runs, including when cleanup returns an + /// error. + pub fn on_shutdown(&'static self, register: impl FnOnce(ShutdownHook)) + where + K: Send + 'static, + S: Send + 'static, + E: 'static, + { + if self + .shutdown_registered + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + + register(Box::new(move || { + Box::pin(async move { + let _reset = ShutdownRegistrationReset(&self.shutdown_registered); + let _ = self.close().await; + }) + })); + } +} + +impl Default for ModuleServiceSlot { + fn default() -> Self { + Self::new() + } +} + +struct ShutdownRegistrationReset<'a>(&'a AtomicBool); + +impl Drop for ShutdownRegistrationReset<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +#[cfg(test)] +#[path = "service_slot_tests.rs"] +mod tests; diff --git a/crates/tinybus/src/module/service_slot_tests.rs b/crates/tinybus/src/module/service_slot_tests.rs new file mode 100644 index 00000000..f65192b8 --- /dev/null +++ b/crates/tinybus/src/module/service_slot_tests.rs @@ -0,0 +1,482 @@ +use super::*; +use std::sync::{Arc, Mutex}; +use std::task::Poll; +use tokio::sync::oneshot; + +#[derive(Debug, PartialEq, Eq)] +struct HostError(&'static str); + +fn slot() -> ModuleServiceSlot { + ModuleServiceSlot::new() +} + +#[tokio::test] +async fn the_same_key_reuses_the_service_and_allows_mutating_operations() { + let slot = slot::<&'static str, usize>(); + let opens = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let closes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + + let first_opens = opens.clone(); + let first_closes = closes.clone(); + let first = slot + .with( + "alice", + move || async move { + first_opens.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok::<_, crate::Error>(4usize) + }, + move |service| async move { + first_closes.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let _ = service; + Ok(()) + }, + |service| { + Box::pin(async move { + *service += 1; + Ok::<_, crate::Error>(*service) + }) + }, + ) + .await + .expect("first operation should open and use the service"); + assert_eq!(first, 5); + + let second_opens = opens.clone(); + let second_closes = closes.clone(); + let second = slot + .with( + "alice", + move || async move { + second_opens.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok::<_, crate::Error>(99usize) + }, + move |service| async move { + second_closes.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let _ = service; + Ok(()) + }, + |service| { + Box::pin(async move { + *service += 1; + Ok::<_, crate::Error>(*service) + }) + }, + ) + .await + .expect("matching key should reuse the installed service"); + + assert_eq!(second, 6); + assert_eq!(opens.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(closes.load(std::sync::atomic::Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn changing_the_key_closes_the_installed_service_before_opening_its_replacement() { + let slot = slot::<&'static str, &'static str>(); + let events = Arc::new(Mutex::new(Vec::new())); + + let opened_events = events.clone(); + let closed_events = events.clone(); + slot.with( + "alice", + move || async move { + opened_events.lock().unwrap().push("open alice"); + Ok::<_, crate::Error>("alice service") + }, + move |service| async move { + let _ = service; + closed_events.lock().unwrap().push("close alice"); + Err(crate::Error::failed("old service close failed")) + }, + |_| Box::pin(async { Ok::<_, crate::Error>(()) }), + ) + .await + .expect("initial service should open"); + + let opened_events = events.clone(); + slot.with( + "bob", + move || async move { + opened_events.lock().unwrap().push("open bob"); + Ok::<_, crate::Error>("bob service") + }, + |_| async { Ok(()) }, + |_| Box::pin(async { Ok::<_, crate::Error>(()) }), + ) + .await + .expect("replacement open should determine the result after a close error"); + + assert_eq!( + *events.lock().unwrap(), + ["open alice", "close alice", "open bob"] + ); +} + +#[tokio::test] +async fn explicit_close_returns_its_error_and_clears_the_slot() { + let slot = slot::(); + slot.with( + 1, + || async { Ok::<_, crate::Error>(7) }, + |_| async { Err(crate::Error::failed("close failed")) }, + |_| Box::pin(async { Ok::<_, crate::Error>(()) }), + ) + .await + .expect("service should install"); + + assert!(slot.close().await.is_err()); + assert!( + slot.close().await.is_ok(), + "an empty slot closes idempotently" + ); + + let opened = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let observed = opened.clone(); + slot.with( + 1, + move || async move { + observed.store(true, std::sync::atomic::Ordering::SeqCst); + Ok::<_, crate::Error>(8) + }, + |_| async { Ok(()) }, + |_| Box::pin(async { Ok::<_, crate::Error>(()) }), + ) + .await + .expect("service should reopen after failed explicit close"); + assert!(opened.load(std::sync::atomic::Ordering::SeqCst)); +} + +#[tokio::test] +async fn a_failed_open_leaves_the_slot_empty() { + let slot = slot::(); + assert!( + slot.with( + 1, + || async { Err(crate::Error::failed("open failed")) }, + |_| async { Ok(()) }, + |_| Box::pin(async { Ok::<_, crate::Error>(()) }), + ) + .await + .is_err() + ); + + let opens = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = opens.clone(); + slot.with( + 1, + move || async move { + observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok::<_, crate::Error>(2) + }, + |_| async { Ok(()) }, + |_| Box::pin(async { Ok::<_, crate::Error>(()) }), + ) + .await + .expect("a later open should succeed"); + assert_eq!(opens.load(std::sync::atomic::Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn a_partial_eq_key_and_host_error_type_need_no_tinybus_policy() { + let slot = ModuleServiceSlot::::new(); + let result = slot + .with( + 1.25, + || async { Ok::<_, HostError>(7) }, + |_| async { Err(HostError("host close error")) }, + |service| { + Box::pin(async move { + *service += 1; + Ok::<_, HostError>(*service) + }) + }, + ) + .await + .expect("the host error type should flow through the generic slot"); + + assert_eq!(result, 8); + assert_eq!(slot.close().await, Err(HostError("host close error"))); + assert_eq!(slot.close().await, Ok(())); +} + +#[tokio::test] +async fn close_waits_for_the_current_operation_to_finish() { + let slot = Arc::new(slot::()); + let (entered_tx, entered_rx) = oneshot::channel(); + let (release_tx, release_rx) = oneshot::channel(); + let with_slot = slot.clone(); + let operation = tokio::spawn(async move { + with_slot + .with( + 1, + || async { Ok::<_, crate::Error>(7) }, + |_| async { Ok(()) }, + move |_| { + Box::pin(async move { + let _ = entered_tx.send(()); + let _ = release_rx.await; + Ok::<_, crate::Error>(()) + }) + }, + ) + .await + }); + entered_rx + .await + .expect("operation should enter before close"); + + let closing_slot = slot.clone(); + let mut close = Box::pin(closing_slot.close()); + let first_poll = + std::future::poll_fn(|context| Poll::Ready(close.as_mut().poll(context))).await; + assert!( + first_poll.is_pending(), + "close must wait for the locked operation" + ); + + let _ = release_tx.send(()); + operation + .await + .expect("operation task should finish") + .expect("operation should succeed"); + close.await.expect("close should succeed"); +} + +#[tokio::test] +async fn shutdown_registration_is_idempotent_and_can_be_repeated_after_cleanup_error() { + let slot = Box::leak(Box::new(slot::())); + slot.with( + 1, + || async { Ok::<_, crate::Error>(7) }, + |_| async { Err(crate::Error::failed("shutdown close failed")) }, + |_| Box::pin(async { Ok::<_, crate::Error>(()) }), + ) + .await + .expect("service should install"); + + let hooks = Arc::new(Mutex::new(Vec::::new())); + let registered = hooks.clone(); + slot.on_shutdown(move |hook| registered.lock().unwrap().push(hook)); + let registered = hooks.clone(); + slot.on_shutdown(move |hook| registered.lock().unwrap().push(hook)); + assert_eq!( + hooks.lock().unwrap().len(), + 1, + "registration should be idempotent" + ); + + let hook = hooks.lock().unwrap().remove(0); + hook().await; + + let registered = hooks.clone(); + slot.on_shutdown(move |hook| registered.lock().unwrap().push(hook)); + assert_eq!( + hooks.lock().unwrap().len(), + 1, + "cleanup error must reset registration" + ); +} + +#[tokio::test] +async fn try_close_with_keeps_the_same_service_and_mutable_cache_after_failure() { + let slot = slot::<&'static str, usize>(); + let opens = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let stored_closes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + + let opened = opens.clone(); + let stored = stored_closes.clone(); + slot.with( + "alice", + move || async move { + opened.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok::<_, crate::Error>(3) + }, + move |_| async move { + stored.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(()) + }, + |service| { + Box::pin(async move { + *service += 4; + Ok::<_, crate::Error>(*service) + }) + }, + ) + .await + .expect("service should install and update its cache"); + + let failed = slot + .try_close_with(|_| Box::pin(async { Err(crate::Error::failed("close not acknowledged")) })) + .await; + assert!(failed.is_err(), "the host close error should be returned"); + + let opened = opens.clone(); + let value = slot + .with( + "alice", + move || async move { + opened.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok::<_, crate::Error>(99) + }, + |_| async { Ok(()) }, + |service| Box::pin(async move { Ok::<_, crate::Error>(*service) }), + ) + .await + .expect("matching key should reuse the retained service"); + assert_eq!(value, 7, "the service-scoped mutable cache should survive"); + assert_eq!(opens.load(std::sync::atomic::Ordering::SeqCst), 1); + + slot.try_close_with(|_| Box::pin(async { Ok(()) })) + .await + .expect("a later acknowledged close should retire the service"); + assert_eq!( + stored_closes.load(std::sync::atomic::Ordering::SeqCst), + 0, + "the stored closer must not run after the host callback already closed it" + ); + slot.close() + .await + .expect("the acknowledged close should leave an empty slot"); + assert_eq!( + stored_closes.load(std::sync::atomic::Ordering::SeqCst), + 0, + "closing the empty slot must not invoke the stored closer" + ); +} + +#[tokio::test] +async fn try_close_with_on_an_empty_slot_does_not_call_the_host_callback() { + let slot = slot::(); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let called = calls.clone(); + + slot.try_close_with(move |_| { + called.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Box::pin(async { Ok(()) }) + }) + .await + .expect("closing an empty slot should succeed"); + + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn cancelling_try_close_with_retains_the_entry_and_original_stored_closer() { + let slot = Arc::new(slot::<&'static str, &'static str>()); + let events = Arc::new(Mutex::new(Vec::new())); + let stored_events = events.clone(); + slot.with( + "alice", + || async { Ok::<_, crate::Error>("alice service") }, + move |_| async move { + stored_events.lock().unwrap().push("stored close alice"); + Ok(()) + }, + |_| Box::pin(async { Ok::<_, crate::Error>(()) }), + ) + .await + .expect("service should install"); + + let (started_tx, started_rx) = oneshot::channel(); + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + let closing_slot = slot.clone(); + let close = tokio::spawn(async move { + closing_slot + .try_close_with(move |_| { + let _ = started_tx.send(()); + Box::pin(async move { + let _ = cancel_rx.await; + Ok(()) + }) + }) + .await + }); + started_rx.await.expect("host close callback should start"); + close.abort(); + assert!( + close + .await + .expect_err("cancelled close task should stop") + .is_cancelled() + ); + drop(cancel_tx); + + let observed = slot + .with( + "alice", + || async { panic!("same-key access must not reopen the service") }, + |_| async { Ok(()) }, + |service| Box::pin(async move { Ok::<_, crate::Error>(*service) }), + ) + .await + .expect("cancellation should release the lock and retain the entry"); + assert_eq!(observed, "alice service"); + + slot.with( + "bob", + || async { Ok::<_, crate::Error>("bob service") }, + |_| async { Ok(()) }, + |_| Box::pin(async { Ok::<_, crate::Error>(()) }), + ) + .await + .expect("a later replacement should proceed"); + assert_eq!( + *events.lock().unwrap(), + ["stored close alice"], + "cancellation must preserve the original closer for subsequent replacement" + ); +} + +#[tokio::test] +async fn try_close_with_waits_for_the_current_operation_to_finish() { + let slot = Arc::new(slot::()); + let (entered_tx, entered_rx) = oneshot::channel(); + let (release_tx, release_rx) = oneshot::channel(); + let operation_slot = slot.clone(); + let operation = tokio::spawn(async move { + operation_slot + .with( + 1, + || async { Ok::<_, crate::Error>(7) }, + |_| async { Ok(()) }, + move |_| { + Box::pin(async move { + let _ = entered_tx.send(()); + let _ = release_rx.await; + Ok::<_, crate::Error>(()) + }) + }, + ) + .await + }); + entered_rx + .await + .expect("operation should enter before explicit close"); + + let closing_slot = slot.clone(); + let close_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let callback_calls = close_calls.clone(); + let mut close = Box::pin(closing_slot.try_close_with(move |_| { + callback_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Box::pin(async { Ok(()) }) + })); + let first_poll = + std::future::poll_fn(|context| Poll::Ready(close.as_mut().poll(context))).await; + assert!( + first_poll.is_pending(), + "explicit close must wait for the locked operation" + ); + assert_eq!( + close_calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "the host close callback must wait for the current operation" + ); + + let _ = release_tx.send(()); + operation + .await + .expect("operation task should finish") + .expect("operation should succeed"); + close.await.expect("explicit close should succeed"); + assert_eq!(close_calls.load(std::sync::atomic::Ordering::SeqCst), 1); +} diff --git a/crates/tinybus/src/module/transport.rs b/crates/tinybus/src/module/transport.rs index 25aae37f..beb7a1d0 100644 --- a/crates/tinybus/src/module/transport.rs +++ b/crates/tinybus/src/module/transport.rs @@ -9,13 +9,16 @@ use std::time::Duration; use async_trait::async_trait; use tokio::sync::{Mutex, Notify, mpsc}; +use crate::broker::RestrictedModuleControllerBinding; use crate::error::{Error, Result}; use crate::message::codec::MAX_FRAME_LEN; use crate::message::{Message, MessageKind}; use crate::module::abi::{ - TB_BACKPRESSURE, TB_BAD_ARGUMENT, TB_CLOSED, TB_MODULE_VTABLE_BASE_SIZE, TB_OK, TB_PANICKED, - TB_TIMEOUT, TbHostVtable, TbModuleVtable, + TB_BACKPRESSURE, TB_BAD_ARGUMENT, TB_BUFFER_TOO_SMALL, TB_CLOSED, TB_MODULE_VTABLE_BASE_SIZE, + TB_NATIVE_TERMINAL_LIVE, TB_OK, TB_PANICKED, TB_TIMEOUT, TB_UNSUPPORTED, TbHostVtable, + TbModuleVtable, TbSlice, }; +use crate::module::host::PreReadyConfidentialRoute; use crate::ports::Transport; const HOST_QUEUE_CAPACITY: usize = 256; @@ -45,6 +48,8 @@ struct HostContext { ready: AtomicBool, ready_gate: StdMutex<()>, broker_routing: AtomicBool, + trusted_host_binding: Option, + pre_ready_confidential_routes: Arc<[PreReadyConfidentialRoute]>, ready_notify: Notify, inflight: AtomicUsize, } @@ -250,7 +255,17 @@ impl ModuleTransport { self.context.broker_routing.store(true, Ordering::Release); } + #[cfg(test)] pub(crate) fn new(label: String, config: Vec) -> (Arc, TbHostVtable) { + Self::new_with_controller_binding(label, config, None, Arc::from([])) + } + + pub(crate) fn new_with_controller_binding( + label: String, + config: Vec, + trusted_host_binding: Option, + pre_ready_confidential_routes: Arc<[PreReadyConfidentialRoute]>, + ) -> (Arc, TbHostVtable) { let (inbound_tx, inbound_rx) = mpsc::channel(HOST_QUEUE_CAPACITY); let context = Box::leak(Box::new(HostContext { inbound: StdMutex::new(Some(inbound_tx)), @@ -276,6 +291,8 @@ impl ModuleTransport { ready: AtomicBool::new(false), ready_gate: StdMutex::new(()), broker_routing: AtomicBool::new(false), + trusted_host_binding, + pre_ready_confidential_routes, ready_notify: Notify::new(), inflight: AtomicUsize::new(0), })); @@ -325,6 +342,10 @@ impl ModuleTransport { ready: host_ready, broker_routing: Some(host_broker_routing), native_terminal_state: Some(host_native_terminal_state), + trusted_host_binding: context.trusted_host_binding.as_ref().map(|_| { + host_trusted_host_binding + as unsafe extern "C" fn(*mut c_void, *mut u8, usize, *mut usize) -> i32 + }), }; (transport, vtable) } @@ -440,7 +461,7 @@ impl ModuleTransport { self.context.admission_closed.load(Ordering::Acquire) } - fn is_stopped(&self) -> bool { + pub(crate) fn is_stopped(&self) -> bool { self.context.stopped.load(Ordering::Acquire) } @@ -695,6 +716,21 @@ impl ModuleTransport { init: crate::module::abi::TbModuleInit, host: TbHostVtable, ) -> Result<()> { + self.eager_initialize_with(host, move |host| { + let mut module = TbModuleVtable::default(); + let code = unsafe { init(&host, &mut module) }; + if code == TB_OK { + Ok(module) + } else { + Err("module initialization failed".to_string()) + } + }) + } + + pub(crate) fn eager_initialize_with(&self, host: TbHostVtable, initialize: F) -> Result<()> + where + F: FnOnce(TbHostVtable) -> std::result::Result, + { let guard = self.lifecycle.clone().try_lock_owned().map_err(|_| { self.commit_terminal(true); self.context @@ -710,13 +746,9 @@ impl ModuleTransport { .expect("physical state lock") .init = InitState::Entered; self.context.init_started.store(true, Ordering::Release); - let mut module = TbModuleVtable::default(); - let code = unsafe { init(&host, &mut module) }; - let result = if code == TB_OK { - self.initialize(module) - } else { - Err(Error::transport("module initialization failed")) - }; + let result = initialize(host) + .map_err(Error::transport) + .and_then(|module| self.initialize(module)); self.clear_config(); { let mut state = self.context.physical.lock().expect("physical state lock"); @@ -799,6 +831,87 @@ impl ModuleTransport { self.context.ready.load(Ordering::Acquire) && !self.is_faulted() && !self.is_stopped() } + fn authorized_pre_ready_peer(&self, message: &Message) -> Option { + if message.header.kind != MessageKind::MethodCall || !message.header.confidential { + return None; + } + + // Serialize the authorization point with READY publication. A call + // that passes this gate is admitted while initialization is active; + // READY and terminal transitions prevent later calls from using it. + let _ready = self.context.ready_gate.lock().expect("module ready lock"); + if self.context.ready.load(Ordering::Acquire) + || !self.context.init_started.load(Ordering::Acquire) + || self.admission_closed() + || self.is_faulted() + || self.context.init_failed.load(Ordering::Acquire) + || self.is_stopped() + || !self.context.broker_routing.load(Ordering::Acquire) + || self.context.native_terminal_state.load(Ordering::Acquire) + != crate::module::abi::TB_NATIVE_TERMINAL_LIVE + { + return None; + } + + let binding = self.context.trusted_host_binding.as_ref()?; + if message.header.sender.as_ref() != Some(binding.peer()) || !binding.is_live() { + return None; + } + + self.context + .pre_ready_confidential_routes + .iter() + .any(|route| { + message.header.path.as_ref() == Some(&route.path) + && message.header.interface.as_ref() == Some(&route.interface) + && message.header.member.as_ref() == Some(&route.member) + }) + .then(|| binding.peer().clone()) + } + + /// Whether a lazy initializer is still available for a pre-init config update. + pub(crate) fn has_deferred_initializer(&self) -> bool { + let state = self.context.physical.lock().expect("physical state lock"); + !self.admission_closed() + && !self.is_faulted() + && state.init == InitState::Deferred + && state.cleanup == CleanupState::Idle + && self + .initializer + .lock() + .expect("module initializer lock") + .is_some() + } + + /// Replace the configuration a deferred initializer will read, if it has + /// not been scheduled. The physical-state lock is held through pointer + /// replacement so `schedule_initializer` cannot take the old host vtable. + fn replace_deferred_config(&self, bytes: &crate::Secret) -> Result { + let state = self.context.physical.lock().expect("physical state lock"); + if self.admission_closed() || self.is_faulted() { + return Err(Error::ConnectionClosed); + } + if state.init != InitState::Deferred || state.cleanup != CleanupState::Idle { + return Ok(false); + } + let mut initializer = self.initializer.lock().expect("module initializer lock"); + let Some(initializer) = initializer.as_mut() else { + return Ok(false); + }; + let replacement = bytes.expose_secret().to_vec(); + let mut config = self.context.config.lock().expect("module config lock"); + config.fill(0); + *config = replacement; + initializer.host.config = TbSlice { + ptr: config.as_ptr(), + len: config.len(), + }; + if self.admission_closed() || self.is_faulted() { + return Err(Error::ConnectionClosed); + } + Ok(true) + } + pub(crate) fn clear_config(&self) { let mut config = self.context.config.lock().expect("module config lock"); config.fill(0); @@ -811,7 +924,7 @@ impl ModuleTransport { return Err(Error::ConnectionClosed); } let started = std::time::Instant::now(); - let lifecycle = + let mut lifecycle = tokio::time::timeout(MODULE_INIT_DEADLINE, self.lifecycle.clone().lock_owned()) .await .map_err(|_| Error::Timeout { @@ -822,6 +935,77 @@ impl ModuleTransport { if self.admission_closed() || self.is_faulted() { return Err(Error::ConnectionClosed); } + if self.replace_deferred_config(&bytes)? { + return Ok(()); + } + if self.module.lock().expect("module vtable lock").is_none() { + let initialization_in_progress = matches!( + self.context + .physical + .lock() + .expect("physical state lock") + .init, + InitState::Scheduled | InitState::Entered + ); + if !initialization_in_progress { + return Err(Error::transport("module is not initialized")); + } + // An ordinary bus call may have scheduled lazy initialization + // before this reconfiguration acquired the lifecycle lock. Let the + // physical initializer run without holding either lifecycle or + // physical-state locks, then use only the original deadline for + // reacquisition and the native callback. + drop(lifecycle); + let remaining = MODULE_INIT_DEADLINE.saturating_sub(started.elapsed()); + tokio::time::timeout(remaining, self.ensure_initialized()) + .await + .map_err(|_| Error::Timeout { + member: crate::MemberName::new("ReinitializeModule") + .expect("static member name is valid"), + timeout_ms: MODULE_INIT_DEADLINE.as_millis() as u64, + })??; + let remaining = MODULE_INIT_DEADLINE.saturating_sub(started.elapsed()); + lifecycle = tokio::time::timeout(remaining, self.lifecycle.clone().lock_owned()) + .await + .map_err(|_| Error::Timeout { + member: crate::MemberName::new("ReinitializeModule") + .expect("static member name is valid"), + timeout_ms: MODULE_INIT_DEADLINE.as_millis() as u64, + })?; + if self.admission_closed() || self.is_faulted() { + return Err(Error::ConnectionClosed); + } + } + if !self.is_ready() { + // The C initializer only publishes its vtable. SDK setup is + // asynchronous and installs the reinitialization connection slot + // immediately before host.ready, so invoking the callback earlier + // can reject a valid replacement as closed. Readiness may wait on + // module code; never retain the lifecycle lock across that wait. + drop(lifecycle); + let remaining = MODULE_INIT_DEADLINE.saturating_sub(started.elapsed()); + tokio::time::timeout(remaining, self.wait_ready()) + .await + .map_err(|_| Error::Timeout { + member: crate::MemberName::new("ReinitializeModule") + .expect("static member name is valid"), + timeout_ms: MODULE_INIT_DEADLINE.as_millis() as u64, + })?; + if !self.is_ready() { + return Err(Error::ConnectionClosed); + } + let remaining = MODULE_INIT_DEADLINE.saturating_sub(started.elapsed()); + lifecycle = tokio::time::timeout(remaining, self.lifecycle.clone().lock_owned()) + .await + .map_err(|_| Error::Timeout { + member: crate::MemberName::new("ReinitializeModule") + .expect("static member name is valid"), + timeout_ms: MODULE_INIT_DEADLINE.as_millis() as u64, + })?; + if self.admission_closed() || self.is_faulted() || !self.is_ready() { + return Err(Error::ConnectionClosed); + } + } let module = self .module .lock() @@ -890,6 +1074,14 @@ impl ModuleTransport { } async fn deliver_now(&self, message: Message) -> Result<()> { + self.deliver_now_with_pre_ready_peer(message, None).await + } + + async fn deliver_now_with_pre_ready_peer( + &self, + message: Message, + required_controller: Option<&crate::BusName>, + ) -> Result<()> { if self.is_faulted() || self.admission_closed() { return Err(Error::ConnectionClosed); } @@ -908,6 +1100,15 @@ impl ModuleTransport { return Err(Error::ConnectionClosed); }; let lease = self.callbacks.enter()?; + if required_controller.is_some_and(|required| { + self.context + .trusted_host_binding + .as_ref() + .is_none_or(|binding| binding.peer() != required || !binding.is_live()) + }) { + drop(lease); + return Err(Error::ConnectionClosed); + } // Stop closes this gate and drains reservations before teardown; // a caller disappearing cannot refund a running native callback. let code = unsafe { (module.deliver)(module.module_ctx, bytes.as_ptr(), bytes.len()) }; @@ -1388,6 +1589,61 @@ unsafe extern "C" fn host_native_terminal_state(ctx: *mut c_void) -> u32 { ) } +unsafe extern "C" fn host_trusted_host_binding( + ctx: *mut c_void, + out: *mut u8, + capacity: usize, + out_len: *mut usize, +) -> i32 { + if out_len.is_null() { + return TB_BAD_ARGUMENT; + } + unsafe { out_len.write(0) }; + if capacity > crate::name::MAX_NAME_LEN || (out.is_null() && capacity != 0) { + return TB_BAD_ARGUMENT; + } + + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let Some(context) = (unsafe { ctx.cast::().as_ref() }) else { + return TB_CLOSED; + }; + let Some(binding) = context.trusted_host_binding.as_ref() else { + return TB_UNSUPPORTED; + }; + let owner = context.owner.lock().ok().and_then(|owner| owner.upgrade()); + let Some(owner) = owner else { + return TB_CLOSED; + }; + if context.native_terminal_state.load(Ordering::Acquire) != TB_NATIVE_TERMINAL_LIVE + || !owner.init_started() + || owner.init_failed() + || owner.is_stopped() + || owner.is_faulted() + || owner.admission_closed() + || !binding.is_live() + { + return TB_CLOSED; + } + + let peer = binding.peer().as_str().as_bytes(); + if peer.len() > crate::name::MAX_NAME_LEN { + return TB_BAD_ARGUMENT; + } + if capacity < peer.len() { + unsafe { out_len.write(peer.len()) }; + return TB_BUFFER_TOO_SMALL; + } + if !peer.is_empty() { + unsafe { std::ptr::copy_nonoverlapping(peer.as_ptr(), out, peer.len()) }; + } + unsafe { out_len.write(peer.len()) }; + TB_OK + })) { + Ok(code) => code, + Err(_) => TB_PANICKED, + } +} + #[derive(Debug)] pub(crate) enum StopError { NotStarted(Error), @@ -1443,6 +1699,12 @@ impl Transport for ModuleTransport { return Err(Error::ConnectionClosed); } let is_call = message.header.kind == MessageKind::MethodCall; + let pre_ready_bypass_peer = if is_call { + self.authorized_pre_ready_peer(&message) + .filter(|_| !self.is_ready()) + } else { + None + }; { let mut pending = self.pending.lock().expect("module pending lock"); if self.admission_closed() { @@ -1451,7 +1713,7 @@ impl Transport for ModuleTransport { if is_call { self.context.inflight.fetch_add(1, Ordering::AcqRel); } - if is_call && !self.is_ready() { + if is_call && !self.is_ready() && pre_ready_bypass_peer.is_none() { pending.push_back(message); if !self.drain_started.swap(true, Ordering::AcqRel) { let transport = self.self_ref.upgrade().ok_or(Error::ConnectionClosed)?; @@ -1460,7 +1722,9 @@ impl Transport for ModuleTransport { return Ok(()); } } - let result = self.deliver_now(message).await; + let result = self + .deliver_now_with_pre_ready_peer(message, pre_ready_bypass_peer.as_ref()) + .await; if result.is_err() && is_call { self.context.inflight.fetch_sub(1, Ordering::AcqRel); } diff --git a/crates/tinybus/src/module/transport_tests.rs b/crates/tinybus/src/module/transport_tests.rs index 4af6e634..6940c629 100644 --- a/crates/tinybus/src/module/transport_tests.rs +++ b/crates/tinybus/src/module/transport_tests.rs @@ -1,6 +1,9 @@ use super::*; use std::sync::atomic::AtomicI32; +use crate::Connection; +use crate::broker::Broker; +use crate::transport::memory::MemoryTransport; use crate::{BusName, InterfaceName, MemberName, ObjectPath}; static DELIVERY_CODE: AtomicI32 = AtomicI32::new(TB_OK); @@ -14,6 +17,40 @@ static REINITIALIZE_CODE: AtomicI32 = AtomicI32::new(TB_OK); static REINITIALIZE_BLOCKED: AtomicBool = AtomicBool::new(false); static REINITIALIZE_ENTERED: AtomicBool = AtomicBool::new(false); static VTABLE_TEST_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); +static PRE_READY_DELIVERED: tokio::sync::Notify = tokio::sync::Notify::const_new(); +static PRE_READY_DELIVERY_COUNT: AtomicUsize = AtomicUsize::new(0); + +unsafe extern "C" fn pre_ready_deliver(_: *mut c_void, _: *const u8, _: usize) -> i32 { + PRE_READY_DELIVERY_COUNT.fetch_add(1, Ordering::AcqRel); + PRE_READY_DELIVERED.notify_one(); + TB_OK +} + +async fn wait_for_pending_len(transport: &ModuleTransport, expected: usize) { + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if transport.pending.lock().expect("module pending lock").len() >= expected { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("the message reached the module pending queue"); +} + +async fn wait_for_pre_ready_delivery_count(expected: usize) { + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if PRE_READY_DELIVERY_COUNT.load(Ordering::Acquire) >= expected { + break; + } + PRE_READY_DELIVERED.notified().await; + } + }) + .await + .expect("module delivery callback reached the expected count"); +} unsafe extern "C" fn deliver(_: *mut c_void, _: *const u8, _: usize) -> i32 { DELIVERIES.fetch_add(1, Ordering::AcqRel); @@ -138,7 +175,7 @@ fn incomplete_module_vtables_are_rejected_and_shutdown_reports_closed() { #[tokio::test] async fn an_older_vtable_loads_but_reports_reinitialization_as_unsupported() { - let (transport, _) = ModuleTransport::new("older".to_string(), Vec::new()); + let (transport, host) = ModuleTransport::new("older".to_string(), Vec::new()); let mut older = TbModuleVtable { module_ctx: std::ptr::dangling_mut(), deliver, @@ -148,6 +185,7 @@ async fn an_older_vtable_loads_but_reports_reinitialization_as_unsupported() { }; older.size = TB_MODULE_VTABLE_BASE_SIZE; transport.initialize(older).unwrap(); + unsafe { (host.ready)(host.host_ctx) }; let error = transport .reinitialize(crate::Secret::new( serde_json::to_vec(&serde_json::json!({ "secret": "never printed" })).unwrap(), @@ -189,10 +227,11 @@ async fn reinitialization_maps_callback_failures_without_exposing_configuration( .contains("not initialized") ); - let (transport, _) = ModuleTransport::new("configured".to_string(), Vec::new()); + let (transport, host) = ModuleTransport::new("configured".to_string(), Vec::new()); let mut module = TbModuleVtable::default(); unsafe { initialize_ok(std::ptr::null(), &mut module) }; transport.initialize(module).unwrap(); + unsafe { (host.ready)(host.host_ctx) }; for (code, expected) in [ (TB_BAD_ARGUMENT, "configuration is invalid"), (TB_CLOSED, "reinitialization failed"), @@ -228,10 +267,11 @@ async fn a_running_reinitialization_excludes_another_reinitialization_and_shutdo REINITIALIZE_BLOCKED.store(true, Ordering::Release); REINITIALIZE_ENTERED.store(false, Ordering::Release); SHUTDOWN_CALLS.store(0, Ordering::Release); - let (transport, _) = ModuleTransport::new("configured".to_string(), Vec::new()); + let (transport, host) = ModuleTransport::new("configured".to_string(), Vec::new()); let mut module = TbModuleVtable::default(); unsafe { initialize_ok(std::ptr::null(), &mut module) }; transport.initialize(module).unwrap(); + unsafe { (host.ready)(host.host_ctx) }; let first_transport = transport.clone(); let first = tokio::spawn(async move { @@ -348,6 +388,285 @@ async fn deferred_initialization_waits_for_ready_then_delivers_pending_calls() { assert_eq!(transport.stop_sync(Duration::from_millis(1)), TB_OK); } +#[tokio::test] +async fn deferred_reinitialize_replaces_the_initializer_config_slice() { + let original = br#"{"token":"old-sensitive-value"}"#; + let replacement = br#"{"route":"new"}"#; + let (transport, host) = ModuleTransport::new("deferred-config".into(), original.to_vec()); + let original_pointer = host.config.ptr; + let observed = Arc::new(StdMutex::new(None)); + let observed_by_init = observed.clone(); + transport.defer_initializer(host, move |host| { + if host.config.ptr.is_null() && host.config.len != 0 { + return Err("configuration pointer is null".to_owned()); + } + let config = serde_json::from_slice(unsafe { + std::slice::from_raw_parts(host.config.ptr, host.config.len) + }) + .map_err(|_| "configuration is invalid".to_owned())?; + *observed_by_init.lock().expect("observed config lock") = Some(config); + let mut module = TbModuleVtable::default(); + let code = unsafe { initialize_ok(&host, &mut module) }; + if code == TB_OK { + Ok(module) + } else { + Err("module initialization failed".to_owned()) + } + }); + + transport + .reinitialize(crate::Secret::new(replacement.to_vec())) + .await + .unwrap(); + { + let config = transport.context.config.lock().expect("module config lock"); + let initializer = transport + .initializer + .lock() + .expect("module initializer lock"); + let host = &initializer.as_ref().unwrap().host; + assert_ne!(config.as_ptr(), original_pointer); + assert_eq!(config.as_slice(), replacement); + assert_eq!(host.config.ptr, config.as_ptr()); + assert_eq!(host.config.len, config.len()); + assert!( + !config + .windows(b"old-sensitive-value".len()) + .any(|window| window == b"old-sensitive-value") + ); + } + + transport.ensure_initialized().await.unwrap(); + assert_eq!( + observed.lock().unwrap().as_ref(), + Some(&serde_json::json!({ "route": "new" })) + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn canceling_reconfiguration_waiting_for_started_lazy_init_keeps_initializer_owned() { + let _lock = VTABLE_TEST_LOCK + .get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await; + REINITIALIZE_CODE.store(TB_OK, Ordering::Release); + REINITIALIZE_ENTERED.store(false, Ordering::Release); + let (transport, host) = ModuleTransport::new("lazy-reinitialize-race".into(), b"{}".to_vec()); + let (worker_started_tx, worker_started_rx) = std::sync::mpsc::sync_channel(1); + let (release_worker_tx, release_worker_rx) = std::sync::mpsc::sync_channel(1); + *transport + .before_worker_lock + .lock() + .expect("worker test hook lock") = Some(Box::new(move || { + let _ = worker_started_tx.send(()); + let _ = release_worker_rx.recv(); + })); + transport.defer_initialize(initialize_ok, host); + + let initializing_transport = transport.clone(); + let initializing = + tokio::spawn(async move { initializing_transport.ensure_initialized().await }); + tokio::task::spawn_blocking(move || worker_started_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!( + transport + .context + .physical + .lock() + .expect("physical state lock") + .init, + InitState::Scheduled + ); + + let (attempted_tx, attempted_rx) = tokio::sync::oneshot::channel(); + let reconfiguring_transport = transport.clone(); + let reconfiguring = tokio::spawn(async move { + let _ = attempted_tx.send(()); + reconfiguring_transport + .reinitialize(crate::Secret::new(br#"{"route":"replacement"}"#.to_vec())) + .await + }); + attempted_rx.await.unwrap(); + tokio::task::yield_now().await; + assert!( + !transport.lifecycle_is_owned_for_test(), + "waiting for a scheduled initializer must not retain the lifecycle lock" + ); + reconfiguring.abort(); + assert!(reconfiguring.await.unwrap_err().is_cancelled()); + + release_worker_tx.send(()).unwrap(); + initializing.await.unwrap().unwrap(); + assert!(!transport.is_faulted()); + assert!(!REINITIALIZE_ENTERED.load(Ordering::Acquire)); + unsafe { (host.ready)(host.host_ctx) }; + + transport + .reinitialize(crate::Secret::new(br#"{"route":"after-init"}"#.to_vec())) + .await + .unwrap(); + assert!(REINITIALIZE_ENTERED.load(Ordering::Acquire)); +} + +#[tokio::test(flavor = "current_thread")] +async fn reinitialization_waits_for_host_ready_after_lazy_vtable_publication() { + struct SetupState { + ready: AtomicBool, + calls: AtomicUsize, + callback_returned: AtomicBool, + release_early: StdMutex>>, + } + unsafe extern "C" fn reinitialize_until_setup(ctx: *mut c_void, _: *const u8, _: usize) -> i32 { + let state = unsafe { &*ctx.cast::() }; + state.calls.fetch_add(1, Ordering::AcqRel); + let code = if state.ready.load(Ordering::Acquire) { + TB_OK + } else { + if let Some(release) = state.release_early.lock().unwrap().take() { + let _ = release.recv(); + } + TB_CLOSED + }; + state.callback_returned.store(true, Ordering::Release); + code + } + unsafe extern "C" fn setup_deliver(_: *mut c_void, _: *const u8, _: usize) -> i32 { + TB_OK + } + unsafe extern "C" fn setup_shutdown(_: *mut c_void, _: u64) -> i32 { + TB_OK + } + + let (transport, host) = ModuleTransport::new("host-ready-race".into(), b"{}".to_vec()); + let state = Arc::new(SetupState { + ready: AtomicBool::new(false), + calls: AtomicUsize::new(0), + callback_returned: AtomicBool::new(false), + release_early: StdMutex::new(None), + }); + *transport.fixture_controller.lock().unwrap() = Some(state.clone()); + let (worker_started_tx, worker_started_rx) = std::sync::mpsc::sync_channel(1); + let (release_worker_tx, release_worker_rx) = std::sync::mpsc::sync_channel(1); + *transport.before_worker_lock.lock().unwrap() = Some(Box::new(move || { + worker_started_tx.send(()).unwrap(); + release_worker_rx.recv().unwrap(); + })); + let (release_early_tx, release_early_rx) = std::sync::mpsc::sync_channel(1); + *state.release_early.lock().unwrap() = Some(release_early_rx); + let state_for_init = state.clone(); + transport.defer_initializer(host, move |_| { + Ok(TbModuleVtable { + size: size_of::() as u32, + _reserved: 0, + module_ctx: Arc::as_ptr(&state_for_init).cast_mut().cast(), + deliver: setup_deliver, + shutdown: setup_shutdown, + reinitialize: Some(reinitialize_until_setup), + }) + }); + + let first_call = tokio::spawn({ + let transport = transport.clone(); + async move { transport.send(call()).await } + }); + transport.wait_initializing().await; + tokio::task::spawn_blocking(move || worker_started_rx.recv()) + .await + .unwrap() + .unwrap(); + + // Poll the reinitializer while the physical initializer is still parked. + // This proves it took the scheduled-initializer path before publication. + let reinitializing_transport = transport.clone(); + let mut reinitializing = Box::pin( + reinitializing_transport + .reinitialize(crate::Secret::new(br#"{"route":"replacement"}"#.to_vec())), + ); + std::future::poll_fn(|cx| match reinitializing.as_mut().poll(cx) { + std::task::Poll::Pending => std::task::Poll::Ready(()), + std::task::Poll::Ready(result) => { + panic!("reinitialization finished before lazy initialization: {result:?}") + } + }) + .await; + + release_worker_tx.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if transport.context.physical.lock().unwrap().result.is_some() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("the lazy initializer publishes its vtable"); + + // Model the SDK setup window: the vtable is published while its + // reinitialization connection is absent and host.ready has not run. + assert!(transport.module.lock().unwrap().is_some()); + assert!(!transport.is_ready()); + let pending_before_ready = std::future::poll_fn(|cx| { + std::task::Poll::Ready(matches!( + reinitializing.as_mut().poll(cx), + std::task::Poll::Pending + )) + }) + .await; + let native_callback_started_before_ready = transport.native_lifecycle_pending(); + let callback_calls_before_ready = state.calls.load(Ordering::Acquire); + release_early_tx.send(()).unwrap(); + if callback_calls_before_ready != 0 { + tokio::time::timeout(Duration::from_secs(2), async { + while !state.callback_returned.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("an early native callback returns"); + } + + tokio::time::pause(); + tokio::time::advance(MODULE_INIT_DEADLINE).await; + let timed_out_before_ready = matches!(reinitializing.await, Err(Error::Timeout { .. })); + + state.ready.store(true, Ordering::Release); + unsafe { (host.ready)(host.host_ctx) }; + let first_call_result = tokio::time::timeout(Duration::from_secs(2), first_call) + .await + .expect("the original business call is released by readiness") + .unwrap(); + let reinitialize_after_ready = transport + .reinitialize(crate::Secret::new(br#"{"route":"after-ready"}"#.to_vec())) + .await; + let callback_calls_after_ready = state.calls.load(Ordering::Acquire); + tokio::time::resume(); + let stopped = transport.stop(Duration::from_secs(2)).await; + + assert!( + pending_before_ready, + "reconfiguration must wait for host.ready" + ); + assert!( + timed_out_before_ready, + "readiness wait keeps the original deadline" + ); + assert!( + !native_callback_started_before_ready, + "the native callback returns TB_CLOSED until setup publishes its connection" + ); + assert_eq!(callback_calls_before_ready, 0); + first_call_result.unwrap(); + reinitialize_after_ready.unwrap(); + assert_eq!( + callback_calls_after_ready, 1, + "callback runs after readiness" + ); + assert_eq!(stopped.unwrap(), TB_OK); +} + #[tokio::test] async fn a_failed_initializer_is_reported_to_callers() { let (transport, host) = @@ -583,10 +902,12 @@ async fn a_reinitialization_that_times_out_reports_a_timeout_without_the_configu .get_or_init(|| tokio::sync::Mutex::new(())) .lock() .await; - let (transport, _) = ModuleTransport::new("timeout".to_string(), Vec::new()); + let (transport, host) = ModuleTransport::new("timeout".to_string(), Vec::new()); let mut module = TbModuleVtable::default(); unsafe { initialize_ok(std::ptr::null(), &mut module) }; transport.initialize(module).unwrap(); + unsafe { (host.ready)(host.host_ctx) }; + REINITIALIZE_ENTERED.store(false, Ordering::Release); REINITIALIZE_CODE.store(TB_TIMEOUT, Ordering::Release); let error = transport .reinitialize(crate::Secret::new( @@ -596,6 +917,7 @@ async fn a_reinitialization_that_times_out_reports_a_timeout_without_the_configu .unwrap_err(); REINITIALIZE_CODE.store(TB_OK, Ordering::Release); assert!(matches!(error, Error::Timeout { .. }), "{error}"); + assert!(REINITIALIZE_ENTERED.load(Ordering::Acquire)); assert!(!error.to_string().contains("never printed")); } @@ -2070,6 +2392,10 @@ async fn actual_native_reinitializer_fault_denies_before_c_returns_and_joins_onc transport.defer_initialize(init, host); transport.ensure_initialized().await.unwrap(); barrier.entered.notified().await; + // This raw-controls fixture does not request Ready from its native init + // entry point. Publish readiness through the real ABI before exercising + // the reinitializer, whose SDK connection slot is installed at that edge. + unsafe { (host.ready)(host.host_ctx) }; { let mut state = barrier.state.lock().unwrap(); state.1 = false; @@ -2078,7 +2404,9 @@ async fn actual_native_reinitializer_fault_denies_before_c_returns_and_joins_onc let transport = transport.clone(); async move { transport.reinitialize(crate::Secret::new(vec![])).await } }); - barrier.entered.notified().await; + tokio::time::timeout(Duration::from_secs(10), barrier.entered.notified()) + .await + .expect("native reinitializer enters after host readiness"); unsafe { (host.fault)(host.host_ctx, std::ptr::null(), 0); } @@ -2375,3 +2703,493 @@ async fn completed_shutdown_has_already_released_its_native_lifecycle_marker() { "completed cleanup still reports native work" ); } + +#[tokio::test] +async fn selected_controller_binding_is_available_before_ready_and_revoked_on_disconnect() { + let broker = Broker::new_restricted_module_control(); + let (broker_transport, controller_transport) = MemoryTransport::pair(); + let selected = broker + .attach_module_controller(Arc::new(broker_transport)) + .unwrap(); + let controller = Connection::connect(Box::new(controller_transport)) + .await + .unwrap(); + assert_eq!(controller.unique_name().as_ref(), Some(&selected)); + let binding = broker + .restricted_module_controller_binding() + .expect("selected live peer is a loader capability"); + let (transport, host) = ModuleTransport::new_with_controller_binding( + "trusted-migration".into(), + vec![], + Some(binding.clone()), + Arc::from([]), + ); + let query = host + .trusted_host_binding + .expect("selected controller installs the optional ABI callback"); + let peer_len = selected.as_str().len(); + transport + .eager_initialize_with(host, |initializing_host| { + let mut output = [0_u8; crate::name::MAX_NAME_LEN]; + let mut output_len = usize::MAX; + assert_eq!( + unsafe { + query( + initializing_host.host_ctx, + output.as_mut_ptr(), + peer_len, + &mut output_len, + ) + }, + TB_OK + ); + assert_eq!(&output[..output_len], selected.as_str().as_bytes()); + assert_eq!(output_len, peer_len); + let mut module = TbModuleVtable::default(); + assert_eq!( + unsafe { initialize_ok(&initializing_host, &mut module) }, + TB_OK + ); + Ok(module) + }) + .unwrap(); + assert!(!transport.is_ready(), "binding is queried during setup"); + + let mut output = [0_u8; crate::name::MAX_NAME_LEN]; + assert!(!transport.is_ready()); + + let mut short = [0xa5_u8; crate::name::MAX_NAME_LEN]; + let mut output_len = usize::MAX; + assert_eq!( + unsafe { + query( + host.host_ctx, + short.as_mut_ptr(), + peer_len - 1, + &mut output_len, + ) + }, + TB_BUFFER_TOO_SMALL + ); + assert_eq!(output_len, peer_len); + assert!(short.iter().all(|byte| *byte == 0xa5)); + + output_len = usize::MAX; + assert_eq!( + unsafe { query(host.host_ctx, std::ptr::null_mut(), 0, &mut output_len,) }, + TB_BUFFER_TOO_SMALL + ); + assert_eq!(output_len, peer_len); + + assert_eq!( + unsafe { + query( + host.host_ctx, + output.as_mut_ptr(), + output.len(), + std::ptr::null_mut(), + ) + }, + TB_BAD_ARGUMENT + ); + + output_len = usize::MAX; + assert_eq!( + unsafe { query(host.host_ctx, std::ptr::null_mut(), 1, &mut output_len) }, + TB_BAD_ARGUMENT + ); + assert_eq!(output_len, 0); + assert_eq!( + unsafe { + query( + host.host_ctx, + output.as_mut_ptr(), + crate::name::MAX_NAME_LEN + 1, + &mut output_len, + ) + }, + TB_BAD_ARGUMENT + ); + assert_eq!(output_len, 0); + + controller.close().await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while binding.is_live() { + tokio::task::yield_now().await; + } + }) + .await + .expect("controller disconnect revokes subsequent queries"); + assert_eq!( + unsafe { + query( + host.host_ctx, + output.as_mut_ptr(), + output.len(), + &mut output_len, + ) + }, + TB_CLOSED + ); + + let replacement_broker = Broker::new_restricted_module_control(); + let (replacement_server, replacement_client) = MemoryTransport::pair(); + let replacement_peer = replacement_broker + .attach_module_controller(Arc::new(replacement_server)) + .unwrap(); + let _replacement = Connection::connect(Box::new(replacement_client)) + .await + .unwrap(); + assert_eq!( + replacement_peer, selected, + "separate brokers reuse unique names" + ); + let (replacement_transport, replacement_host) = ModuleTransport::new_with_controller_binding( + "replacement-migration".into(), + vec![], + replacement_broker.restricted_module_controller_binding(), + Arc::from([]), + ); + replacement_transport + .context + .init_started + .store(true, Ordering::Release); + let replacement_query = replacement_host.trusted_host_binding.unwrap(); + let mut replacement_output = [0_u8; crate::name::MAX_NAME_LEN]; + let mut replacement_len = 0; + assert_eq!( + unsafe { + replacement_query( + replacement_host.host_ctx, + replacement_output.as_mut_ptr(), + replacement_output.len(), + &mut replacement_len, + ) + }, + TB_OK + ); + assert_eq!( + &replacement_output[..replacement_len], + replacement_peer.as_str().as_bytes() + ); + assert_eq!( + unsafe { + query( + host.host_ctx, + output.as_mut_ptr(), + output.len(), + &mut output_len, + ) + }, + TB_CLOSED, + "an old attachment never follows a replacement with the same unique name" + ); +} + +#[tokio::test] +async fn selected_controller_route_is_delivered_before_ready_only_for_exact_confidential_call() { + use std::time::Duration; + + let _test_guard = VTABLE_TEST_LOCK + .get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await; + PRE_READY_DELIVERY_COUNT.store(0, Ordering::Release); + + let broker = Broker::new_restricted_module_control(); + let (broker_transport, controller_transport) = MemoryTransport::pair(); + let selected = broker + .attach_module_controller(Arc::new(broker_transport)) + .unwrap(); + let controller = Connection::connect(Box::new(controller_transport)) + .await + .unwrap(); + assert_eq!(controller.unique_name().as_ref(), Some(&selected)); + + let route = PreReadyConfidentialRoute { + path: "/org/tinystoragedrivers/migration".parse().unwrap(), + interface: "org.tinystoragedrivers.Migration".parse().unwrap(), + member: "GetSchema".parse().unwrap(), + }; + let binding = broker + .restricted_module_controller_binding() + .expect("the host selected one live controller"); + let (transport, host) = ModuleTransport::new_with_controller_binding( + "migration-fixture".into(), + vec![], + Some(binding.clone()), + Arc::from([route]), + ); + transport + .eager_initialize_with(host, |_| { + Ok(TbModuleVtable { + size: size_of::() as u32, + _reserved: 0, + module_ctx: std::ptr::dangling_mut(), + deliver: pre_ready_deliver, + shutdown, + reinitialize: Some(reinitialize), + }) + }) + .unwrap(); + assert!(!transport.is_ready(), "fixture remains pre-READY"); + + let staged = broker.attach_module_staged(transport.clone()); + let unique = staged.unique_name().clone(); + let module_name: crate::BusName = "org.tinystoragedrivers.Migration".parse().unwrap(); + broker + .reserve_module_name(&unique, module_name.clone()) + .unwrap(); + broker.attest_module( + &unique, + crate::attest::Attestation { + name: module_name.clone(), + sha256: "a".repeat(64), + library_sha256: None, + }, + ); + staged.start(); + + let mut call = Message::method_call( + module_name.clone(), + "/org/tinystoragedrivers/migration".parse().unwrap(), + "org.tinystoragedrivers.Migration".parse().unwrap(), + "GetSchema".parse().unwrap(), + serde_json::Value::Null, + ); + call.header.confidential = true; + let notification = PRE_READY_DELIVERED.notified(); + tokio::pin!(notification); + controller.send(call).await.unwrap(); + let delivered_while_initializing = + tokio::time::timeout(Duration::from_millis(200), &mut notification) + .await + .is_ok(); + + let mut ordinary = Message::method_call( + module_name.clone(), + "/org/tinystoragedrivers/migration".parse().unwrap(), + "org.tinystoragedrivers.Migration".parse().unwrap(), + "GetSchema".parse().unwrap(), + serde_json::Value::Null, + ); + ordinary.header.confidential = false; + controller.send(ordinary).await.unwrap(); + let queued_pre_ready_count = usize::from(!delivered_while_initializing); + wait_for_pending_len(&transport, queued_pre_ready_count + 1).await; + + let (outsider_broker, outsider_transport) = MemoryTransport::pair(); + broker.attach(Arc::new(outsider_broker)); + let outsider = Connection::connect(Box::new(outsider_transport)) + .await + .unwrap(); + let mut forged = Message::method_call( + module_name.clone(), + "/org/tinystoragedrivers/migration".parse().unwrap(), + "org.tinystoragedrivers.Migration".parse().unwrap(), + "GetSchema".parse().unwrap(), + serde_json::Value::Null, + ); + forged.header.confidential = true; + forged.header.sender = Some(selected.clone()); + outsider.send(forged).await.unwrap(); + wait_for_pending_len(&transport, queued_pre_ready_count + 2).await; + + let mut wrong_route = Message::method_call( + module_name.clone(), + "/org/tinystoragedrivers/migration/other".parse().unwrap(), + "org.tinystoragedrivers.Migration".parse().unwrap(), + "GetSchema".parse().unwrap(), + serde_json::Value::Null, + ); + wrong_route.header.confidential = true; + controller.send(wrong_route).await.unwrap(); + wait_for_pending_len(&transport, queued_pre_ready_count + 3).await; + + drop(controller); + tokio::time::timeout(Duration::from_secs(1), async { + while binding.is_live() { + tokio::task::yield_now().await; + } + }) + .await + .expect("the selected controller disconnects and its binding becomes stale"); + let mut stale_controller = Message::method_call( + module_name, + "/org/tinystoragedrivers/migration".parse().unwrap(), + "org.tinystoragedrivers.Migration".parse().unwrap(), + "GetSchema".parse().unwrap(), + serde_json::Value::Null, + ); + stale_controller.header.confidential = true; + stale_controller.header.sender = Some(selected.clone()); + outsider.send(stale_controller).await.unwrap(); + wait_for_pending_len(&transport, queued_pre_ready_count + 4).await; + assert_eq!( + PRE_READY_DELIVERY_COUNT.load(Ordering::Acquire), + usize::from(delivered_while_initializing) + ); + + // Release the legacy pending queue so the RED test leaves no blocked task. + unsafe { (host.ready)(host.host_ctx) }; + wait_for_pre_ready_delivery_count(5).await; + + assert!( + delivered_while_initializing, + "the exact confidential selected-controller route should bypass only the READY queue" + ); + assert_eq!(PRE_READY_DELIVERY_COUNT.load(Ordering::Acquire), 5); +} + +#[tokio::test] +async fn a_binding_absent_at_transport_creation_is_never_added_later() { + let broker = Broker::new_restricted_module_control(); + let (old_transport, old_host) = ModuleTransport::new_with_controller_binding( + "before-selection".into(), + vec![], + None, + Arc::from([]), + ); + assert!(old_host.trusted_host_binding.is_none()); + let mut output = [0_u8; crate::name::MAX_NAME_LEN]; + let mut output_len = 1; + assert_eq!( + unsafe { + host_trusted_host_binding( + old_host.host_ctx, + output.as_mut_ptr(), + output.len(), + &mut output_len, + ) + }, + TB_UNSUPPORTED + ); + assert_eq!(output_len, 0); + + let (broker_transport, controller_transport) = MemoryTransport::pair(); + broker + .attach_module_controller(Arc::new(broker_transport)) + .unwrap(); + let _controller = Connection::connect(Box::new(controller_transport)) + .await + .unwrap(); + assert!(old_host.trusted_host_binding.is_none()); + let (new_transport, new_host) = ModuleTransport::new_with_controller_binding( + "after-selection".into(), + vec![], + broker.restricted_module_controller_binding(), + Arc::from([]), + ); + assert!(new_host.trusted_host_binding.is_some()); + drop(old_transport); + drop(new_transport); + + let unrestricted = Broker::new(); + assert!( + unrestricted + .restricted_module_controller_binding() + .is_none() + ); + let (unrestricted_transport, unrestricted_host) = ModuleTransport::new_with_controller_binding( + "unrestricted".into(), + vec![], + unrestricted.restricted_module_controller_binding(), + Arc::from([]), + ); + assert!(unrestricted_host.trusted_host_binding.is_none()); + drop(unrestricted_transport); +} + +#[tokio::test] +async fn a_retained_binding_denies_after_its_module_owner_is_terminal_or_gone() { + let broker = Broker::new_restricted_module_control(); + let (broker_transport, controller_transport) = MemoryTransport::pair(); + broker + .attach_module_controller(Arc::new(broker_transport)) + .unwrap(); + let _controller = Connection::connect(Box::new(controller_transport)) + .await + .unwrap(); + let binding = broker + .restricted_module_controller_binding() + .expect("selected controller"); + let (transport, host) = ModuleTransport::new_with_controller_binding( + "terminal-binding".into(), + vec![], + Some(binding), + Arc::from([]), + ); + transport.eager_initialize(initialize_ok, host).unwrap(); + let query = host.trusted_host_binding.unwrap(); + let mut output = [0_u8; crate::name::MAX_NAME_LEN]; + let mut output_len = 0; + transport.commit_terminal(true); + assert_eq!( + unsafe { + query( + host.host_ctx, + output.as_mut_ptr(), + output.len(), + &mut output_len, + ) + }, + TB_CLOSED + ); + drop(transport); + assert_eq!( + unsafe { + query( + host.host_ctx, + output.as_mut_ptr(), + output.len(), + &mut output_len, + ) + }, + TB_CLOSED, + "the process-lifetime HostContext callback is a closed tombstone" + ); +} + +#[tokio::test] +async fn trusted_host_callback_denies_a_poisoned_owner_without_unwinding_through_c() { + let broker = Broker::new_restricted_module_control(); + let (broker_transport, controller_transport) = MemoryTransport::pair(); + broker + .attach_module_controller(Arc::new(broker_transport)) + .unwrap(); + let _controller = Connection::connect(Box::new(controller_transport)) + .await + .unwrap(); + let (transport, host) = ModuleTransport::new_with_controller_binding( + "poisoned-owner".into(), + vec![], + broker.restricted_module_controller_binding(), + Arc::from([]), + ); + transport.eager_initialize(initialize_ok, host).unwrap(); + let query = host.trusted_host_binding.unwrap(); + let context = transport.context; + assert!( + std::thread::spawn(move || { + let _owner = context.owner.lock().unwrap(); + panic!("poison the module owner lock for the callback test"); + }) + .join() + .is_err() + ); + + let mut output = [0_u8; crate::name::MAX_NAME_LEN]; + let mut output_len = usize::MAX; + assert_eq!( + unsafe { + query( + host.host_ctx, + output.as_mut_ptr(), + output.len(), + &mut output_len, + ) + }, + TB_CLOSED + ); + assert_eq!(output_len, 0); +} diff --git a/crates/tinybus/src/module/zip_deflate.rs b/crates/tinybus/src/module/zip_deflate.rs new file mode 100644 index 00000000..11cab40b --- /dev/null +++ b/crates/tinybus/src/module/zip_deflate.rs @@ -0,0 +1,106 @@ +//! Bounded output copying and strict raw-DEFLATE stream validation. + +use std::io::{self, Read, Write}; + +use crate::error::Result; +use crate::module::refused; + +pub(super) fn copy_bounded( + input: &mut impl Read, + output: &mut impl Write, + remaining: u64, + actual: &mut u64, +) -> Result<()> { + let mut limited = input.take(remaining.saturating_add(1)); + *actual = + io::copy(&mut limited, output).map_err(|_| refused("release archive extraction failed"))?; + if *actual > remaining { + return Err(refused("release archive exceeds the extraction limit")); + } + Ok(()) +} + +/// Requires a real DEFLATE end marker and exact input consumption. +/// The high-level flate2 reader can report EOF for an incomplete stream. +pub(super) struct StrictDeflate { + input: R, + decoder: flate2::Decompress, + buffer: [u8; 8192], + position: usize, + length: usize, + expected_input: u64, + ended: bool, +} + +impl StrictDeflate { + pub(super) fn new(input: R, expected_input: u64) -> Self { + Self { + input, + decoder: flate2::Decompress::new(false), + buffer: [0; 8192], + position: 0, + length: 0, + expected_input, + ended: false, + } + } +} + +impl Read for StrictDeflate { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if output.is_empty() || self.ended { + return Ok(0); + } + loop { + if self.position == self.length { + self.length = self.input.read(&mut self.buffer)?; + self.position = 0; + } + let eof = self.length == 0; + let input = &self.buffer[self.position..self.length]; + let before_in = self.decoder.total_in(); + let before_out = self.decoder.total_out(); + let status = self + .decoder + .decompress( + input, + output, + if eof { + flate2::FlushDecompress::Finish + } else { + flate2::FlushDecompress::None + }, + ) + .map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "invalid DEFLATE stream") + })?; + let consumed = (self.decoder.total_in() - before_in) as usize; + let produced = (self.decoder.total_out() - before_out) as usize; + self.position += consumed; + + if status == flate2::Status::StreamEnd { + if self.decoder.total_in() != self.expected_input { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "DEFLATE stream did not consume its complete entry", + )); + } + self.ended = true; + return Ok(produced); + } + if produced != 0 { + return Ok(produced); + } + if eof || (consumed == 0 && self.position < self.length) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "truncated DEFLATE stream", + )); + } + } + } +} + +#[cfg(test)] +#[path = "zip_deflate_tests.rs"] +mod tests; diff --git a/crates/tinybus/src/module/zip_deflate_tests.rs b/crates/tinybus/src/module/zip_deflate_tests.rs new file mode 100644 index 00000000..0511514e --- /dev/null +++ b/crates/tinybus/src/module/zip_deflate_tests.rs @@ -0,0 +1,47 @@ +use super::*; + +fn encoded(bytes: &[u8]) -> Vec { + let mut encoder = + flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() +} + +#[test] +fn reads_a_complete_stream_and_stops_at_its_end_marker() { + let original = b"the whole deflate stream"; + let compressed = encoded(original); + let mut decoder = StrictDeflate::new(compressed.as_slice(), compressed.len() as u64); + let mut decoded = Vec::new(); + decoder.read_to_end(&mut decoded).unwrap(); + assert_eq!(decoded, original); + assert_eq!(decoder.read(&mut [0; 1]).unwrap(), 0); +} + +#[test] +fn refuses_truncated_and_trailing_deflate_bytes() { + let mut truncated = encoded(b"payload whose final DEFLATE marker matters"); + truncated.pop(); + let mut decoder = StrictDeflate::new(truncated.as_slice(), truncated.len() as u64); + assert!(decoder.read_to_end(&mut Vec::new()).is_err()); + + let compressed = encoded(b"payload"); + let mut trailing = compressed.clone(); + trailing.push(0); + let mut decoder = StrictDeflate::new(trailing.as_slice(), trailing.len() as u64); + assert!(decoder.read_to_end(&mut Vec::new()).is_err()); +} + +#[test] +fn output_copy_allows_exact_limit_but_refuses_one_byte_over() { + let input = b"1234"; + let mut output = Vec::new(); + let mut actual = 0; + copy_bounded(&mut &input[..], &mut output, 4, &mut actual).unwrap(); + assert_eq!(output, input); + assert_eq!(actual, 4); + + let mut output = Vec::new(); + assert!(copy_bounded(&mut &input[..], &mut output, 3, &mut actual).is_err()); + assert_eq!(actual, 4); +} diff --git a/crates/tinybus/src/module/zip_descriptor.rs b/crates/tinybus/src/module/zip_descriptor.rs new file mode 100644 index 00000000..43256767 --- /dev/null +++ b/crates/tinybus/src/module/zip_descriptor.rs @@ -0,0 +1,75 @@ +//! Strict validation of ZIP streaming descriptors. + +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +use crate::error::Result; +use crate::module::refused; + +/// Match a descriptor against central-directory claims and stay inside the +/// validated compressed-data-to-central-directory range. +pub(super) fn validate_data_descriptor( + archive_path: &Path, + offset: u64, + directory_offset: u64, + zip64: bool, + expected_crc: u32, + expected_compressed: u64, + expected_expanded: u64, +) -> Result { + const SIGNATURE: [u8; 4] = *b"PK\x07\x08"; + let max_size = if zip64 { 24 } else { 16 }; + let available = directory_offset + .checked_sub(offset) + .ok_or_else(|| refused("release archive has an invalid data descriptor"))?; + if available < (if zip64 { 20u64 } else { 12u64 }) { + return Ok(false); + } + let read_size = available.min(max_size) as usize; + let mut bytes = [0u8; 24]; + let mut file = File::open(archive_path) + .map_err(|_| refused("release archive has an invalid data descriptor"))?; + file.seek(SeekFrom::Start(offset)) + .and_then(|_| file.read_exact(&mut bytes[..read_size])) + .map_err(|_| refused("release archive has an invalid data descriptor"))?; + + for prefix in [0usize, 4] { + if prefix == 4 && bytes[..4] != SIGNATURE { + continue; + } + let required = prefix + if zip64 { 20 } else { 12 }; + if read_size < required { + continue; + } + let crc = read_u32(&bytes[prefix..prefix + 4]); + let (compressed, expanded) = if zip64 { + ( + read_u64(&bytes[prefix + 4..prefix + 12]), + read_u64(&bytes[prefix + 12..prefix + 20]), + ) + } else { + ( + u64::from(read_u32(&bytes[prefix + 4..prefix + 8])), + u64::from(read_u32(&bytes[prefix + 8..prefix + 12])), + ) + }; + if crc == expected_crc && compressed == expected_compressed && expanded == expected_expanded + { + return Ok(true); + } + } + Ok(false) +} + +fn read_u32(bytes: &[u8]) -> u32 { + u32::from_le_bytes(bytes.try_into().expect("fixed-width ZIP integer")) +} + +fn read_u64(bytes: &[u8]) -> u64 { + u64::from_le_bytes(bytes.try_into().expect("fixed-width ZIP integer")) +} + +#[cfg(test)] +#[path = "zip_descriptor_tests.rs"] +mod tests; diff --git a/crates/tinybus/src/module/zip_descriptor_tests.rs b/crates/tinybus/src/module/zip_descriptor_tests.rs new file mode 100644 index 00000000..d9ea930d --- /dev/null +++ b/crates/tinybus/src/module/zip_descriptor_tests.rs @@ -0,0 +1,60 @@ +use super::*; + +fn descriptor(zip64: bool, signature: bool, crc: u32, compressed: u64, expanded: u64) -> Vec { + let mut bytes = Vec::new(); + if signature { + bytes.extend_from_slice(b"PK\x07\x08"); + } + bytes.extend_from_slice(&crc.to_le_bytes()); + if zip64 { + bytes.extend_from_slice(&compressed.to_le_bytes()); + bytes.extend_from_slice(&expanded.to_le_bytes()); + } else { + bytes.extend_from_slice(&(compressed as u32).to_le_bytes()); + bytes.extend_from_slice(&(expanded as u32).to_le_bytes()); + } + bytes +} + +fn validate(bytes: &[u8], zip64: bool, crc: u32, compressed: u64, expanded: u64) -> Result { + let directory = tempfile::tempdir().unwrap(); + let archive = directory.path().join("descriptor.zip"); + std::fs::write(&archive, bytes).unwrap(); + validate_data_descriptor( + &archive, + 0, + bytes.len() as u64, + zip64, + crc, + compressed, + expanded, + ) +} + +#[test] +fn matches_signed_and_unsigned_32_bit_descriptors() { + for signed in [true, false] { + let bytes = descriptor(false, signed, 0x1234, 17, 29); + assert!(validate(&bytes, false, 0x1234, 17, 29).unwrap()); + } +} + +#[test] +fn matches_signed_and_unsigned_zip64_descriptors() { + for signed in [true, false] { + let bytes = descriptor(true, signed, 0x1234, 1 << 33, 1 << 34); + assert!(validate(&bytes, true, 0x1234, 1 << 33, 1 << 34).unwrap()); + } +} + +#[test] +fn rejects_wrong_fields_truncation_and_out_of_range_offsets() { + let valid = descriptor(true, true, 0x1234, 17, 29); + assert!(!validate(&valid, true, 0x5678, 17, 29).unwrap()); + assert!(!validate(&valid[..10], true, 0x1234, 17, 29).unwrap()); + + let directory = tempfile::tempdir().unwrap(); + let archive = directory.path().join("descriptor.zip"); + std::fs::write(&archive, &valid).unwrap(); + assert!(validate_data_descriptor(&archive, 1, 0, true, 0x1234, 17, 29,).is_err()); +} diff --git a/crates/tinybus/src/module/zip_extract.rs b/crates/tinybus/src/module/zip_extract.rs new file mode 100644 index 00000000..22a5c9fe --- /dev/null +++ b/crates/tinybus/src/module/zip_extract.rs @@ -0,0 +1,520 @@ +//! Bounded ZIP extraction for module release archives. +//! +//! `rawzip` parses ZIP structure but intentionally leaves extraction policy to +//! its callers. This adapter accepts portable paths, STORE and DEFLATE entries, +//! and writes regular files into the host-owned staging directory without +//! applying archive permissions or creating links. Unicode normalization and +//! full case folding are used only for alias detection; output keeps archive +//! spelling. + +use std::collections::HashMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Component, Path, PathBuf}; + +use icu_casemap::CaseMapper; +use rawzip::{CompressionMethod, ZipArchive, ZipArchiveEntryWayfinder}; +use unicode_normalization::UnicodeNormalization; + +use crate::error::Result; +use crate::module::refused; +use crate::module::zip_deflate::{StrictDeflate, copy_bounded}; +use crate::module::zip_descriptor::validate_data_descriptor; + +const MAX_ENTRY_COUNT: u64 = 100_000; +/// A single uncompressed module file may be large (debug symbols, for example), +/// but cannot exceed one GiB even when the download itself is compressed. +const MAX_ENTRY_EXPANDED_BYTES: u64 = 1024 * 1024 * 1024; +/// The current release-download limit is 512 MiB compressed; a 2 GiB expanded +/// archive leaves room for substantial decompression while bounding disk use. +const MAX_ARCHIVE_EXPANDED_BYTES: u64 = 2 * 1024 * 1024 * 1024; +const MAX_NAME_BYTES: usize = 4096; +/// NFC plus full case-fold may expand a component; keep even the transient key +/// bounded by a fixed multiple of the existing archive-name limit. +const MAX_PATH_KEY_COMPONENT_BYTES: usize = MAX_NAME_BYTES * 16; +const MAX_PATH_DEPTH: usize = 128; +const MAX_PATH_METADATA_BYTES: usize = 16 * 1024 * 1024; +const MAX_PATH_COMPONENTS: usize = 250_000; + +#[derive(Clone, Copy)] +struct ExtractionLimits { + entry_bytes: u64, + total_bytes: u64, + entries: u64, + path_metadata_bytes: usize, + path_depth: usize, + path_components: usize, +} + +const DEFAULT_LIMITS: ExtractionLimits = ExtractionLimits { + entry_bytes: MAX_ENTRY_EXPANDED_BYTES, + total_bytes: MAX_ARCHIVE_EXPANDED_BYTES, + entries: MAX_ENTRY_COUNT, + path_metadata_bytes: MAX_PATH_METADATA_BYTES, + path_depth: MAX_PATH_DEPTH, + path_components: MAX_PATH_COMPONENTS, +}; + +#[derive(Default)] +struct PathNode { + children: HashMap, + explicit_entry: Option, +} + +struct PathChild { + spelling: String, + node: PathNode, +} + +impl PathNode { + fn insert( + &mut self, + path: &Path, + is_dir: bool, + path_metadata_bytes: &mut usize, + max_path_metadata_bytes: usize, + ) -> Result { + let mut node = self; + for component in path.components() { + if node.explicit_entry == Some(false) { + return Ok(false); + } + let Component::Normal(name) = component else { + return Ok(false); + }; + let spelling = name + .to_str() + .ok_or_else(|| refused("release archive contains an unsafe path"))?; + let folded = collision_key_component(spelling)?; + // Bound the derived trie key alongside original path metadata so + // case-fold expansion cannot grow archive-controlled bookkeeping. + *path_metadata_bytes = path_metadata_bytes + .checked_add(folded.len()) + .ok_or_else(|| refused("release archive path metadata exceeds its limit"))?; + if *path_metadata_bytes > max_path_metadata_bytes { + return Err(refused("release archive path metadata exceeds its limit")); + } + let child = node.children.entry(folded).or_insert_with(|| PathChild { + spelling: spelling.to_owned(), + node: PathNode::default(), + }); + if child.spelling != spelling { + return Ok(false); + } + node = &mut child.node; + } + if node.explicit_entry.is_some() || (!is_dir && !node.children.is_empty()) { + return Ok(false); + } + node.explicit_entry = Some(is_dir); + Ok(true) + } +} + +fn collision_key_component(component: &str) -> Result { + // Normalize both sides of full folding so canonical aliases share a key; + // the returned string is never used as the extracted filesystem spelling. + let normalized: String = component.nfc().collect(); + let folded = CaseMapper::new().fold_string(&normalized); + let key: String = folded.nfc().collect(); + if key.is_empty() || key.len() > MAX_PATH_KEY_COMPONENT_BYTES { + return Err(refused("release archive path key exceeds its limit")); + } + Ok(key) +} + +#[derive(Clone)] +struct EntryPlan { + wayfinder: ZipArchiveEntryWayfinder, + path: PathBuf, + is_dir: bool, + method: CompressionMethod, + compressed_size: u64, + expanded_size: u64, + data_range: (u64, u64), +} + +/// Extract a ZIP archive into a fresh private staging directory. +/// +/// The caller owns `destination` and must discard it if this function fails. +pub(super) fn extract(archive_path: &Path, destination: &Path) -> Result<()> { + extract_with_limits(archive_path, destination, DEFAULT_LIMITS) +} + +/// Safely extract a ZIP archive and hash its sole shared module library. +#[cfg(feature = "cli")] +pub(super) fn library_sha256(archive_path: &Path) -> Result { + let directory = tempfile::tempdir() + .map_err(|_| refused("checksum ZIP extraction directory is unavailable"))?; + extract(archive_path, directory.path())?; + let mut pending = vec![directory.path().to_path_buf()]; + let mut library = None; + while let Some(directory) = pending.pop() { + for entry in std::fs::read_dir(&directory) + .map_err(|_| refused("checksum ZIP extraction could not be read"))? + { + let entry = entry.map_err(|_| refused("checksum ZIP extraction is invalid"))?; + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|_| refused("checksum ZIP extraction is invalid"))?; + if file_type.is_dir() { + pending.push(path); + continue; + } + if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| matches!(extension, "so" | "dylib" | "dll")) + && library.replace(path).is_some() + { + return Err(refused( + "checksum ZIP contains more than one module library", + )); + } + } + } + let library = library.ok_or_else(|| refused("checksum ZIP contains no module library"))?; + crate::module::sha256_file(library) +} + +fn extract_with_limits( + archive_path: &Path, + destination: &Path, + limits: ExtractionLimits, +) -> Result<()> { + let file = File::open(archive_path).map_err(|_| refused("release archive is unreadable"))?; + let file_len = file + .metadata() + .map_err(|_| refused("release archive is unreadable"))? + .len(); + let mut buffer = vec![0; rawzip::RECOMMENDED_BUFFER_SIZE]; + let archive = ZipArchive::from_file(file, &mut buffer) + .map_err(|_| refused("release archive is invalid"))?; + let expected_count = archive.entries_hint(); + if expected_count > limits.entries { + return Err(refused("release archive contains too many entries")); + } + + let mut plans = Vec::with_capacity(expected_count as usize); + let mut hinted_expanded_total = 0u64; + let mut path_metadata_bytes = 0usize; + let mut path_component_count = 0usize; + let mut path_trie = PathNode::default(); + let mut local_header_buffer = vec![0; rawzip::MAX_CENTRAL_DIRECTORY_RECORD_SIZE]; + let mut entries = archive.entries(&mut buffer); + while let Some(entry) = entries + .next_entry() + .map_err(|_| refused("release archive is invalid"))? + { + if plans.len() as u64 >= limits.entries { + return Err(refused("release archive contains too many entries")); + } + + let raw_path = entry.file_path(); + let original = std::str::from_utf8(raw_path.as_ref()) + .map_err(|_| refused("release archive contains an unsafe path"))?; + path_metadata_bytes = path_metadata_bytes + .checked_add(original.len()) + .ok_or_else(|| refused("release archive path metadata exceeds its limit"))?; + if path_metadata_bytes > limits.path_metadata_bytes { + return Err(refused("release archive path metadata exceeds its limit")); + } + let (path, is_dir, components) = checked_path(original, limits.path_depth)?; + let creator_system = entry.version_made_by().creator_system(); + if matches!( + creator_system, + rawzip::CreatorSystem::UNIX | rawzip::CreatorSystem::MACOS + ) { + // Inspect the raw type bits: rawzip's normalized mode synthesizes + // S_IFREG when a Unix archive omits the type, which would combine + // with a slash-marked directory as a false special-file type. + let file_type = (entry.external_attributes() >> 16) & 0o170000; + let allowed_type = if is_dir { 0o040000 } else { 0o100000 }; + if file_type != 0 && file_type != allowed_type { + return Err(refused( + "release archive contains an unsupported entry type", + )); + } + } + path_component_count = path_component_count + .checked_add(components) + .ok_or_else(|| refused("release archive path metadata exceeds its limit"))?; + if path_component_count > limits.path_components { + return Err(refused("release archive path metadata exceeds its limit")); + } + if !path_trie.insert( + &path, + is_dir, + &mut path_metadata_bytes, + limits.path_metadata_bytes, + )? { + return Err(refused( + "release archive contains duplicate or case-colliding paths", + )); + } + + let method = entry.compression_method(); + let flags = entry.flags(); + validate_flags(flags, method)?; + if method != CompressionMethod::STORE && method != CompressionMethod::DEFLATE { + return Err(refused( + "release archive uses an unsupported compression method", + )); + } + + let compressed_size = entry.compressed_size_hint(); + let expanded_size = entry.uncompressed_size_hint(); + if expanded_size > limits.entry_bytes { + return Err(refused( + "release archive entry exceeds the extraction limit", + )); + } + if is_dir && expanded_size != 0 { + return Err(refused("release archive directory entry is not empty")); + } + hinted_expanded_total = hinted_expanded_total + .checked_add(expanded_size) + .ok_or_else(|| refused("release archive exceeds the extraction limit"))?; + if hinted_expanded_total > limits.total_bytes { + return Err(refused("release archive exceeds the extraction limit")); + } + let local = archive + .get_entry(entry.wayfinder()) + .map_err(|_| refused("release archive is invalid"))?; + let range = local.compressed_data_range(); + if range.0 > range.1 + || range.0 < entry.local_header_offset() + || range.1 > file_len + || range.1 > archive.directory_offset() + || range.1 - range.0 != compressed_size + { + return Err(refused("release archive contains invalid entry offsets")); + } + let local_header = local + .local_header(&mut local_header_buffer) + .map_err(|_| refused("release archive is invalid"))?; + if local_header.file_path().as_ref() != raw_path.as_ref() + || local_header.compression_method() != method + || local_header.flags() != flags + { + return Err(refused("release archive headers disagree")); + } + if flags.has_data_descriptor() { + let local_sizes = ( + local_header.crc32(), + local_header.compressed_size_hint(), + local_header.uncompressed_size_hint(), + ); + let all_zero = local_sizes == (0, 0, 0); + let matches_central = local_sizes == (entry.crc32(), compressed_size, expanded_size); + if !all_zero && !matches_central { + return Err(refused("release archive headers disagree")); + } + let local_has_zip64_extra = local_header + .extra_fields() + .any(|(id, _)| id == rawzip::extra_fields::ExtraFieldId::ZIP64); + if !validate_data_descriptor( + archive_path, + range.1, + archive.directory_offset().min(file_len), + local_has_zip64_extra + || compressed_size >= u32::MAX as u64 + || expanded_size >= u32::MAX as u64, + entry.crc32(), + compressed_size, + expanded_size, + )? { + return Err(refused("release archive data descriptor disagrees")); + } + } else if local_header.crc32() != entry.crc32() + || local_header.compressed_size_hint() != compressed_size + || local_header.uncompressed_size_hint() != expanded_size + { + return Err(refused("release archive headers disagree")); + } + + plans.push(EntryPlan { + wayfinder: entry.wayfinder(), + path, + is_dir, + method, + compressed_size, + expanded_size, + data_range: range, + }); + } + if plans.len() as u64 != expected_count { + return Err(refused("release archive entry count is inconsistent")); + } + + let mut ranges: Vec<_> = plans + .iter() + .map(|plan| plan.data_range) + .filter(|(start, end)| start < end) + .collect(); + ranges.sort_unstable_by_key(|range| range.0); + if ranges.windows(2).any(|pair| pair[0].1 > pair[1].0) { + return Err(refused("release archive contains overlapping entries")); + } + + let mut expanded_total = 0u64; + for plan in plans { + let target = destination.join(&plan.path); + if plan.is_dir { + fs::create_dir_all(&target) + .map_err(|_| refused("release archive extraction failed"))?; + } else if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|_| refused("release archive extraction failed"))?; + } + let mut output: Box = if plan.is_dir { + Box::new(io::sink()) + } else { + Box::new( + OpenOptions::new() + .write(true) + .create_new(true) + .open(&target) + .map_err(|_| refused("release archive extraction failed"))?, + ) + }; + let local = archive + .get_entry(plan.wayfinder) + .map_err(|_| refused("release archive is invalid"))?; + if local.compressed_data_range() != plan.data_range { + return Err(refused("release archive entry offsets changed")); + } + let reader = local.reader(); + let mut actual = 0u64; + match plan.method { + CompressionMethod::STORE => { + let mut verified = local.verifying_reader(reader); + copy_bounded( + &mut verified, + &mut output, + limits.entry_bytes.min(limits.total_bytes - expanded_total), + &mut actual, + )?; + } + CompressionMethod::DEFLATE => { + let decoder = StrictDeflate::new(reader, plan.compressed_size); + let mut verified = local.verifying_reader(decoder); + copy_bounded( + &mut verified, + &mut output, + limits.entry_bytes.min(limits.total_bytes - expanded_total), + &mut actual, + )?; + } + _ => { + return Err(refused( + "release archive uses an unsupported compression method", + )); + } + } + if actual != plan.expanded_size { + return Err(refused("release archive entry size is inconsistent")); + } + expanded_total = expanded_total + .checked_add(actual) + .ok_or_else(|| refused("release archive exceeds the extraction limit"))?; + } + Ok(()) +} + +fn validate_flags(flags: rawzip::EntryFlags, method: CompressionMethod) -> Result<()> { + if flags.is_encrypted() || flags.has_strong_encryption() || flags.is_masked() { + return Err(refused("release archive contains encrypted entries")); + } + let allowed: u16 = 1 << 3 + | 1 << 11 + | if method == CompressionMethod::DEFLATE { + 0b110 + } else { + 0 + }; + if flags.bits() & !allowed != 0 { + return Err(refused("release archive contains unsupported entry flags")); + } + Ok(()) +} + +fn checked_path(original: &str, max_depth: usize) -> Result<(PathBuf, bool, usize)> { + if original.is_empty() + || original.len() > MAX_NAME_BYTES + || original.as_bytes().contains(&0) + || original.starts_with('/') + || original.contains('\\') + || original.contains(':') + || original + .chars() + .any(|ch| ch.is_control() || "<>|?*\"".contains(ch)) + { + return Err(refused("release archive contains an unsafe path")); + } + let is_dir = original.ends_with('/'); + let body = if is_dir { + original.strip_suffix('/').unwrap_or_default() + } else { + original + }; + if body.split('/').any(|part| part == "..") { + return Err(refused("release archive contains an unsafe path")); + } + if body.split('/').count() > max_depth { + return Err(refused("release archive path exceeds the depth limit")); + } + let normalized = rawzip::path::ZipFilePath::from_bytes(original.as_bytes()) + .try_normalize() + .map_err(|_| refused("release archive contains an unsafe path"))?; + if normalized.as_ref() != original { + return Err(refused("release archive contains an unsafe path")); + } + let parts: Vec<_> = body.split('/').collect(); + if parts.is_empty() + || parts + .iter() + .any(|part| part.is_empty() || *part == "." || *part == "..") + { + return Err(refused("release archive contains an unsafe path")); + } + for part in &parts { + if part.ends_with('.') || part.ends_with(' ') || is_windows_device_name(part) { + return Err(refused("release archive contains an unsafe path")); + } + } + + let mut path = PathBuf::new(); + for part in &parts { + path.push(part); + } + if path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(refused("release archive contains an unsafe path")); + } + Ok((path, is_dir, parts.len())) +} + +fn is_windows_device_name(component: &str) -> bool { + let stem = component + .split('.') + .next() + .unwrap_or_default() + .trim_end_matches(['.', ' ']); + let upper = stem.to_ascii_uppercase(); + matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || ["COM", "LPT"].iter().any(|prefix| { + upper.strip_prefix(prefix).is_some_and(|number| { + matches!( + number, + "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³" + ) + }) + }) +} + +#[cfg(all(test, feature = "modules"))] +#[path = "zip_extract_tests.rs"] +mod tests; diff --git a/crates/tinybus/src/module/zip_extract_tests.rs b/crates/tinybus/src/module/zip_extract_tests.rs new file mode 100644 index 00000000..349c5377 --- /dev/null +++ b/crates/tinybus/src/module/zip_extract_tests.rs @@ -0,0 +1,742 @@ +use super::*; + +fn extract_fixture(name: &str) -> (tempfile::TempDir, PathBuf, Result<()>) { + let root = tempfile::tempdir().unwrap(); + let archive = root.path().join("fixture.zip"); + let destination = root.path().join("staging"); + fs::create_dir(&destination).unwrap(); + let bytes: &[u8] = match name { + "compat_store.zip" => include_bytes!("fixtures/compat_store.zip"), + "compat_deflate_descriptor.zip" => { + include_bytes!("fixtures/compat_deflate_descriptor.zip") + } + "unsafe_parent.zip" => include_bytes!("fixtures/unsafe_parent.zip"), + "windows_drive.zip" => include_bytes!("fixtures/windows_drive.zip"), + "windows_backslash.zip" => include_bytes!("fixtures/windows_backslash.zip"), + "unix_link_mode.zip" => include_bytes!("fixtures/unix_link_mode.zip"), + "suid_mode.zip" => include_bytes!("fixtures/suid_mode.zip"), + "duplicate.zip" => include_bytes!("fixtures/duplicate.zip"), + "archive_collision.zip" => include_bytes!("fixtures/archive_collision.zip"), + "case_collision.zip" => include_bytes!("fixtures/case_collision.zip"), + "parent_case_collision.zip" => include_bytes!("fixtures/parent_case_collision.zip"), + "file_directory_collision.zip" => include_bytes!("fixtures/file_directory_collision.zip"), + "canonical_parent_collision.zip" => { + include_bytes!("fixtures/canonical_parent_collision.zip") + } + "canonical_leaf_collision.zip" => { + include_bytes!("fixtures/canonical_leaf_collision.zip") + } + "casefold_collision.zip" => include_bytes!("fixtures/casefold_collision.zip"), + "unicode_paths.zip" => include_bytes!("fixtures/unicode_paths.zip"), + "compat_zip64_local.zip" => include_bytes!("fixtures/compat_zip64_local.zip"), + "encrypted.zip" => include_bytes!("fixtures/encrypted.zip"), + "unsupported_method.zip" => include_bytes!("fixtures/unsupported_method.zip"), + "overlap.zip" => include_bytes!("fixtures/overlap.zip"), + "one_module.zip" => include_bytes!("fixtures/one_module.zip"), + "compat_deflate_descriptor_zip64_local.zip" => { + include_bytes!("fixtures/compat_deflate_descriptor_zip64_local.zip") + } + "deep_path.zip" => include_bytes!("fixtures/deep_path.zip"), + _ => panic!("unknown test fixture"), + }; + fs::write(&archive, bytes).unwrap(); + let result = extract(&archive, &destination); + (root, destination, result) +} + +fn extract_fixture_bytes(bytes: &[u8]) -> (tempfile::TempDir, PathBuf, Result<()>) { + let root = tempfile::tempdir().unwrap(); + let archive = root.path().join("fixture.zip"); + let destination = root.path().join("staging"); + fs::create_dir(&destination).unwrap(); + fs::write(&archive, bytes).unwrap(); + let result = extract(&archive, &destination); + (root, destination, result) +} + +fn extract_fixture_bytes_with_limits( + bytes: &[u8], + limits: ExtractionLimits, +) -> (tempfile::TempDir, PathBuf, Result<()>) { + let root = tempfile::tempdir().unwrap(); + let archive = root.path().join("fixture.zip"); + let destination = root.path().join("staging"); + fs::create_dir(&destination).unwrap(); + fs::write(&archive, bytes).unwrap(); + let result = extract_with_limits(&archive, &destination, limits); + (root, destination, result) +} + +fn extract_through_cache_staging( + bytes: &[u8], +) -> (tempfile::TempDir, Result<()>, Option, bool) { + let root = tempfile::tempdir().unwrap(); + let cache_dir = root.path().join("cache"); + fs::create_dir(&cache_dir).unwrap(); + let mut staging_path = None; + let mut prior_module_was_extracted = false; + let result = (|| { + let staging = crate::module::cache::stage(&cache_dir)?; + staging_path = Some(staging.path().to_path_buf()); + let archive = staging.path().join("fixture.zip"); + fs::write(&archive, bytes).map_err(|_| refused("test fixture write failed"))?; + let extraction = extract(&archive, staging.path()); + prior_module_was_extracted = staging.path().join("folder/module.so").is_file(); + extraction?; + crate::module::cache::commit(staging, &cache_dir) + })(); + (root, result, staging_path, prior_module_was_extracted) +} + +#[test] +fn extracts_independent_stored_and_deflated_archives() { + let (_root, destination, result) = extract_fixture("compat_store.zip"); + result.unwrap(); + assert_eq!( + fs::read(destination.join("folder/module.so")).unwrap(), + b"module-payload" + ); + assert_eq!(fs::read(destination.join("empty")).unwrap(), b""); + + let (_root, destination, result) = extract_fixture("compat_deflate_descriptor.zip"); + result.unwrap(); + assert_eq!( + fs::read(destination.join("folder/module.so")).unwrap(), + b"deflated-module-payload".repeat(16) + ); +} + +#[test] +fn extracts_a_small_archive_with_zip64_local_size_fields() { + let (_root, destination, result) = extract_fixture("compat_zip64_local.zip"); + result.unwrap(); + assert_eq!( + fs::read(destination.join("module.so")).unwrap(), + b"zip64-local-module" + ); +} + +#[test] +fn accepts_streaming_zip64_local_headers_with_ordinary_central_sizes() { + let (_root, destination, result) = extract_fixture("compat_deflate_descriptor_zip64_local.zip"); + result.unwrap(); + assert_eq!( + fs::read(destination.join("module.so")).unwrap(), + b"zip64 streaming descriptor" + ); +} + +#[test] +fn excessive_path_depth_is_rejected_before_filesystem_extraction() { + let (_root, destination, result) = extract_fixture("deep_path.zip"); + assert!(result.is_err()); + assert!(fs::read_dir(destination).unwrap().next().is_none()); +} + +#[test] +fn rejects_encryption_and_unsupported_methods() { + for fixture in ["encrypted.zip", "unsupported_method.zip"] { + let (_root, _destination, result) = extract_fixture(fixture); + assert!(result.is_err(), "accepted fixture {fixture}"); + } +} + +#[test] +fn rejects_overlapping_compressed_entry_ranges() { + let (_root, _destination, result) = extract_fixture("overlap.zip"); + assert!(result.is_err()); +} + +#[test] +fn central_directory_entry_count_must_match_the_entries_read() { + let mut bytes = include_bytes!("fixtures/one_module.zip").to_vec(); + let eocd = bytes + .windows(4) + .position(|signature| signature == b"PK\x05\x06") + .unwrap(); + bytes[eocd + 10..eocd + 12].copy_from_slice(&0u16.to_le_bytes()); + let (_root, _destination, result) = extract_fixture_bytes(&bytes); + assert!(result.is_err()); +} + +#[test] +fn path_policy_rejects_unportable_or_ambiguous_names_before_normalization() { + for name in [ + "../escape", + "a/../escape", + "/absolute", + "C:/drive", + "C:relative-drive", + r"..\escape", + r"\\server\share\escape", + "a:stream", + "NUL.txt", + "COM1", + "LPT9.log", + "COM¹.log", + "LPT³", + "trailing.", + "trailing ", + "a//b", + "./file", + "a\u{0001}b", + "", + ] { + assert!( + checked_path(name, MAX_PATH_DEPTH).is_err(), + "accepted {name:?}" + ); + } + assert!(checked_path(&"a".repeat(MAX_NAME_BYTES + 1), MAX_PATH_DEPTH).is_err()); + let (root, _destination, result) = extract_fixture("unsafe_parent.zip"); + assert!(result.is_err()); + assert!(!root.path().join("outside.txt").exists()); +} + +#[test] +fn windows_drive_and_backslash_paths_are_rejected_on_every_platform() { + for fixture in ["windows_drive.zip", "windows_backslash.zip"] { + let (root, _destination, result) = extract_fixture(fixture); + assert!(result.is_err(), "accepted fixture {fixture}"); + assert!(!root.path().join("outside.txt").exists()); + } +} + +#[test] +fn duplicate_names_are_refused_before_any_file_is_written() { + let (_root, destination, result) = extract_fixture("duplicate.zip"); + assert!(result.is_err()); + assert!(!destination.join("module.so").exists()); +} + +#[test] +fn case_colliding_paths_are_refused_portably() { + let (_root, destination, result) = extract_fixture("case_collision.zip"); + assert!(result.is_err()); + assert!(!destination.join("foo/a").exists()); + assert!(!destination.join("FOO/b").exists()); +} + +#[test] +fn implicit_directory_case_collisions_and_file_directory_conflicts_are_refused() { + for fixture in ["parent_case_collision.zip", "file_directory_collision.zip"] { + let (_root, _destination, result) = extract_fixture(fixture); + assert!(result.is_err(), "accepted fixture {fixture}"); + } +} + +#[test] +fn canonically_equivalent_unicode_components_are_refused_portably() { + let mut trie = PathNode::default(); + let mut key_bytes = 0; + assert!( + trie.insert( + Path::new("é/one"), + false, + &mut key_bytes, + MAX_PATH_METADATA_BYTES + ) + .unwrap() + ); + assert!( + !trie + .insert( + Path::new("e\u{301}/two"), + false, + &mut key_bytes, + MAX_PATH_METADATA_BYTES, + ) + .unwrap() + ); + + let mut trie = PathNode::default(); + let mut key_bytes = 0; + assert!( + trie.insert( + Path::new("dir/café"), + false, + &mut key_bytes, + MAX_PATH_METADATA_BYTES, + ) + .unwrap() + ); + assert!( + !trie + .insert( + Path::new("dir/cafe\u{301}"), + false, + &mut key_bytes, + MAX_PATH_METADATA_BYTES, + ) + .unwrap() + ); + + for fixture in [ + "canonical_parent_collision.zip", + "canonical_leaf_collision.zip", + ] { + let (_root, _destination, result) = extract_fixture(fixture); + assert!( + result.is_err(), + "accepted canonical alias fixture {fixture}" + ); + } +} + +#[test] +fn full_unicode_casefold_aliases_are_refused_portably() { + let mut trie = PathNode::default(); + let mut key_bytes = 0; + assert!( + trie.insert( + Path::new("dir/Straße"), + false, + &mut key_bytes, + MAX_PATH_METADATA_BYTES, + ) + .unwrap() + ); + assert!( + !trie + .insert( + Path::new("dir/STRASSE"), + false, + &mut key_bytes, + MAX_PATH_METADATA_BYTES, + ) + .unwrap() + ); + + let (_root, _destination, result) = extract_fixture("casefold_collision.zip"); + assert!(result.is_err()); +} + +#[test] +fn distinct_unicode_paths_keep_their_archive_spelling() { + let decomposed = "cafe\u{301}/module.so"; + let (path, _is_dir, _components) = checked_path(decomposed, MAX_PATH_DEPTH).unwrap(); + assert_eq!(path, PathBuf::from(decomposed)); + + let (_root, destination, result) = extract_fixture("unicode_paths.zip"); + result.unwrap(); + assert_eq!( + fs::read(destination.join("café/module.so")).unwrap(), + b"accented" + ); + assert_eq!( + fs::read(destination.join("数据/module.so")).unwrap(), + b"cjk" + ); +} + +#[test] +fn casefold_key_expansion_counts_toward_the_path_metadata_limit() { + let mut trie = PathNode::default(); + let mut metadata_bytes = 0; + let result = trie.insert(Path::new("İ"), false, &mut metadata_bytes, 2); + assert!(result.is_err()); + assert!(trie.children.is_empty()); +} + +#[test] +fn link_and_special_mode_metadata_never_create_links_or_privileges() { + let (_root, destination, result) = extract_fixture("unix_link_mode.zip"); + assert!(result.is_err(), "symbolic-link metadata must be refused"); + assert!(!destination.join("link").exists()); + + let mut special = include_bytes!("fixtures/compat_store.zip").to_vec(); + let central = special + .windows(4) + .position(|signature| signature == b"PK\x01\x02") + .unwrap(); + // Mark the first regular entry as a Unix FIFO. The extractor must reject + // the metadata before materializing the entry as a regular file. + special[central + 4..central + 6].copy_from_slice(&((3_u16 << 8) | 20).to_le_bytes()); + special[central + 38..central + 42].copy_from_slice(&(0o010644_u32 << 16).to_le_bytes()); + let (_root, destination, result) = extract_fixture_bytes(&special); + assert!(result.is_err(), "special-file metadata must be refused"); + assert!(fs::read_dir(destination).unwrap().next().is_none()); + + let (_root, destination, result) = extract_fixture("suid_mode.zip"); + result.unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(destination.join("module.so")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o6000, 0, "setuid and setgid bits are not restored"); + } +} + +#[test] +fn an_archive_entry_cannot_overwrite_the_host_created_archive() { + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("staging"); + fs::create_dir(&destination).unwrap(); + let archive = destination.join("fixture.zip"); + fs::write(&archive, include_bytes!("fixtures/archive_collision.zip")).unwrap(); + let original = fs::read(&archive).unwrap(); + + assert!(extract(&archive, &destination).is_err()); + assert_eq!(fs::read(&archive).unwrap(), original); +} + +#[test] +fn inflated_output_is_bounded_even_when_the_archive_is_small() { + let root = tempfile::tempdir().unwrap(); + let archive = root.path().join("fixture.zip"); + let destination = root.path().join("staging"); + fs::create_dir(&destination).unwrap(); + fs::write( + &archive, + include_bytes!("fixtures/compat_deflate_descriptor.zip"), + ) + .unwrap(); + + let result = extract_with_limits( + &archive, + &destination, + ExtractionLimits { + entry_bytes: 8, + total_bytes: 16, + entries: 8, + ..DEFAULT_LIMITS + }, + ); + assert!(result.is_err()); + assert!(!destination.join("folder/module.so").exists()); +} + +#[test] +fn path_metadata_budget_is_checked_before_output_creation() { + let root = tempfile::tempdir().unwrap(); + let archive = root.path().join("fixture.zip"); + let destination = root.path().join("staging"); + fs::create_dir(&destination).unwrap(); + fs::write(&archive, include_bytes!("fixtures/compat_store.zip")).unwrap(); + + let result = extract_with_limits( + &archive, + &destination, + ExtractionLimits { + path_metadata_bytes: 4, + ..DEFAULT_LIMITS + }, + ); + assert!(result.is_err()); + assert!(fs::read_dir(destination).unwrap().next().is_none()); +} + +#[test] +fn path_component_budget_is_checked_before_trie_insertion() { + let root = tempfile::tempdir().unwrap(); + let archive = root.path().join("fixture.zip"); + let destination = root.path().join("staging"); + fs::create_dir(&destination).unwrap(); + fs::write(&archive, include_bytes!("fixtures/compat_store.zip")).unwrap(); + + let result = extract_with_limits( + &archive, + &destination, + ExtractionLimits { + path_components: 1, + ..DEFAULT_LIMITS + }, + ); + assert!(result.is_err()); + assert!(fs::read_dir(destination).unwrap().next().is_none()); +} + +#[test] +fn entry_count_and_expanded_bytes_limits_fail_before_extracting() { + for limits in [ + ExtractionLimits { + entries: 0, + ..DEFAULT_LIMITS + }, + ExtractionLimits { + entry_bytes: 1, + ..DEFAULT_LIMITS + }, + ExtractionLimits { + total_bytes: 1, + ..DEFAULT_LIMITS + }, + ] { + let root = tempfile::tempdir().unwrap(); + let archive = root.path().join("fixture.zip"); + let destination = root.path().join("staging"); + fs::create_dir(&destination).unwrap(); + fs::write(&archive, include_bytes!("fixtures/one_module.zip")).unwrap(); + + assert!(extract_with_limits(&archive, &destination, limits).is_err()); + assert!(fs::read_dir(destination).unwrap().next().is_none()); + } +} + +#[test] +fn an_entry_limit_applies_to_the_observed_count_not_only_the_eocd_hint() { + let mut bytes = include_bytes!("fixtures/compat_store.zip").to_vec(); + let eocd = bytes + .windows(4) + .position(|signature| signature == b"PK\x05\x06") + .unwrap(); + bytes[eocd + 10..eocd + 12].copy_from_slice(&1u16.to_le_bytes()); + let (_root, _destination, result) = extract_fixture_bytes_with_limits( + &bytes, + ExtractionLimits { + entries: 1, + ..DEFAULT_LIMITS + }, + ); + assert!(result.is_err()); +} + +#[test] +fn corrupt_central_crc_is_refused_without_promoting_partial_output() { + let root = tempfile::tempdir().unwrap(); + let archive = root.path().join("fixture.zip"); + let destination = root.path().join("staging"); + fs::create_dir(&destination).unwrap(); + let mut bytes = include_bytes!("fixtures/compat_store.zip").to_vec(); + let central = bytes + .windows(4) + .position(|window| window == b"PK\x01\x02") + .unwrap(); + bytes[central + 16] ^= 0x80; + bytes[14] ^= 0x80; + fs::write(&archive, bytes).unwrap(); + + assert!(extract(&archive, &destination).is_err()); +} + +#[test] +fn central_and_local_size_claims_must_match_the_decompressed_bytes() { + let mut bytes = include_bytes!("fixtures/one_module.zip").to_vec(); + set_member_size(&mut bytes, "module.so", 6); + let (_root, _destination, result) = extract_fixture_bytes(&bytes); + assert!(result.is_err()); +} + +#[test] +fn central_and_local_names_must_agree() { + let mut bytes = include_bytes!("fixtures/one_module.zip").to_vec(); + let central = bytes + .windows(4) + .position(|signature| signature == b"PK\x01\x02") + .unwrap(); + let name = central + 46; + bytes[name..name + 9].copy_from_slice(b"module.xx"); + let (_root, _destination, result) = extract_fixture_bytes(&bytes); + assert!(result.is_err()); +} + +#[test] +fn central_compressed_size_must_match_the_local_data_range() { + let mut bytes = include_bytes!("fixtures/one_module.zip").to_vec(); + set_central_compressed_size(&mut bytes, "module.so", 8); + let (_root, _destination, result) = extract_fixture_bytes(&bytes); + assert!(result.is_err()); +} + +#[test] +fn local_headers_must_match_central_metadata() { + let mut ordinary = include_bytes!("fixtures/one_module.zip").to_vec(); + set_local_crc(&mut ordinary, "module.so", 1); + let (_root, _destination, result) = extract_fixture_bytes(&ordinary); + assert!(result.is_err()); + + let mut streaming = include_bytes!("fixtures/compat_deflate_descriptor.zip").to_vec(); + set_local_crc(&mut streaming, "folder/module.so", 1); + let (_root, _destination, result) = extract_fixture_bytes(&streaming); + assert!(result.is_err()); +} + +#[test] +fn reserved_flags_are_refused() { + let mut bytes = include_bytes!("fixtures/one_module.zip").to_vec(); + set_member_flags(&mut bytes, "module.so", 0x10); + let (_root, _destination, result) = extract_fixture_bytes(&bytes); + assert!(result.is_err()); +} + +#[test] +fn crc_zero_is_still_verified_for_an_empty_file() { + let root = tempfile::tempdir().unwrap(); + let archive = root.path().join("fixture.zip"); + let destination = root.path().join("staging"); + fs::create_dir(&destination).unwrap(); + let mut bytes = include_bytes!("fixtures/compat_store.zip").to_vec(); + set_member_crc(&mut bytes, "empty", 1); + fs::write(&archive, bytes).unwrap(); + + assert!(extract(&archive, &destination).is_err()); +} + +#[test] +fn corrupt_crc_never_promotes_partial_cache_staging() { + let mut bytes = include_bytes!("fixtures/compat_store.zip").to_vec(); + set_member_crc(&mut bytes, "empty", 1); + + let (root, result, staging_path, prior_module_was_extracted) = + extract_through_cache_staging(&bytes); + assert!(result.is_err()); + assert!(prior_module_was_extracted); + assert!(!staging_path.unwrap().exists()); + assert_eq!(fs::read_dir(root.path().join("cache")).unwrap().count(), 0); +} + +#[test] +fn data_descriptor_must_match_the_central_directory() { + let root = tempfile::tempdir().unwrap(); + let archive = root.path().join("fixture.zip"); + let destination = root.path().join("staging"); + fs::create_dir(&destination).unwrap(); + let mut bytes = include_bytes!("fixtures/compat_deflate_descriptor.zip").to_vec(); + let descriptor = bytes + .windows(4) + .position(|window| window == b"PK\x07\x08") + .unwrap(); + bytes[descriptor + 4] ^= 0x80; + fs::write(&archive, bytes).unwrap(); + + assert!(extract(&archive, &destination).is_err()); + assert!(!destination.join("folder/module.so").exists()); +} + +fn set_member_crc(bytes: &mut [u8], name: &str, crc: u32) { + let mut offset = 0; + while let Some(relative) = bytes[offset..] + .windows(4) + .position(|sig| sig == b"PK\x03\x04") + { + let local = offset + relative; + let name_len = u16::from_le_bytes([bytes[local + 26], bytes[local + 27]]) as usize; + let name_start = local + 30; + if &bytes[name_start..name_start + name_len] == name.as_bytes() { + bytes[local + 14..local + 18].copy_from_slice(&crc.to_le_bytes()); + break; + } + offset = name_start + name_len; + } + + let mut offset = 0; + while let Some(relative) = bytes[offset..] + .windows(4) + .position(|sig| sig == b"PK\x01\x02") + { + let central = offset + relative; + let name_len = u16::from_le_bytes([bytes[central + 28], bytes[central + 29]]) as usize; + let name_start = central + 46; + if &bytes[name_start..name_start + name_len] == name.as_bytes() { + bytes[central + 16..central + 20].copy_from_slice(&crc.to_le_bytes()); + return; + } + offset = name_start + name_len; + } + panic!("fixture entry not found: {name}"); +} + +fn set_member_size(bytes: &mut [u8], name: &str, size: u32) { + let mut offset = 0; + while let Some(relative) = bytes[offset..] + .windows(4) + .position(|sig| sig == b"PK\x03\x04") + { + let local = offset + relative; + let name_len = u16::from_le_bytes([bytes[local + 26], bytes[local + 27]]) as usize; + let name_start = local + 30; + if &bytes[name_start..name_start + name_len] == name.as_bytes() { + bytes[local + 22..local + 26].copy_from_slice(&size.to_le_bytes()); + break; + } + offset = name_start + name_len; + } + + let mut offset = 0; + while let Some(relative) = bytes[offset..] + .windows(4) + .position(|sig| sig == b"PK\x01\x02") + { + let central = offset + relative; + let name_len = u16::from_le_bytes([bytes[central + 28], bytes[central + 29]]) as usize; + let name_start = central + 46; + if &bytes[name_start..name_start + name_len] == name.as_bytes() { + bytes[central + 24..central + 28].copy_from_slice(&size.to_le_bytes()); + return; + } + offset = name_start + name_len; + } + panic!("fixture entry not found: {name}"); +} + +fn set_local_crc(bytes: &mut [u8], name: &str, crc: u32) { + let mut offset = 0; + while let Some(relative) = bytes[offset..] + .windows(4) + .position(|sig| sig == b"PK\x03\x04") + { + let local = offset + relative; + let name_len = u16::from_le_bytes([bytes[local + 26], bytes[local + 27]]) as usize; + let name_start = local + 30; + if &bytes[name_start..name_start + name_len] == name.as_bytes() { + bytes[local + 14..local + 18].copy_from_slice(&crc.to_le_bytes()); + return; + } + offset = name_start + name_len; + } + panic!("fixture entry not found: {name}"); +} + +fn set_central_compressed_size(bytes: &mut [u8], name: &str, size: u32) { + let mut offset = 0; + while let Some(relative) = bytes[offset..] + .windows(4) + .position(|sig| sig == b"PK\x01\x02") + { + let central = offset + relative; + let name_len = u16::from_le_bytes([bytes[central + 28], bytes[central + 29]]) as usize; + let name_start = central + 46; + if &bytes[name_start..name_start + name_len] == name.as_bytes() { + bytes[central + 20..central + 24].copy_from_slice(&size.to_le_bytes()); + return; + } + offset = name_start + name_len; + } + panic!("fixture entry not found: {name}"); +} + +fn set_member_flags(bytes: &mut [u8], name: &str, flags: u16) { + let mut offset = 0; + while let Some(relative) = bytes[offset..] + .windows(4) + .position(|sig| sig == b"PK\x03\x04") + { + let local = offset + relative; + let name_len = u16::from_le_bytes([bytes[local + 26], bytes[local + 27]]) as usize; + let name_start = local + 30; + if &bytes[name_start..name_start + name_len] == name.as_bytes() { + bytes[local + 6..local + 8].copy_from_slice(&flags.to_le_bytes()); + break; + } + offset = name_start + name_len; + } + + let mut offset = 0; + while let Some(relative) = bytes[offset..] + .windows(4) + .position(|sig| sig == b"PK\x01\x02") + { + let central = offset + relative; + let name_len = u16::from_le_bytes([bytes[central + 28], bytes[central + 29]]) as usize; + let name_start = central + 46; + if &bytes[name_start..name_start + name_len] == name.as_bytes() { + bytes[central + 8..central + 10].copy_from_slice(&flags.to_le_bytes()); + return; + } + offset = name_start + name_len; + } + panic!("fixture entry not found: {name}"); +} diff --git a/crates/tinybus/src/proxy.rs b/crates/tinybus/src/proxy.rs index ccdca469..cb9daf0b 100644 --- a/crates/tinybus/src/proxy.rs +++ b/crates/tinybus/src/proxy.rs @@ -237,6 +237,10 @@ impl Proxy { pub fn connection(&self) -> &Connection { &self.connection } + + pub(crate) fn timeout(&self) -> Duration { + self.timeout + } } impl std::fmt::Debug for Proxy { diff --git a/crates/tinybus/src/router.rs b/crates/tinybus/src/router.rs index b0741851..f3ba60bf 100644 --- a/crates/tinybus/src/router.rs +++ b/crates/tinybus/src/router.rs @@ -226,6 +226,31 @@ pub(crate) struct NameChange { } impl Router { + /// Whether a broker-minted unique peer remains attached under that exact + /// identity. Well-known aliases never satisfy this check. + #[cfg(feature = "modules")] + pub(crate) fn has_unique_peer(&self, unique: &BusName) -> bool { + self.names + .get(unique) + .and_then(|id| self.peers.get(id)) + .is_some_and(|peer| peer.unique == *unique) + } + + /// Verify that `id` is still attached under the unique identity stamped by + /// the broker for that connection. + pub(crate) fn ensure_peer_attached(&self, id: u64, unique: &BusName) -> Result<()> { + let attached = self.names.get(unique) == Some(&id) + && self + .peers + .get(&id) + .is_some_and(|peer| peer.unique == *unique); + if attached { + Ok(()) + } else { + Err(Error::NameHasNoOwner(unique.clone())) + } + } + /// Attach a peer and mint its unique name. pub fn attach(&mut self, outbox: mpsc::Sender) -> (u64, BusName) { // Ids start at 1 and are never reused, so a stale reply addressed to a @@ -250,9 +275,10 @@ impl Router { /// Detach a peer, releasing every name it owned. /// - /// Returns one [`NameChange`] per released well-known name so the broker - /// can emit `NameOwnerChanged`. That signal is how the kernel learns an - /// integration died without polling it. + /// Returns one [`NameChange`] for the peer's unique name and each released + /// well-known name so the broker can emit `NameOwnerChanged`. The unique + /// transition lets observers detect peer death without polling; alias + /// transitions still report service ownership changes. pub fn detach(&mut self, id: u64) -> Vec { let Some(peer) = self.peers.remove(&id) else { return Vec::new(); @@ -275,21 +301,21 @@ impl Router { let mut changes = Vec::new(); for name in owned { self.names.remove(&name); - if !name.is_unique() { - changes.push(NameChange { - name, - old_owner: Some(peer.unique.clone()), - new_owner: None, - }); - } + changes.push(NameChange { + name, + old_owner: Some(peer.unique.clone()), + new_owner: None, + }); } changes } #[cfg(feature = "modules")] - pub(crate) fn detach_unique(&mut self, unique: &BusName) { + pub(crate) fn detach_unique(&mut self, unique: &BusName) -> Vec { if let Some(id) = self.names.get(unique).copied() { - self.detach(id); + self.detach(id) + } else { + Vec::new() } } @@ -355,9 +381,26 @@ impl Router { self.request_name(id, name) } + #[cfg(feature = "modules")] + pub(crate) fn release_name_for_unique( + &mut self, + unique: &BusName, + name: &BusName, + ) -> Result { + let id = *self + .names + .get(unique) + .ok_or_else(|| Error::NameHasNoOwner(unique.clone()))?; + self.ensure_peer_attached(id, unique)?; + self.release_name(id, name) + } + /// Give up a well-known name. Releasing a name you do not own is an error, /// not a no-op — it always means the caller's model of the bus is wrong. pub fn release_name(&mut self, id: u64, name: &BusName) -> Result { + if name.is_unique() { + return Err(Error::protocol("a unique name cannot be released")); + } match self.names.get(name) { Some(owner) if *owner == id => { self.names.remove(name); @@ -551,6 +594,22 @@ impl Router { } } + /// Resolve a call only while its sender remains attached as `source`. + /// Callers hold the router lock across this check and destination lookup, + /// making detach and route admission one ordered table operation. + pub(crate) fn resolve_call_from( + &self, + from: u64, + source: &BusName, + destination: &BusName, + expected: Option<&BusName>, + confidential: bool, + header: &crate::message::Header, + ) -> Result> { + self.ensure_peer_attached(from, source)?; + self.resolve_call(destination, expected, confidential, header) + } + #[cfg(feature = "modules")] pub(crate) fn add_host_callbacks( &mut self, @@ -609,6 +668,30 @@ impl Router { self.resolve(destination) } + #[cfg(feature = "modules")] + pub(crate) fn directed_signal_target_from( + &self, + from: u64, + source: &BusName, + destination: &BusName, + header: &crate::message::Header, + ) -> Result> { + self.ensure_peer_attached(from, source)?; + self.directed_signal_target(source, destination, header) + } + + /// Select signal recipients only while the stamped sender remains + /// attached. The caller keeps the router lock through both operations. + pub(crate) fn subscribers_from( + &self, + signal: &Message, + from: u64, + source: &BusName, + ) -> Result>> { + self.ensure_peer_attached(from, source)?; + Ok(self.subscribers(signal, from)) + } + /// The outbox of whoever owns `destination`. pub fn resolve(&self, destination: &BusName) -> Result> { let id = self diff --git a/crates/tinybus/src/router_tests.rs b/crates/tinybus/src/router_tests.rs index 22b8d089..af6258e0 100644 --- a/crates/tinybus/src/router_tests.rs +++ b/crates/tinybus/src/router_tests.rs @@ -128,10 +128,20 @@ fn detaching_frees_the_names_and_reports_the_change() { router.request_name(a, name.clone()).unwrap(); let changes = router.detach(a); - assert_eq!(changes.len(), 1, "only the well-known name is announced"); - assert_eq!(changes[0].name, name); - assert_eq!(changes[0].old_owner, Some(a_unique)); - assert!(changes[0].new_owner.is_none()); + assert_eq!( + changes.len(), + 2, + "the alias and unique peer name are announced" + ); + let alias_change = changes.iter().find(|change| change.name == name).unwrap(); + assert_eq!(alias_change.old_owner, Some(a_unique.clone())); + assert!(alias_change.new_owner.is_none()); + let unique_change = changes + .iter() + .find(|change| change.name == a_unique) + .unwrap(); + assert_eq!(unique_change.old_owner, Some(a_unique)); + assert!(unique_change.new_owner.is_none()); assert!(router.list_names().is_empty()); // ...and a call to the dead integration now names it, rather than @@ -140,6 +150,28 @@ fn detaching_frees_the_names_and_reports_the_change() { assert!(err.to_string().contains("no peer owns"), "{err}"); } +#[test] +fn detaching_reports_the_unique_name_as_peer_death() { + let mut router = Router::default(); + let (peer, unique) = router.attach(outbox()); + + let changes = router.detach(peer); + + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].name, unique); + assert_eq!(changes[0].old_owner, Some(unique)); + assert!(changes[0].new_owner.is_none()); +} + +#[test] +fn a_live_peer_cannot_release_its_unique_name() { + let mut router = Router::default(); + let (peer, unique) = router.attach(outbox()); + + assert!(router.release_name(peer, &unique).is_err()); + assert_eq!(router.owner_of(&unique), Some(unique)); +} + #[test] fn the_bus_name_and_unique_names_cannot_be_claimed() { let mut router = Router::default(); @@ -202,6 +234,7 @@ fn attestation(name: &str) -> Attestation { Attestation { name: BusName::new(name).unwrap(), sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(), + library_sha256: None, } } diff --git a/crates/tinybus/src/service/mod.rs b/crates/tinybus/src/service/mod.rs index 807b5b9d..ccd0555f 100644 --- a/crates/tinybus/src/service/mod.rs +++ b/crates/tinybus/src/service/mod.rs @@ -26,6 +26,7 @@ use serde_json::Value; use crate::error::Result; use crate::name::{BusName, InterfaceName, MemberName}; +use crate::{WeakConnection, connection::ConnectionOrigin}; pub use crate::service::tree::ObjectTree; @@ -45,10 +46,21 @@ pub use crate::service::tree::ObjectTree; /// ```compile_fail /// let _: tinybus::CallContext = serde_json::from_str("{}").unwrap(); /// ``` -#[derive(Debug, Clone, Default)] +#[derive(Clone, Default)] pub struct CallContext { sender: Option, confidential: bool, + receive_origin: Option>, +} + +impl std::fmt::Debug for CallContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CallContext") + .field("sender", &self.sender) + .field("confidential", &self.confidential) + .field("has_receive_origin", &self.receive_origin.is_some()) + .finish() + } } impl CallContext { @@ -67,10 +79,31 @@ impl CallContext { self.confidential } + /// Whether this call was received by `connection` itself. + /// + /// The connection identity is opaque and compared through weak allocation + /// identity. Context captured from another bus connection cannot match even + /// when both brokers assigned the same unique sender name. + pub fn is_for_connection(&self, connection: &WeakConnection) -> bool { + self.receive_origin + .as_ref() + .is_some_and(|origin| connection.matches_origin(origin)) + } + + #[cfg(test)] pub(crate) fn brokered(sender: Option<&BusName>, confidential: bool) -> Self { + Self::brokered_with_origin(sender, confidential, None) + } + + pub(crate) fn brokered_with_origin( + sender: Option<&BusName>, + confidential: bool, + receive_origin: Option<&std::sync::Arc>, + ) -> Self { Self { sender: sender.filter(|name| name.is_unique()).cloned(), confidential: confidential && sender.is_some_and(BusName::is_unique), + receive_origin: receive_origin.map(std::sync::Arc::downgrade), } } } diff --git a/crates/tinybus/src/service/mod_tests.rs b/crates/tinybus/src/service/mod_tests.rs index b0d346de..623ebf54 100644 --- a/crates/tinybus/src/service/mod_tests.rs +++ b/crates/tinybus/src/service/mod_tests.rs @@ -1,5 +1,97 @@ use super::*; +#[test] +fn call_context_debug_exposes_only_its_sender_and_routing_facts() { + let empty = CallContext::default(); + let empty_debug = format!("{empty:?}"); + assert!(empty_debug.contains("sender: None")); + assert!(empty_debug.contains("confidential: false")); + assert!(empty_debug.contains("has_receive_origin: false")); + + let sender = BusName::new(":1.42").unwrap(); + let authenticated = CallContext::brokered(Some(&sender), true); + let authenticated_debug = format!("{authenticated:?}"); + assert!(authenticated_debug.contains(":1.42")); + assert!(authenticated_debug.contains("confidential: true")); + assert!(authenticated_debug.contains("has_receive_origin: false")); +} + +struct ArcForwardingInterface; + +#[async_trait::async_trait] +impl Interface for ArcForwardingInterface { + fn name(&self) -> InterfaceName { + InterfaceName::new("org.example.ArcForwarding").unwrap() + } + + fn members(&self) -> Vec { + vec![MemberName::new("Inspect").unwrap()] + } + + fn requires_confidential(&self, _: &MemberName) -> bool { + true + } + + async fn call(&self, member: &MemberName, _: Value) -> Result { + Ok(Value::String(member.as_str().to_owned())) + } + + async fn call_with_context( + &self, + member: &MemberName, + _: Value, + context: &CallContext, + ) -> Result { + Ok(serde_json::json!([ + member.as_str(), + context.is_confidential() + ])) + } +} + +#[tokio::test] +async fn arc_interface_forwards_legacy_and_contextual_dispatch() { + let interface = std::sync::Arc::new(ArcForwardingInterface); + let member = MemberName::new("Inspect").unwrap(); + assert_eq!( + as Interface>::name(&interface), + InterfaceName::new("org.example.ArcForwarding").unwrap() + ); + assert_eq!( + as Interface>::members(&interface), + vec![member.clone()] + ); + assert!( + as Interface>::requires_confidential( + &interface, &member + ) + ); + assert_eq!( + as Interface>::call( + &interface, + &member, + Value::Null + ) + .await + .unwrap(), + Value::String("Inspect".to_owned()) + ); + + let sender = BusName::new(":1.42").unwrap(); + let context = CallContext::brokered(Some(&sender), true); + assert_eq!( + as Interface>::call_with_context( + &interface, + &member, + Value::Null, + &context + ) + .await + .unwrap(), + serde_json::json!(["Inspect", true]) + ); +} + #[test] fn only_a_brokered_unique_sender_is_authenticated() { assert!(CallContext::default().authenticated_sender().is_none()); diff --git a/crates/tinybus/src/service/tree.rs b/crates/tinybus/src/service/tree.rs index 4ad8ca32..0a2f11f9 100644 --- a/crates/tinybus/src/service/tree.rs +++ b/crates/tinybus/src/service/tree.rs @@ -19,7 +19,15 @@ use crate::service::{CallContext, Interface}; /// Everything one connection exports. #[derive(Default)] pub struct ObjectTree { - objects: HashMap>>, + objects: HashMap>, + next_registration: u64, +} + +struct ObjectRegistration { + #[cfg_attr(not(feature = "modules"), allow(dead_code))] + id: u64, + name: InterfaceName, + interface: Arc, } impl ObjectTree { @@ -37,28 +45,71 @@ impl ObjectTree { /// order-dependent. pub fn insert(&mut self, path: ObjectPath, interface: Arc) { let name = interface.name(); - let entry = self.objects.entry(path).or_default(); - entry.retain(|existing| existing.name() != name); - entry.push(interface); + let (_, replaced) = self.insert_owned(path, name, interface); + drop(replaced); } - /// Export `interface` at `path` only when the complete path is vacant. - /// - /// Unlike [`Self::insert`], this never replaces a live implementation. The - /// caller holds the connection's object-tree write lock across this check - /// and insert, so concurrent registrations cannot both claim one address. - pub(crate) fn insert_if_absent( - &mut self, - path: ObjectPath, - interface: Arc, - ) -> bool { + /// Export `interface` at `path` only if no interface is already served there. + pub fn insert_if_absent(&mut self, path: ObjectPath, interface: Arc) -> bool { if self.objects.contains_key(&path) { return false; } - self.objects.insert(path, vec![interface]); + self.insert(path, interface); true } + /// Export `interface` and return the identity of this exact registration. + /// A later replacement at the same path and interface gets a different + /// identity, so an older owner cannot unregister the newer implementation. + pub(crate) fn insert_owned( + &mut self, + path: ObjectPath, + name: InterfaceName, + interface: Arc, + ) -> (u64, Vec>) { + self.next_registration = self + .next_registration + .checked_add(1) + .expect("object registration identity exhausted"); + let id = self.next_registration; + let entry = self.objects.entry(path).or_default(); + let mut replaced = Vec::new(); + let mut retained = Vec::with_capacity(entry.len() + 1); + for existing in entry.drain(..) { + if existing.name == name { + replaced.push(existing.interface); + } else { + retained.push(existing); + } + } + *entry = retained; + entry.push(ObjectRegistration { + id, + name, + interface, + }); + (id, replaced) + } + + /// Remove one interface only if it is still the same registration. + #[cfg(feature = "modules")] + pub(crate) fn take_if_owned( + &mut self, + path: &ObjectPath, + interface: &InterfaceName, + id: u64, + ) -> Option> { + let entries = self.objects.get_mut(path)?; + let index = entries + .iter() + .position(|entry| entry.id == id && entry.name == *interface)?; + let removed = entries.remove(index).interface; + if entries.is_empty() { + self.objects.remove(path); + } + Some(removed) + } + /// Stop exporting everything at `path`. Returns whether anything went away. pub fn remove(&mut self, path: &ObjectPath) -> bool { self.objects.remove(path).is_some() @@ -75,7 +126,7 @@ impl ObjectTree { pub fn interfaces_at(&self, path: &ObjectPath) -> Vec { self.objects .get(path) - .map(|list| list.iter().map(|i| i.name()).collect()) + .map(|list| list.iter().map(|entry| entry.name.clone()).collect()) .unwrap_or_default() } @@ -91,8 +142,8 @@ impl ObjectTree { .get(path) .ok_or_else(|| Error::UnknownObject { path: path.clone() })?; list.iter() - .find(|i| &i.name() == interface) - .cloned() + .find(|entry| &entry.name == interface) + .map(|entry| entry.interface.clone()) .ok_or_else(|| Error::UnknownInterface { path: path.clone(), interface: interface.clone(), diff --git a/crates/tinybus/src/stream/base64.rs b/crates/tinybus/src/stream/base64.rs index 6879f6b7..9c170461 100644 --- a/crates/tinybus/src/stream/base64.rs +++ b/crates/tinybus/src/stream/base64.rs @@ -42,7 +42,7 @@ pub fn encode(bytes: &[u8]) -> String { /// definition, and this message travels back to the peer as an error reply. pub fn decode(text: &str) -> Result> { let bytes = text.as_bytes(); - if !bytes.len().is_multiple_of(4) { + if bytes.len() % 4 != 0 { return Err(Error::protocol( "base64 chunk length is not a multiple of four", )); diff --git a/crates/tinybus/src/stream/mod.rs b/crates/tinybus/src/stream/mod.rs index 876f9e7a..a8a3a6ac 100644 --- a/crates/tinybus/src/stream/mod.rs +++ b/crates/tinybus/src/stream/mod.rs @@ -261,6 +261,8 @@ pub(crate) struct StreamRegistry { limits: std::sync::RwLock, inbound: std::sync::Mutex>>, next_id: AtomicU64, + #[cfg(test)] + after_close_seal: std::sync::Mutex>>, } impl StreamRegistry { @@ -269,6 +271,8 @@ impl StreamRegistry { limits: std::sync::RwLock::new(StreamLimits::default()), inbound: std::sync::Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), + #[cfg(test)] + after_close_seal: std::sync::Mutex::new(None), } } @@ -487,15 +491,29 @@ impl StreamRegistry { // the *fast* case — into "no such stream". let received = stream.received.load(Ordering::Relaxed); stream.touch(); + let truncated = received != total_len; + if truncated { + stream.finish(Outcome::Aborted("the sender closed a truncated stream")); + } else { + stream.finish(Outcome::Complete); + } stream.seal(); - if received != total_len { - stream.finish(Outcome::Aborted("the sender closed a truncated stream")); + #[cfg(test)] + if let Some(hook) = self + .after_close_seal + .lock() + .expect("stream close test hook lock") + .take() + { + hook(); + } + + if truncated { return Err(Error::protocol(format!( "stream closed after {received} bytes, {total_len} declared" ))); } - stream.finish(Outcome::Complete); Ok(Value::Null) } @@ -692,6 +710,15 @@ impl StreamWriter { } } + /// The peer that accepted this stream's `Open` request. + /// + /// A method carrying this writer's handle must use the same destination; + /// resolving a well-known name again could select a different peer after a + /// name handover. + pub(crate) fn destination(&self) -> &BusName { + &self.destination + } + /// How many bytes have been accepted by the receiver so far. pub fn sent(&self) -> u64 { self.sent diff --git a/crates/tinybus/src/stream/stream_tests.rs b/crates/tinybus/src/stream/stream_tests.rs index ef54ed2f..ffc45e75 100644 --- a/crates/tinybus/src/stream/stream_tests.rs +++ b/crates/tinybus/src/stream/stream_tests.rs @@ -18,7 +18,8 @@ use crate::message::Message; use crate::name::{BusName, InterfaceName, MemberName, ObjectPath}; use crate::ports::Transport; use crate::stream::{ - MAX_CHUNK_LEN, STREAM_INTERFACE, STREAM_PATH, StreamDescriptor, StreamLimits, StreamRef, + MAX_CHUNK_LEN, STREAM_INTERFACE, STREAM_PATH, StreamDescriptor, StreamLimits, StreamReader, + StreamRef, StreamRegistry, }; use crate::transport::memory::{MemoryBus, MemoryTransport}; @@ -96,6 +97,92 @@ fn payload(len: usize) -> Vec { (0..len).map(|i| (i % 251) as u8).collect() } +fn stream_header(member: &str, sender: &BusName) -> crate::message::Header { + let mut header = Message::method_call( + BusName::new("ai.tinyhumans.StreamReceiver").unwrap(), + ObjectPath::new(STREAM_PATH).unwrap(), + InterfaceName::new(STREAM_INTERFACE).unwrap(), + MemberName::new(member).unwrap(), + Value::Null, + ) + .header; + header.sender = Some(sender.clone()); + header +} + +async fn close_verdict_is_published_before_reader_observes_eof( + declared_len: u64, + close_len: u64, + expected_terminal: &str, + expected_close_error: Option<&str>, +) { + let registry = Arc::new(StreamRegistry::new()); + let owner = BusName::new("ai.tinyhumans.StreamSender").unwrap(); + let open = registry + .dispatch( + &stream_header("Open", &owner), + serde_json::json!([StreamDescriptor::with_len(declared_len)]), + ) + .await + .unwrap(); + let id = open.as_str().unwrap().to_string(); + let mut reader: StreamReader = registry.take_reader(&id).unwrap(); + registry + .dispatch( + &stream_header("Write", &owner), + serde_json::json!([id.clone(), 0, super::base64::encode(b"abc")]), + ) + .await + .unwrap(); + assert_eq!( + reader.next_chunk().await.unwrap().as_deref(), + Some(b"abc".as_slice()) + ); + + let (close_sealed_tx, close_sealed_rx) = tokio::sync::oneshot::channel(); + let release_close = Arc::new(std::sync::Barrier::new(2)); + let close_release = Arc::clone(&release_close); + *registry + .after_close_seal + .lock() + .expect("stream close test hook lock") = Some(Box::new(move || { + let _ = close_sealed_tx.send(()); + close_release.wait(); + })); + + let close_registry = Arc::clone(®istry); + let close_member = MemberName::new("Close").unwrap(); + let close_header = stream_header("Close", &owner); + let close = tokio::task::spawn_blocking(move || { + close_registry.close( + &close_header, + &close_member, + serde_json::json!([id, close_len]), + ) + }); + + close_sealed_rx + .await + .expect("Close should reach the per-registry scheduling hook"); + let terminal = reader.next_chunk().await; + // Always release and join the paused synchronous Close before asserting, so + // a deliberately failing RED run cannot strand a blocking task. + release_close.wait(); + let close_result = close.await.expect("Close task should complete"); + + let terminal = match terminal { + Ok(None) => "clean EOF".to_string(), + Ok(Some(_)) => "unexpected extra chunk".to_string(), + Err(Error::StreamAborted { reason }) => format!("aborted: {reason}"), + Err(error) => format!("error: {}", error.wire_name()), + }; + assert_eq!(terminal, expected_terminal); + match expected_close_error { + Some(wire_name) => assert_eq!(close_result.unwrap_err().wire_name(), wire_name), + None => assert!(close_result.is_ok()), + } +} + /// A broker, a sink service that owns [`SINK`], and a client. async fn bus() -> (Connection, Connection) { let bus = MemoryBus::new(); @@ -370,6 +457,22 @@ async fn closing_short_of_the_declared_length_is_an_error_not_a_short_read() { assert_eq!(error.wire_name(), "ai.tinyhumans.tinybus.Error.Protocol"); } +#[tokio::test] +async fn a_complete_close_publishes_its_verdict_before_reader_eof() { + close_verdict_is_published_before_reader_observes_eof(3, 3, "clean EOF", None).await; +} + +#[tokio::test] +async fn a_truncated_close_publishes_its_abort_before_reader_eof() { + close_verdict_is_published_before_reader_observes_eof( + 4, + 4, + "aborted: the sender closed a truncated stream", + Some("ai.tinyhumans.tinybus.Error.Protocol"), + ) + .await; +} + #[tokio::test] async fn a_reader_sees_an_error_rather_than_an_eof_when_the_sender_aborts() { let (client, service) = bus().await; diff --git a/crates/tinybus/tests/availability.rs b/crates/tinybus/tests/availability.rs new file mode 100644 index 00000000..255070de --- /dev/null +++ b/crates/tinybus/tests/availability.rs @@ -0,0 +1,22 @@ +use tinybus::module::Availability; + +#[test] +fn public_availability_serialization_is_available_without_the_loader() { + let cases = [ + (Availability::Available, "available"), + (Availability::Loading, "loading"), + (Availability::Ready, "ready"), + (Availability::Failed, "failed"), + (Availability::Unsupported, "unsupported"), + (Availability::Disabled, "disabled"), + ]; + + for (availability, wire_name) in cases { + let json = serde_json::to_string(&availability).unwrap(); + assert_eq!(json, format!("\"{wire_name}\"")); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + availability + ); + } +} diff --git a/crates/tinybus/tests/module_sender_lookup.rs b/crates/tinybus/tests/module_sender_lookup.rs new file mode 100644 index 00000000..5b2aa45d --- /dev/null +++ b/crates/tinybus/tests/module_sender_lookup.rs @@ -0,0 +1,122 @@ +#![cfg(feature = "modules")] + +use std::sync::Arc; +use std::time::Duration; + +use tinybus::Connection; +use tinybus::broker::Broker; +use tinybus::module::{LinkedModule, ModuleHost, ModuleState}; +use tinybus::transport::memory::{MemoryBus, MemoryTransport}; + +mod fixture { + async fn setup(_: tinybus::Connection) -> tinybus::Result<()> { + Ok(()) + } + + tinybus_module::module_export_static! { + setup = setup, + worker_threads = 1, + provides = ["ai.tinyhumans.module.SenderLookupFixture"], + methods = [], + signals = [], + requires = [], + optional = [], + lazy = false, + } +} + +fn linked_module() -> tinybus::Result { + fixture::linked_module() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sender_lookup_resolves_the_admitted_peer_during_initialization() { + let broker = Broker::new_restricted_module_control(); + let broker_task = broker.spawn(MemoryBus::new()); + let (broker_transport, controller_transport) = MemoryTransport::pair(); + broker + .attach_module_controller(Arc::new(broker_transport)) + .unwrap(); + let _controller = Connection::connect(Box::new(controller_transport)) + .await + .unwrap(); + + let host = Arc::new(ModuleHost::new(broker)); + let lookup = host.sender_lookup(); + + // Linked admission hashes the host executable once synchronously. Keep + // that unrelated first-use work outside the explicit setup barrier so the + // deadline measures module startup and broker progress. + let mut hash_warmup = linked_module().unwrap(); + hash_warmup.descriptor = + tinybus::module::abi::TbAbiDescriptor::current("tinybus-sender-lookup-warmup", "0.3.12"); + hash_warmup.manifest.module.name = "tinybus-sender-lookup-warmup".to_owned(); + hash_warmup.manifest.bus_name = "ai.tinyhumans.module.SenderLookupWarmup".parse().unwrap(); + hash_warmup.manifest.object_path = "/ai/tinyhumans/module/SenderLookupWarmup".parse().unwrap(); + hash_warmup.manifest.lazy_init = true; + let warmup_info = host + .attach_linked_with_config(hash_warmup, serde_json::json!({})) + .unwrap(); + assert_eq!(warmup_info.state, ModuleState::Resolved); + assert!( + lookup + .sender_for_module("tinybus-sender-lookup-warmup") + .is_none() + ); + + let (setup_started_tx, setup_started_rx) = tokio::sync::oneshot::channel(); + let (finish_setup_tx, finish_setup_rx) = tokio::sync::oneshot::channel(); + let attach_host = host.clone(); + let attach = tokio::spawn(async move { + attach_host + .attach_linked_with_setup( + linked_module().unwrap(), + serde_json::json!({}), + move |_| async move { + let _ = setup_started_tx.send(()); + finish_setup_rx + .await + .map_err(|_| tinybus::Error::failed("test setup gate closed")) + }, + ) + .await + }); + + tokio::time::timeout(Duration::from_secs(2), setup_started_rx) + .await + .expect("module reached setup") + .expect("setup barrier sender remains live"); + + let module_info = host + .list() + .into_iter() + .find(|info| info.name == "tinybus") + .expect("linked target module was admitted"); + assert_eq!(module_info.state, ModuleState::Initializing); + let sender = lookup + .sender_for_module("tinybus") + .expect("the admitted initializing module has a live unique peer"); + assert!( + lookup + .sender_for_module("ai.tinyhumans.module.SenderLookupFixture") + .is_none() + ); + assert!(sender.is_unique()); + assert_eq!(host.sender_for_module("tinybus"), Some(sender.clone())); + + finish_setup_tx.send(()).unwrap(); + let info = tokio::time::timeout(Duration::from_secs(2), attach) + .await + .expect("module setup becomes ready") + .unwrap() + .unwrap(); + assert!(matches!( + info.state, + ModuleState::Ready | ModuleState::Serving + )); + assert_eq!(lookup.sender_for_module("tinybus"), Some(sender)); + + drop(host); + assert!(lookup.sender_for_module("tinybus").is_none()); + broker_task.abort(); +} diff --git a/docs/modules/attest/README.md b/docs/modules/attest/README.md index ade189ac..c742750c 100644 --- a/docs/modules/attest/README.md +++ b/docs/modules/attest/README.md @@ -105,10 +105,34 @@ attested. `load_github_release`. Before extracting anything, `acquire` fetches the release's own `checksum.toml`, refuses a disagreement between it and the caller's value, downloads the archive, and hashes the bytes it actually received. Only -then is the library extracted and loaded. The attestation records the digest of -that **archive** — the artifact the operator named — not a hash of the extracted -`.so`, which nobody vouched for and which the host would only be computing in -order to trust itself for it. +then is the library extracted and loaded. The attestation keeps that **archive** +digest in `sha256` — the artifact the operator named — and may carry a separately +pinned `library_sha256` for the extracted library. + +Release checksum manifests may publish both values under the same archive key: + +```toml +[sha256] +"clock-linux.tar.gz" = "" + +[library_sha256] +"clock-linux.tar.gz" = "" +``` + +`tinybus modules checksum` writes both values when an archive contains one +module library. A host registry pins the pair in `ReleaseAsset`. Cache hits +re-hash the extracted library against that pin, and the native loader checks it +again at the actual mapping boundary. The checked library digest is bound to the +process-lifetime native handle; the same handle cannot later acquire a different +digest because its path now names different bytes. `ModuleClient::call_attested` +requires the original manifest name and both digests, then sends to the unique +peer whose attestation it checked. Its host readiness hook initializes a lazy +module before the attestation query and sends no probe call. + +The pre-map path check relies on the module directory protections enforced by +host admission. It does not claim to prove code mapped outside TinyBus, survive a +compromised host process, or contain an in-process module that can already read +host memory. Such modules remain inside the host trust boundary. A pinned digest is the stronger of the two statements, because it cannot be edited on the machine that runs it. diff --git a/docs/modules/broker/README.md b/docs/modules/broker/README.md index d2446f0f..9685192d 100644 --- a/docs/modules/broker/README.md +++ b/docs/modules/broker/README.md @@ -30,10 +30,12 @@ indistinguishable from "the integration is slow". ## `NameOwnerChanged` -Emitted when a well-known name is claimed or released, including by a peer -dying. This is how the kernel learns an integration is gone in milliseconds -rather than at the next timeout. Subscribers still have to ask for it; the -broker does not push it at peers that did not. +Emitted when a well-known name is claimed or released, and when a peer detaches +for its unique name. The unique-name transition identifies peer death even +when that peer never claimed an alias. This is how the kernel learns an +integration is gone in milliseconds rather than at the next timeout. +Subscribers still have to ask for it; the broker does not push it at peers +that did not. ## Restricted module control (`modules` feature) diff --git a/docs/modules/message/README.md b/docs/modules/message/README.md index 38bb1a90..01663ce9 100644 --- a/docs/modules/message/README.md +++ b/docs/modules/message/README.md @@ -38,3 +38,10 @@ allocating for it — the length arrives before the bytes do. - An error body is the message *without* the dotted name, which lives in `error_name`. Including it would make an error accrete a prefix each time it crossed the bus. +- Module lifecycle metadata is an optional closed header field on + `MODULE_UNAVAILABLE` errors. `initializing` is preserved as typed state while + the dotted error name and body remain unchanged; older peers ignore the field + and classify the error as unavailable. Unknown lifecycle values are ignored + for lifecycle classification, so a `MODULE_UNAVAILABLE` error keeps its + legacy unavailable meaning. Metadata on another error name or message kind + never changes that message's classification. diff --git a/docs/modules/module/README.md b/docs/modules/module/README.md index cad1f0a6..7397f88a 100644 --- a/docs/modules/module/README.md +++ b/docs/modules/module/README.md @@ -44,6 +44,46 @@ last initialized instance. Linked modules keep the embedding host's tracing and panic hook. The existing instance-wide last panic location is diagnostic state, not a proof of exact attribution among concurrently panicking methods. +## Host runtime leases + +`RuntimeLeaseDriver` is the protocol-neutral contract for a host that reserves +module-side resources and exports one callback through `ModuleRuntime::lease`. +The host validates the opaque reservation, creates its object, supplies an +owned callback factory, then starts the module. The callback factory runs only +after validation and object creation. The returned cloneable `RuntimeLease` +provides `object()` and `close()`; the last user clone starts the same cleanup +worker as explicit close, including when dropped outside Tokio. + +The driver returns `StopOutcome::Acknowledged`, `RetryableFailure`, or +`Unacknowledged`. A retryable failure is safe only after the driver proves the +operation settled. Lost replies, timeouts, malformed responses and faults are +uncertain unless the driver has other authoritative evidence. The SDK retains +the active STOP future across its per-attempt deadline, never overlaps STOP +attempts, and removes the exact callback registration only after ACK. If the +operation remains uncertain or finite retries end, the dedicated runtime parks +the reservation and callback guard without another retry. Caller close waiters +may time out independently; that does not cancel cleanup. + +`ModuleClient::bind` is the ordinary counterpart for owned module operations. +It waits for the module's normal lazy readiness, resolves the current serving +peer, and keeps that peer for root calls, streamed uploads and held object paths. +It supports linked modules and explicit fixtures without claiming archive or +library proof. A peer disconnect makes the held calls fail; they do not move to +a new owner of the public bus name. The proxy's deadline is preserved for owner +lookup and later calls, unless the client supplies an explicit timeout override. +Use this for ordinary Docs and Voice operations; use strict attested calls when +the operation requires native artifact proof or confidential delivery. + +`ModuleClient::bind_attested` applies the same identity rule to module calls. It +returns a `BoundModuleClient` only after the loaded peer proves the registered +archive and library pair. Later ordinary, detailed, confidential, and held +object calls use that same unique peer even after its public bus name changes +owners. If the peer disconnects, those calls fail instead of rebinding. + +The lifecycle contract, retry policy, typed error accessor and content-free +report records compile without the `modules` feature. The runtime lease and its +host-object registration require `modules`. + ## Opted-in terminal factories Configured modules can opt in with `module_export!(terminal_setup_factory = @@ -97,9 +137,10 @@ cannot certify whole-Connection destruction. Each SDK image has 256 instance slots. The existing opaque `module_ctx` contains a slot/generation address token, never a dereferenced pointer or extra ABI allocation. Callback lookup acquires the exact State before releasing registry -exclusion. Successful teardown retires its registry anchor; only actual State -last-Drop releases the slot, so old views and surviving SDK resources count -against the limit. Generations never wrap: exhaustion permanently denies that +exclusion. Successful teardown retires its registry anchor before publishing +physical completion; only actual State last-Drop releases the slot, so old views +and surviving SDK resources count against the limit. +Generations never wrap: exhaustion permanently denies that slot. Null, stale or unknown context tokens return `TB_CLOSED` for otherwise valid terminal callbacks and cannot select a replacement. Legacy shutdown's null-context behavior remains unchanged. @@ -191,6 +232,22 @@ Reinitialization has a mandatory four-second module-side deadline and is serialized with both other reinitializations and shutdown. A host timeout never permits a later lifecycle callback to overlap code that is still returning. +Hosts that choose configuration from each caller can use +`module::ReconfigurableModule` to hash the selected JSON configuration and +reinitialize only when it changes. The coordinator stores a digest, not the +configuration. Its default strategy uses lifecycle reinitialization; a +`Reconfigure::Member("Configure")` strategy calls a module-owned member with +the configuration as its sole argument over a confidential call, so the +recipient must be attested. `retry_while_initializing` retries only a typed +initializing result, within the configured attempt and delay bounds. + +`proxy(config)` serializes configuration transitions but releases its lock +before the caller uses the returned proxy. When a bus call must run under the +selected configuration, hold `exclusive()` through that call and obtain the +proxy with `guard.proxy(config)`. All users of the coordinator must follow this +contract; calling `proxy(config)` while holding its guard waits on the same +lock. + Stopping a module loaded with `HostCallbackBinding` revokes new callback admission and waits for already admitted user futures before starting native shutdown. Both phases share the stop deadline. A drain timeout refuses shutdown; @@ -208,10 +265,13 @@ When present, `modules.toml` is authoritative for its directory. Keys are artifact file names (or stems) and values are lowercase SHA-256 hashes. An artifact absent from the file or with a mismatched hash is refused. For remote loads, the host supplies the expected SHA-256 alongside the artifact URL; -hashing happens before the platform loader opens the artifact. Search -precedence is `OPENHUMAN_MODULE_PATH`, the platform user data directory, then -the platform system directory. `tinybus modules scan --path --dry-run` -performs admission and dependency checks without initializing or attaching. +hashing happens before the platform loader opens the artifact. Search paths are +host-configured through `SearchPathConfig::new(env_var, app_dir)`. When set, +path-list entries from `env_var` come first, followed by the app-specific user +and system data directories for the platform. A `ModuleHost` without this +configuration has no implicit search paths. `tinybus modules scan --path +--dry-run` performs admission and dependency checks without initializing or +attaching. GitHub releases are loaded with `ModuleHost::load_github_release`. The release URL must identify a tag, the selected asset must be a `.tar.gz` or `.zip` archive, and @@ -338,6 +398,35 @@ Factory panics return the native initialization panic code. The factory form is available in module_export, module_export_static and module_export_optional_static; legacy setup callbacks remain supported. +When setup also needs the loader-issued host binding, use +`setup_factory_with_context = factory, config = Config` in either export macro. +Its factory returns `Fn(ModuleSetupContext, Config) -> Future>` and +runs once per ABI instance after host validation and initial config parsing. +The retained callback receives a new context for each initial setup or +reinitialization, so its connection matches the active module attachment while +its captured per-instance mode remains stable. A generic module can see +`context.trusted_host() == None`; a migration service must require the binding +before registering itself or starting ordinary storage access. + +```rust,ignore +fn setup_factory() -> impl Fn(ModuleSetupContext, Config) -> SetupFuture { + let admitted_mode = Arc::new(Mutex::new(None)); + move |context, config| { + // Validate the mode against this factory's retained state, then use + // context.connection() and context.trusted_host() for this attachment. + Box::pin(setup_with_context(context, config, Arc::clone(&admitted_mode))) + } +} + +tinybus_module::module_export! { + setup_factory_with_context = setup_factory, + config = Config, + worker_threads = 1, + provides = ["org.example.Service"], + methods = [], signals = [], requires = [], optional = [], lazy = false, +} +``` + ## Controller admission `ModuleHost::new` uses its supplied broker's immutable module-control policy. diff --git a/docs/protocol.md b/docs/protocol.md index 75fdb240..b6b3392a 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -213,7 +213,9 @@ does `RequestName` for a unique name. When ownership changes, the bus emits `NameOwnerChanged`, with body `[name, old_owner, new_owner]`, either owner being `null`. Unchanged ownership -emits no signal. This is how a peer learns a service died without polling it. +emits no signal. On peer detach, this includes the peer's unique name as well +as every well-known name it owned, so observers can detect a peer that never +claimed an alias without polling it. An embedded module host also exposes the additive module members above. A broker built without the `modules` feature returns `UnknownMethod`; a diff --git a/examples/Cargo.lock b/examples/Cargo.lock index 6cc91dd4..83848a36 100644 --- a/examples/Cargo.lock +++ b/examples/Cargo.lock @@ -260,7 +260,7 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.1.3" +version = "0.3.12" dependencies = [ "async-trait", "serde", @@ -284,7 +284,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.1.3" +version = "0.3.12" dependencies = [ "proc-macro2", "quote", diff --git a/proof-workspaces/msrv-185-client/Cargo.lock b/proof-workspaces/msrv-185-client/Cargo.lock new file mode 100644 index 00000000..00bae4db --- /dev/null +++ b/proof-workspaces/msrv-185-client/Cargo.lock @@ -0,0 +1,372 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinybus" +version = "0.3.12" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "sha2", + "thiserror", + "tinybus-macros", + "tokio", + "tracing", +] + +[[package]] +name = "tinybus-macros" +version = "0.3.12" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinybus-msrv-185-client-proof" +version = "0.0.0" +dependencies = [ + "tinybus", + "tokio", +] + +[[package]] +name = "tokio" +version = "1.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/proof-workspaces/msrv-185-client/Cargo.toml b/proof-workspaces/msrv-185-client/Cargo.toml new file mode 100644 index 00000000..82ec3dea --- /dev/null +++ b/proof-workspaces/msrv-185-client/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] +resolver = "3" + +[package] +name = "tinybus-msrv-185-client-proof" +version = "0.0.0" +edition = "2024" +rust-version = "1.85" +publish = false + +[dependencies] +tinybus = { path = "../../crates/tinybus", version = "=0.3.12" } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/proof-workspaces/msrv-185-client/README.md b/proof-workspaces/msrv-185-client/README.md new file mode 100644 index 00000000..5108178c --- /dev/null +++ b/proof-workspaces/msrv-185-client/README.md @@ -0,0 +1,8 @@ +# Rust 1.85 host-client proof + +This isolated consumer compiles the public TinyBus host/core crate and its +default macros at the declared Rust 1.85 MSRV, then sends a real request through +the in-memory broker and receives the reply through `Proxy`. It intentionally +does not activate the module loader or link the separate `tinybus-module` +runtime; compiled module fixtures continue to run at their declared Rust 1.88 +MSRV in the main workspace. diff --git a/proof-workspaces/msrv-185-client/tests/memory_proxy.rs b/proof-workspaces/msrv-185-client/tests/memory_proxy.rs new file mode 100644 index 00000000..e810b681 --- /dev/null +++ b/proof-workspaces/msrv-185-client/tests/memory_proxy.rs @@ -0,0 +1,39 @@ +use tinybus::{Connection, broker::Broker, transport::memory::MemoryBus}; + +const NAME: &str = "ai.example.msrv.Echo"; +const PATH: &str = "/ai/example/msrv/Echo"; + +struct Echo; + +#[tinybus::interface(name = "ai.example.msrv.Echo")] +impl Echo { + async fn echo(&self, value: String) -> tinybus::Result { + Ok(value) + } +} + +#[tokio::test] +async fn rust_185_client_roundtrips_over_the_real_memory_broker() { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + + let service = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + service.request_name(NAME).await.unwrap(); + service + .serve_at(tinybus::ObjectPath::new(PATH).unwrap(), Echo) + .await + .unwrap(); + + let client = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let proxy = client.proxy(NAME, PATH, NAME).unwrap(); + let reply = proxy + .call::("Echo", ("Rust 1.85".to_owned(),)) + .await + .unwrap(); + + assert_eq!(reply, "Rust 1.85"); +} diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh index edde89b7..cbd8300d 100755 --- a/scripts/check-coverage.sh +++ b/scripts/check-coverage.sh @@ -1,19 +1,22 @@ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." -cargo build --locked --example module_clock --example module_clock_two --example module_wrong_target --example module_context --example module_context_eager --example module_host_callback --example module_host_stream_callback --example module_controlled_init --example module_terminal --no-default-features --features modules,macros,__native-fixture-support +cargo build --locked --example module_clock --example module_clock_two --example module_wrong_target --example module_context --example module_context_configured --example module_context_factory --example module_context_eager --example module_trusted_migration --example module_host_callback --example module_host_stream_callback --example module_controlled_init --example module_terminal --no-default-features --features uds,modules,macros,__native-fixture-support set -euo pipefail target_dir=$(cargo metadata --format-version 1 --no-deps | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])') -module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f -name 'libmodule_clock.so' -print -quit) -module_two=$(find "$target_dir/debug/examples" -maxdepth 1 -type f -name 'libmodule_clock_two.so' -print -quit) -wrong_target=$(find "$target_dir/debug/examples" -maxdepth 1 -type f -name 'libmodule_wrong_target.so' -print -quit) -context_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f -name 'libmodule_context.so' -print -quit) -context_eager_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f -name 'libmodule_context_eager.so' -print -quit) +module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_clock.so' -o -name 'libmodule_clock.dylib' \) -print -quit) +module_two=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_clock_two.so' -o -name 'libmodule_clock_two.dylib' \) -print -quit) +wrong_target=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_wrong_target.so' -o -name 'libmodule_wrong_target.dylib' \) -print -quit) +context_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_context.so' -o -name 'libmodule_context.dylib' \) -print -quit) +configured_context_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_context_configured.so' -o -name 'libmodule_context_configured.dylib' \) -print -quit) +context_factory_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_context_factory.so' -o -name 'libmodule_context_factory.dylib' \) -print -quit) +context_eager_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_context_eager.so' -o -name 'libmodule_context_eager.dylib' \) -print -quit) +trusted_migration_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_trusted_migration.so' -o -name 'libmodule_trusted_migration.dylib' \) -print -quit) host_callback_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_host_callback.so' -o -name 'libmodule_host_callback.dylib' \) -print -quit) host_stream_callback_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_host_stream_callback.so' -o -name 'libmodule_host_stream_callback.dylib' \) -print -quit) controlled_init_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_controlled_init.so' -o -name 'libmodule_controlled_init.dylib' \) -print -quit) terminal_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_terminal.so' -o -name 'libmodule_terminal.dylib' \) -print -quit) -for artifact in "$module" "$module_two" "$wrong_target" "$context_module" "$context_eager_module" "$host_callback_module" "$host_stream_callback_module" "$controlled_init_module" "$terminal_module"; do +for artifact in "$module" "$module_two" "$wrong_target" "$context_module" "$configured_context_module" "$context_factory_module" "$context_eager_module" "$trusted_migration_module" "$host_callback_module" "$host_stream_callback_module" "$controlled_init_module" "$terminal_module"; do if [ -z "$artifact" ] || [ ! -f "$artifact" ]; then echo "a module loader fixture is missing" >&2 exit 1 @@ -51,12 +54,12 @@ export TINYBUS_TEST_TERMINAL_MODULE="$terminal_private/$terminal_library" # process/network branches are not meaningful per-file coverage. The # test-support module is integration-test glue whose platform-specific # dynamic-loader and timeout paths run in the dedicated OS jobs instead. -export TINYBUS_TEST_MODULE="$module" TINYBUS_TEST_MODULE_TWO="$private/$library" TINYBUS_TEST_WRONG_TARGET="$wrong_target" TINYBUS_TEST_CONTEXT_MODULE="$context_module" TINYBUS_TEST_CONTEXT_EAGER_MODULE="$context_eager_module" TINYBUS_TEST_HOST_CALLBACK_MODULE="$host_callback_module" TINYBUS_TEST_HOST_STREAM_CALLBACK_MODULE="$host_stream_callback_module" TINYBUS_TEST_CONTROLLED_INIT_MODULE="$controlled_init_module" +export TINYBUS_TEST_MODULE="$module" TINYBUS_TEST_MODULE_TWO="$private/$library" TINYBUS_TEST_WRONG_TARGET="$wrong_target" TINYBUS_TEST_CONTEXT_MODULE="$context_module" TINYBUS_TEST_CONFIGURED_CONTEXT_MODULE="$configured_context_module" TINYBUS_TEST_CONTEXT_FACTORY_MODULE="$context_factory_module" TINYBUS_TEST_CONTEXT_EAGER_MODULE="$context_eager_module" TINYBUS_TEST_MIGRATION_MODULE="$trusted_migration_module" TINYBUS_TEST_HOST_CALLBACK_MODULE="$host_callback_module" TINYBUS_TEST_HOST_STREAM_CALLBACK_MODULE="$host_stream_callback_module" TINYBUS_TEST_CONTROLLED_INIT_MODULE="$controlled_init_module" cargo llvm-cov --workspace --all-features --ignore-filename-regex 'crates/tinybus/src/ports/(listener|transport)\.rs$|crates/tinybus/src/module/github\.rs$|crates/tinybus/src/module/(host|mod)\.rs$|crates/tinybus/src/bin/(tinybus\.rs|tinybus/unix\.rs)$|crates/tinybus/src/connection\.rs$|crates/tinybus/src/test_support\.rs$' --no-report -- --include-ignored --skip terminal_runtime_tests::compiled_native_ -terminal_tests=$(cargo test --locked -p tinybus-module --all-features --features tinybus/macros -- --list --ignored | sed -n 's/^\(terminal_runtime_tests::compiled_native_.*\): test$/\1/p') +terminal_tests=$(cargo test --locked -p tinybus-module --all-features --features tinybus/macros,tinybus/uds -- --list --ignored | sed -n 's/^\(terminal_runtime_tests::compiled_native_.*\): test$/\1/p') [ -n "$terminal_tests" ] || { echo "no compiled terminal scenarios found" >&2; exit 1; } while IFS= read -r test; do - cargo llvm-cov test --no-report -p tinybus-module --all-features --features tinybus/macros "$test" -- --ignored --exact + cargo llvm-cov test --no-report -p tinybus-module --all-features --features tinybus/macros,tinybus/uds "$test" -- --ignored --exact done <<< "$terminal_tests" cargo llvm-cov report --ignore-filename-regex 'crates/tinybus/src/ports/(listener|transport)\.rs$|crates/tinybus/src/module/github\.rs$|crates/tinybus/src/module/(host|mod)\.rs$|crates/tinybus/src/bin/(tinybus\.rs|tinybus/unix\.rs)$|crates/tinybus/src/connection\.rs$|crates/tinybus/src/test_support\.rs$' --fail-under-file-lines 90 diff --git a/scripts/test-e2e.ps1 b/scripts/test-e2e.ps1 index 612fb284..d4f77172 100644 --- a/scripts/test-e2e.ps1 +++ b/scripts/test-e2e.ps1 @@ -20,7 +20,10 @@ $acl.AddAccessRule($rule) Set-Acl -LiteralPath $private -AclObject $acl Copy-Item (Join-Path $examples module_clock.dll) $private Copy-Item (Join-Path $examples module_context.dll) $private +Copy-Item (Join-Path $examples module_context_configured.dll) $private +Copy-Item (Join-Path $examples module_context_factory.dll) $private Copy-Item (Join-Path $examples module_context_eager.dll) $private +Copy-Item (Join-Path $examples module_trusted_migration.dll) $private Copy-Item (Join-Path $examples module_host_callback.dll) $private Copy-Item (Join-Path $examples module_host_stream_callback.dll) $private Copy-Item (Join-Path $examples module_controlled_init.dll) $private @@ -28,7 +31,7 @@ Copy-Item (Join-Path $examples module_terminal.dll) $private Copy-Item (Join-Path $examples module_wrong_target.dll) $private # Admit every intentional fixture by its actual built bytes so later # checks, including the wrong-target gate, can exercise their contract. -$fixtureAllowlist = @('module_clock.dll', 'module_context.dll', 'module_context_eager.dll', 'module_host_callback.dll', 'module_host_stream_callback.dll', 'module_controlled_init.dll', 'module_terminal.dll', 'module_wrong_target.dll') | ForEach-Object { +$fixtureAllowlist = @('module_clock.dll', 'module_context.dll', 'module_context_configured.dll', 'module_context_factory.dll', 'module_context_eager.dll', 'module_host_callback.dll', 'module_host_stream_callback.dll', 'module_controlled_init.dll', 'module_terminal.dll', 'module_wrong_target.dll') | ForEach-Object { $digest = (Get-FileHash -LiteralPath (Join-Path $private $_) -Algorithm SHA256).Hash.ToLowerInvariant() '"' + $_ + '" = "' + $digest + '"' } @@ -50,7 +53,10 @@ $wrongTarget = (Get-ChildItem "$private/module_wrong_target.dll" | Select-Object $env:TEMP = $private $env:TMP = $private $env:TINYBUS_TEST_CONTEXT_MODULE = (Get-Item "$private/module_context.dll").FullName +$env:TINYBUS_TEST_CONFIGURED_CONTEXT_MODULE = (Get-Item "$private/module_context_configured.dll").FullName +$env:TINYBUS_TEST_CONTEXT_FACTORY_MODULE = (Get-Item "$private/module_context_factory.dll").FullName $env:TINYBUS_TEST_CONTEXT_EAGER_MODULE = (Get-Item "$private/module_context_eager.dll").FullName +$env:TINYBUS_TEST_MIGRATION_MODULE = (Get-Item "$private/module_trusted_migration.dll").FullName $env:TINYBUS_TEST_CONTROLLED_INIT_MODULE = (Get-Item "$private/module_controlled_init.dll").FullName $env:TINYBUS_TEST_TERMINAL_MODULE = (Get-Item "$private/module_terminal.dll").FullName $env:TINYBUS_TEST_HOST_CALLBACK_MODULE = (Get-Item "$private/module_host_callback.dll").FullName @@ -59,19 +65,19 @@ $env:TINYBUS_TEST_MODULE = $module $env:TINYBUS_TEST_MODULE_TWO = $moduleTwo $env:TINYBUS_TEST_WRONG_TARGET = $wrongTarget $env:TINYBUS_TEST_MODULE_DIRECTORY = $mixed -cargo test --locked --no-default-features --workspace --features modules,macros,test-support,__native-fixture-support -- --ignored --skip terminal_runtime_tests::compiled_native_ +cargo test --locked --no-default-features --workspace --features uds,modules,macros,test-support,__native-fixture-support -- --ignored --skip terminal_runtime_tests::compiled_native_ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Retained fixture owners require a fresh process for each SDK scenario. -$testList = cargo test --locked -p tinybus-module --features __native-fixture-support,tinybus/macros -- --list --ignored +$testList = cargo test --locked -p tinybus-module --features __native-fixture-support,tinybus/macros,tinybus/uds -- --list --ignored if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } foreach ($line in $testList) { if ($line -match '^(terminal_runtime_tests::compiled_native_.*): test$') { - cargo test --locked -p tinybus-module --features __native-fixture-support,tinybus/macros $Matches[1] -- --ignored --exact + cargo test --locked -p tinybus-module --features __native-fixture-support,tinybus/macros,tinybus/uds $Matches[1] -- --ignored --exact if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } } # Directory admission against the real ACLs: a bundle Everyone can # write falls back to the verified release cache. -cargo test --locked --no-default-features --features modules,macros admission_tests -- --include-ignored +cargo test --locked --no-default-features --features uds,modules,macros admission_tests -- --include-ignored if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/scripts/test-e2e.sh b/scripts/test-e2e.sh index d50796f1..f794f630 100755 --- a/scripts/test-e2e.sh +++ b/scripts/test-e2e.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." -cargo build --locked --example module_clock --example module_clock_two --example module_wrong_target --example module_context --example module_context_eager --example module_host_callback --example module_host_stream_callback --example module_controlled_init --example module_terminal --no-default-features --features modules,macros,__native-fixture-support -cargo test --locked --no-default-features --features modules,macros,test-support test_support::tests +cargo build --locked --example module_clock --example module_clock_two --example module_wrong_target --example module_context --example module_context_configured --example module_context_factory --example module_context_eager --example module_trusted_migration --example module_host_callback --example module_host_stream_callback --example module_controlled_init --example module_terminal --no-default-features --features uds,modules,macros,__native-fixture-support +cargo test --locked --no-default-features --features uds,modules,macros,test-support test_support::tests case "${RUNNER_OS:-$(uname -s)}" in Windows|MINGW*|MSYS*|CYGWIN*) pwsh -NoProfile -File scripts/test-e2e.ps1 ;; *) @@ -12,12 +12,15 @@ module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodu module_two=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_clock_two.so' -o -name 'libmodule_clock_two.dylib' \) -print -quit) wrong_target=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_wrong_target.so' -o -name 'libmodule_wrong_target.dylib' \) -print -quit) context_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_context.so' -o -name 'libmodule_context.dylib' \) -print -quit) +configured_context_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_context_configured.so' -o -name 'libmodule_context_configured.dylib' \) -print -quit) +context_factory_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_context_factory.so' -o -name 'libmodule_context_factory.dylib' \) -print -quit) context_eager_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_context_eager.so' -o -name 'libmodule_context_eager.dylib' \) -print -quit) +trusted_migration_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_trusted_migration.so' -o -name 'libmodule_trusted_migration.dylib' \) -print -quit) host_callback_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_host_callback.so' -o -name 'libmodule_host_callback.dylib' \) -print -quit) host_stream_callback_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_host_stream_callback.so' -o -name 'libmodule_host_stream_callback.dylib' \) -print -quit) controlled_init_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_controlled_init.so' -o -name 'libmodule_controlled_init.dylib' \) -print -quit) terminal_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_terminal.so' -o -name 'libmodule_terminal.dylib' \) -print -quit) -for artifact in "$module" "$module_two" "$wrong_target" "$context_module" "$context_eager_module" "$host_callback_module" "$host_stream_callback_module" "$controlled_init_module" "$terminal_module"; do +for artifact in "$module" "$module_two" "$wrong_target" "$context_module" "$configured_context_module" "$context_factory_module" "$context_eager_module" "$trusted_migration_module" "$host_callback_module" "$host_stream_callback_module" "$controlled_init_module" "$terminal_module"; do if [ -z "$artifact" ] || [ ! -f "$artifact" ]; then echo "a module example artifact is missing; the loader tests would not exercise it" >&2 exit 1 @@ -48,16 +51,16 @@ else fi printf '"%s" = "%s"\n' "$terminal_library" "$terminal_digest" > "$terminal_private/modules.toml" export TINYBUS_TEST_TERMINAL_MODULE="$terminal_private/$terminal_library" -export TINYBUS_TEST_MODULE="$module" TINYBUS_TEST_MODULE_TWO="$private/$library" TINYBUS_TEST_WRONG_TARGET="$wrong_target" TINYBUS_TEST_CONTEXT_MODULE="$context_module" TINYBUS_TEST_CONTEXT_EAGER_MODULE="$context_eager_module" TINYBUS_TEST_HOST_CALLBACK_MODULE="$host_callback_module" TINYBUS_TEST_HOST_STREAM_CALLBACK_MODULE="$host_stream_callback_module" TINYBUS_TEST_CONTROLLED_INIT_MODULE="$controlled_init_module" -cargo test --locked --no-default-features --workspace --features modules,macros,test-support,__native-fixture-support -- --ignored --skip terminal_runtime_tests::compiled_native_ +export TINYBUS_TEST_MODULE="$module" TINYBUS_TEST_MODULE_TWO="$private/$library" TINYBUS_TEST_WRONG_TARGET="$wrong_target" TINYBUS_TEST_CONTEXT_MODULE="$context_module" TINYBUS_TEST_CONFIGURED_CONTEXT_MODULE="$configured_context_module" TINYBUS_TEST_CONTEXT_FACTORY_MODULE="$context_factory_module" TINYBUS_TEST_CONTEXT_EAGER_MODULE="$context_eager_module" TINYBUS_TEST_MIGRATION_MODULE="$trusted_migration_module" TINYBUS_TEST_HOST_CALLBACK_MODULE="$host_callback_module" TINYBUS_TEST_HOST_STREAM_CALLBACK_MODULE="$host_stream_callback_module" TINYBUS_TEST_CONTROLLED_INIT_MODULE="$controlled_init_module" +cargo test --locked --no-default-features --workspace --features uds,modules,macros,test-support,__native-fixture-support -- --ignored --skip terminal_runtime_tests::compiled_native_ -terminal_tests=$(cargo test --locked -p tinybus-module --features __native-fixture-support,tinybus/macros -- --list --ignored | sed -n 's/^\(terminal_runtime_tests::compiled_native_.*\): test$/\1/p') +terminal_tests=$(cargo test --locked -p tinybus-module --features __native-fixture-support,tinybus/macros,tinybus/uds -- --list --ignored | sed -n 's/^\(terminal_runtime_tests::compiled_native_.*\): test$/\1/p') [ -n "$terminal_tests" ] || { echo "no compiled terminal scenarios found" >&2; exit 1; } while IFS= read -r test; do - cargo test --locked -p tinybus-module --features __native-fixture-support,tinybus/macros "$test" -- --ignored --exact + cargo test --locked -p tinybus-module --features __native-fixture-support,tinybus/macros,tinybus/uds "$test" -- --ignored --exact done <<< "$terminal_tests" -cargo test --locked --no-default-features --features modules,macros admission_tests -- --include-ignored -TINYBUS_TEST_UMASK=1 cargo test --locked --no-default-features --features modules,macros permissive_umask -- --test-threads=1 +cargo test --locked --no-default-features --features uds,modules,macros admission_tests -- --include-ignored +TINYBUS_TEST_UMASK=1 cargo test --locked --no-default-features --features uds,modules,macros permissive_umask -- --test-threads=1 ;; esac diff --git a/scripts/test-features.sh b/scripts/test-features.sh index 6e0d5742..f5b4f817 100755 --- a/scripts/test-features.sh +++ b/scripts/test-features.sh @@ -1,7 +1,17 @@ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." +case "${RUNNER_OS:-$(uname -s)}" in + Linux|Darwin|macOS) + python3 scripts/test_check_coverage.py + ;; + Windows|MINGW*|MSYS*|CYGWIN*) ;; +esac cargo check --locked --workspace --no-default-features +# Compile TinyBus' own default-feature test targets without workspace feature +# unification, so dev-only fixture dependencies select their own backends. +cargo check --locked --package tinybus --tests cargo check --locked --package tinybus --no-default-features --features modules +cargo metadata --locked --format-version 1 --manifest-path examples/Cargo.toml >/dev/null cargo test --locked --workspace --no-default-features --features macros cargo test --locked --workspace --all-features diff --git a/scripts/test_check_coverage.py b/scripts/test_check_coverage.py new file mode 100644 index 00000000..46cfd5c9 --- /dev/null +++ b/scripts/test_check_coverage.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +"""Exercise coverage-script fixture discovery without running Cargo.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parent.parent +COVERAGE_SCRIPT = ROOT / "scripts" / "check-coverage.sh" +LIBRARIES = ( + "module_clock", + "module_clock_two", + "module_wrong_target", + "module_context", + "module_context_configured", + "module_context_factory", + "module_context_eager", + "module_trusted_migration", + "module_host_callback", + "module_host_stream_callback", + "module_controlled_init", + "module_terminal", +) +TERMINAL_TESTS = ( + "terminal_runtime_tests::compiled_native_fixture_first", + "terminal_runtime_tests::compiled_native_fixture_second", +) +IGNORE_REGEX = ( + r"crates/tinybus/src/ports/(listener|transport)\.rs$" + r"|crates/tinybus/src/module/github\.rs$" + r"|crates/tinybus/src/module/(host|mod)\.rs$" + r"|crates/tinybus/src/bin/(tinybus\.rs|tinybus/unix\.rs)$" + r"|crates/tinybus/src/connection\.rs$" + r"|crates/tinybus/src/test_support\.rs$" +) + + +FAKE_CARGO = r"""#!/usr/bin/env python3 +import hashlib +import json +import os +from pathlib import Path +import sys + +args = sys.argv[1:] +target = Path(os.environ["FAKE_TARGET_DIR"]) +suffix = os.environ["FAKE_FIXTURE_SUFFIX"] +log = Path(os.environ["FAKE_CARGO_LOG"]) +entry = {"args": args} + +if args and args[0] == "build": + examples = target / "debug" / "examples" + examples.mkdir(parents=True, exist_ok=True) + for name in ( + "module_clock", + "module_clock_two", + "module_wrong_target", + "module_context", + "module_context_configured", + "module_context_factory", + "module_context_eager", + "module_trusted_migration", + "module_host_callback", + "module_host_stream_callback", + "module_controlled_init", + "module_terminal", + ): + (examples / f"lib{name}.{suffix}").write_bytes(name.encode()) +elif args[:2] == ["metadata", "--format-version"]: + print(json.dumps({"target_directory": str(target)})) +elif args[:2] == ["llvm-cov", "--workspace"]: + fixture_variables = ( + "TINYBUS_TEST_MODULE", + "TINYBUS_TEST_MODULE_TWO", + "TINYBUS_TEST_WRONG_TARGET", + "TINYBUS_TEST_CONTEXT_MODULE", + "TINYBUS_TEST_CONFIGURED_CONTEXT_MODULE", + "TINYBUS_TEST_CONTEXT_FACTORY_MODULE", + "TINYBUS_TEST_CONTEXT_EAGER_MODULE", + "TINYBUS_TEST_MIGRATION_MODULE", + "TINYBUS_TEST_HOST_CALLBACK_MODULE", + "TINYBUS_TEST_HOST_STREAM_CALLBACK_MODULE", + "TINYBUS_TEST_CONTROLLED_INIT_MODULE", + "TINYBUS_TEST_TERMINAL_MODULE", + ) + fixtures = {name: os.environ.get(name, "") for name in fixture_variables} + if any(not Path(path).is_file() for path in fixtures.values()): + print("coverage did not receive every compiled fixture", file=sys.stderr) + sys.exit(3) + for variable in ("TINYBUS_TEST_MODULE_TWO", "TINYBUS_TEST_TERMINAL_MODULE"): + path = Path(fixtures[variable]) + manifest = path.parent / "modules.toml" + expected = f'"{path.name}" = "{hashlib.sha256(path.read_bytes()).hexdigest()}"' + if not manifest.is_file() or expected not in manifest.read_text(): + print(f"invalid private fixture manifest for {variable}", file=sys.stderr) + sys.exit(4) + entry["fixture_paths"] = fixtures +elif args[:2] == ["test", "--locked"] and "--list" in args: + for name in ( + "terminal_runtime_tests::compiled_native_fixture_first", + "terminal_runtime_tests::compiled_native_fixture_second", + ): + print(f"{name}: test") +elif args[:2] == ["llvm-cov", "test"]: + if "--ignored" not in args or "--exact" not in args: + print("terminal scenario lost isolated ignored/exact execution", file=sys.stderr) + sys.exit(5) +elif args[:2] == ["llvm-cov", "report"]: + if args[-2:] != ["--fail-under-file-lines", "90"]: + print("coverage report threshold changed", file=sys.stderr) + sys.exit(6) +else: + print(f"unexpected fake Cargo invocation: {args!r}", file=sys.stderr) + sys.exit(7) + +with log.open("a", encoding="utf-8") as output: + output.write(json.dumps(entry) + "\n") +""" + + +class CoverageScriptPlatformTests(unittest.TestCase): + def run_coverage_script(self, suffix: str) -> tuple[subprocess.CompletedProcess[str], list[dict]]: + with tempfile.TemporaryDirectory(prefix="tinybus-coverage-script-") as directory: + temporary = Path(directory) + checkout = temporary / "checkout" + scripts = checkout / "scripts" + scripts.mkdir(parents=True) + shutil.copyfile(COVERAGE_SCRIPT, scripts / COVERAGE_SCRIPT.name) + + fake_bin = temporary / "bin" + fake_bin.mkdir() + cargo = fake_bin / "cargo" + cargo.write_text(textwrap.dedent(FAKE_CARGO), encoding="utf-8") + cargo.chmod(0o755) + + target = checkout / "target" + call_log = temporary / "cargo-calls.jsonl" + environment = os.environ.copy() + environment.update( + { + "FAKE_CARGO_LOG": str(call_log), + "FAKE_FIXTURE_SUFFIX": suffix, + "FAKE_TARGET_DIR": str(target), + "PATH": os.pathsep.join((str(fake_bin), os.environ["PATH"])), + } + ) + result = subprocess.run( + ["bash", str(scripts / COVERAGE_SCRIPT.name)], + cwd=checkout, + env=environment, + text=True, + capture_output=True, + check=False, + ) + calls = [ + json.loads(line) + for line in call_log.read_text(encoding="utf-8").splitlines() + ] if call_log.exists() else [] + return result, calls + + def assert_coverage_contract( + self, + result: subprocess.CompletedProcess[str], + calls: list[dict], + suffix: str, + ) -> None: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + args = [call["args"] for call in calls] + + self.assertEqual( + args[0], + [ + "build", + "--locked", + "--example", + "module_clock", + "--example", + "module_clock_two", + "--example", + "module_wrong_target", + "--example", + "module_context", + "--example", + "module_context_configured", + "--example", + "module_context_factory", + "--example", + "module_context_eager", + "--example", + "module_trusted_migration", + "--example", + "module_host_callback", + "--example", + "module_host_stream_callback", + "--example", + "module_controlled_init", + "--example", + "module_terminal", + "--no-default-features", + "--features", + "uds,modules,macros,__native-fixture-support", + ], + ) + self.assertEqual( + args[1], + ["metadata", "--format-version", "1", "--no-deps"], + ) + + coverage_call = next(call for call in calls if call["args"][:2] == ["llvm-cov", "--workspace"]) + self.assertEqual( + coverage_call["args"], + [ + "llvm-cov", + "--workspace", + "--all-features", + "--ignore-filename-regex", + IGNORE_REGEX, + "--no-report", + "--", + "--include-ignored", + "--skip", + "terminal_runtime_tests::compiled_native_", + ], + ) + fixture_paths = coverage_call["fixture_paths"] + self.assertEqual(len(fixture_paths), len(LIBRARIES)) + for variable, library in zip(fixture_paths, LIBRARIES, strict=True): + self.assertTrue(fixture_paths[variable].endswith(f"lib{library}.{suffix}")) + + list_call = next(call for call in calls if call["args"][:2] == ["test", "--locked"]) + self.assertEqual( + list_call["args"], + [ + "test", + "--locked", + "-p", + "tinybus-module", + "--all-features", + "--features", + "tinybus/macros,tinybus/uds", + "--", + "--list", + "--ignored", + ], + ) + isolated_calls = [call["args"] for call in calls if call["args"][:2] == ["llvm-cov", "test"]] + self.assertEqual( + isolated_calls, + [ + [ + "llvm-cov", + "test", + "--no-report", + "-p", + "tinybus-module", + "--all-features", + "--features", + "tinybus/macros,tinybus/uds", + test, + "--", + "--ignored", + "--exact", + ] + for test in TERMINAL_TESTS + ], + ) + report_call = next(call for call in calls if call["args"][:2] == ["llvm-cov", "report"]) + self.assertEqual( + report_call["args"], + [ + "llvm-cov", + "report", + "--ignore-filename-regex", + IGNORE_REGEX, + "--fail-under-file-lines", + "90", + ], + ) + + def test_linux_so_fixtures_reach_the_unchanged_coverage_gate(self) -> None: + result, calls = self.run_coverage_script("so") + self.assert_coverage_contract(result, calls, "so") + + def test_macos_dylib_fixtures_reach_the_unchanged_coverage_gate(self) -> None: + result, calls = self.run_coverage_script("dylib") + self.assert_coverage_contract(result, calls, "dylib") + + def test_fake_cargo_harness_is_limited_to_the_posix_feature_lane(self) -> None: + lines = (ROOT / "scripts" / "test-features.sh").read_text( + encoding="utf-8" + ).splitlines() + case_line = 'case "${RUNNER_OS:-$(uname -s)}" in' + self.assertIn( + case_line, + lines, + "the fake-Cargo coverage harness must be scoped to the POSIX runner branch", + ) + case_start = lines.index(case_line) + windows_branch = next( + index + for index, line in enumerate(lines) + if line.startswith(" Windows|") + ) + posix_branch = next( + index + for index, line in enumerate(lines) + if line.strip() == "Linux|Darwin|macOS)" + ) + posix_end = next( + index + for index, line in enumerate(lines[posix_branch + 1 :], posix_branch + 1) + if line.strip() == ";;" + ) + case_end = next( + index + for index, line in enumerate(lines[windows_branch + 1 :], windows_branch + 1) + if line.strip() == "esac" + ) + invocations = [ + index + for index, line in enumerate(lines) + if line.strip() == "python3 scripts/test_check_coverage.py" + ] + + self.assertEqual(len(invocations), 1) + self.assertGreater(invocations[0], posix_branch) + self.assertLess(invocations[0], posix_end) + self.assertNotIn( + "python3 scripts/test_check_coverage.py", + [line.strip() for line in lines[windows_branch + 1 : case_end]], + ) + self.assertGreater(windows_branch, case_start) + self.assertEqual( + [line.strip() for line in lines if line.strip().startswith("cargo ")], + [ + "cargo check --locked --workspace --no-default-features", + "cargo check --locked --package tinybus --tests", + "cargo check --locked --package tinybus --no-default-features --features modules", + "cargo metadata --locked --format-version 1 --manifest-path examples/Cargo.toml >/dev/null", + "cargo test --locked --workspace --no-default-features --features macros", + "cargo test --locked --workspace --all-features", + ], + ) + + +if __name__ == "__main__": + unittest.main()