diff --git a/AGENTS.md b/AGENTS.md
index efd0e534634..19259211c38 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -198,9 +198,12 @@ place.
| `linux-canary.yml`, `windows-canary.yml` | `RELEASE_REPO` guard | Were pinned to `block/buzz` |
| `infra/aws/` | new directory | Terraform deploying the relay to AWS account `618867225791` (`eu-west-3`) on ECS Fargate + RDS + ElastiCache + S3 + EFS, serving `wss://relay.bitcoinmarkets.app`. Upstream deploys via `deploy/charts/buzz` (Helm) and has no Terraform, so this adds only new paths and should never conflict. See [`infra/aws/README.md`](infra/aws/README.md) |
| `.github/workflows/deploy-aws.yml` | new | Continuous deployment of the relay to AWS on every push to `main`. Runs after `docker.yml` via `workflow_run`, authenticates by OIDC (no stored keys), and applies Terraform with the commit's immutable `:sha-<7>` image |
+| `desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx` | Inbox and Markets share one primary-menu row | The nav entry point for [Bitcoin markets](#bitcoin-markets-fork-local-feature-31). Packing Markets as its own `SidebarMenuItem` pushed the sortable sections down and broke `virtualization.spec.ts` "06", so the two buttons live in one flex row inside a single height-stable item — the badge is `right-1` rather than upstream's `right-2` because the Inbox button is now half-width. **Upstream develops this file steadily and the fork's hunk sits on its first menu item, so expect a conflict whenever upstream reorders the primary menu.** #6003 (2026-08-20 sync) wrapped the whole header in a fragment and appended ` `, which re-indented every line and conflicted; resolution is *take upstream's structure and indentation, re-seat the fork's combined row where upstream's plain Inbox item was* |
+| `desktop/src/features/sidebar/ui/AppSidebar.tsx` + `AppSidebar.types.ts` | `markets` view, `onSelectMarkets`, `"markets"` in the `SidebarSelectedView` union | Threads the Markets selection down to the header row above. `AppSidebar.types.ts` is fork-added and additive; the `AppSidebar.tsx` changes are one-line insertions into existing prop lists, so they resolve as *keep ours, take upstream's* |
+| `desktop/src/features/markets/**`, `desktop/src/app/routes/markets.tsx`, `desktop/src-tauri/src/commands/markets.rs`, `crates/buzz-core/src/markets.rs`, `crates/buzz-avnu-proxy/` | new | The [Bitcoin markets](#bitcoin-markets-fork-local-feature-31) implementation. All additive paths upstream has no counterpart for, so they should never conflict. Their *declaration* sites do — `commands/mod.rs`, `lib.rs`'s invoke handler, `crates/buzz-core/src/lib.rs`, `desktop/package.json`, `tsconfig.json`, `vite.config.ts`, `routes.ts`, `routeTree.gen.ts` — each a one-to-few-line insertion into an existing list |
| `desktop/src-tauri/src/relay/allowlist.rs` | new | Single-relay host allowlist. Upstream is multi-community by design; this fork ships a client that reaches only `relay.bitcoinmarkets.app`. **Lives under `relay/`, not at the crate root** — see the `relay.rs` row |
| `desktop/src-tauri/src/native_websocket.rs` | allowlist call in `open_connection` | The transport is the one path every relay session takes, so a host restriction there cannot be bypassed from the UI |
-| `desktop/src-tauri/src/relay.rs` | release builds default to the allowlisted relay; also declares `pub mod allowlist;` | Without the default a release build uses `ws://localhost:3000`, which the allowlist then rejects — a client that cannot connect at all. The module is declared *here* because `lib.rs`'s sorted module list is a permanent conflict site, and because `lib.rs` was itself at the 1000-line desktop ratchet when the move was made in the 2026-08-01 sync. `lib.rs` now carries no fork patch at all. **`relay.rs` has since become the constrained file, and the ratchet is how you find out — as a red check, not a merge conflict.** Upstream #6187 (2026-08-19 sync) made the file-size policy a first-class gate: it is now `just file-size-check`, run repository-wide as the **`File size policy`** step of the `scripts` CI job, and it no longer hangs off the per-surface `desktop`/`web`/`mobile` path filters. So an overflow here fails on every PR regardless of which paths it touched, and it surfaces under `scripts` rather than `Desktop Core` — run `just file-size-check` locally to reproduce. The 2026-08-14 sync merged cleanly and pushed it 987 → 1002 against a hard limit of 1000 (`desktop/scripts/check-file-sizes.mjs`; upstream's own `mod get;` was +3, the fork's block +14). Fixed by condensing the fork's two comment blocks to 995, since AGENTS.md is where the reasoning belongs — **do not split or reorganise upstream's `relay.rs` to make room**, that trades 5 lines for a permanent conflict surface. Upstream is extracting submodules from this file on its own (`mod get;`, `mod submit;`), so the pressure should ease; if it does not, the fork's ~11 lines here are the budget to work within |
+| `desktop/src-tauri/src/relay.rs` | release builds default to the allowlisted relay; also declares `pub mod allowlist;` | Without the default a release build uses `ws://localhost:3000`, which the allowlist then rejects — a client that cannot connect at all. The module is declared *here* because `lib.rs`'s sorted module list is a permanent conflict site, and because `lib.rs` was itself at the 1000-line desktop ratchet when the move was made in the 2026-08-01 sync. `lib.rs` now carries no fork patch at all. **`relay.rs` has since become the constrained file, and the ratchet is how you find out — as a red check, not a merge conflict.** Upstream #6187 (2026-08-19 sync) made the file-size policy a first-class gate: it is now `just file-size-check`, run repository-wide as the **`File size policy`** step of the `scripts` CI job, and it no longer hangs off the per-surface `desktop`/`web`/`mobile` path filters. So an overflow here fails on every PR regardless of which paths it touched, and it surfaces under `scripts` rather than `Desktop Core` — run `just file-size-check` locally to reproduce. The 2026-08-14 sync merged cleanly and pushed it 987 → 1002 against a hard limit of 1000 (`desktop/scripts/check-file-sizes.mjs`; upstream's own `mod get;` was +3, the fork's block +14). Fixed by condensing the fork's two comment blocks to 995, since AGENTS.md is where the reasoning belongs — **do not split or reorganise upstream's `relay.rs` to make room**, that trades 5 lines for a permanent conflict surface. Upstream is extracting submodules from this file on its own (`mod get;`, `mod submit;`), so the pressure should ease; if it does not, the fork's ~11 lines here are the budget to work within. **It has not eased, and the budget is now nearly spent.** The 2026-08-20 sync merged cleanly and pushed it 996 → 1003; condensing the fork's two comment blocks again — 5 comment lines down to 2, one per block — brought it to 999. That leaves ~4 fork lines in this file (2 comments, 2 code) and no further comment slack, so the next overflow cannot be absorbed the same way. **When it recurs, move `pub mod allowlist;` back to `lib.rs` rather than touching upstream's code here** — `lib.rs` was the reason for the original move and is now 936 lines with 64 to spare, so the conflict-surface trade has reversed. Re-check both line counts before deciding; do not split upstream's `relay.rs` |
| `mobile/lib/shared/relay/relay_allowlist.dart` | new | Mobile counterpart. Skips enforcement under `flutter test` (`FLUTTER_TEST`) because upstream tests use `wss://relay.example.com`; editing those 13 files would be a large permanent conflict surface |
| `mobile/lib/shared/relay/relay_socket.dart` | allowlist call in `connect()` | Transport choke point, as on desktop |
| `mobile/lib/shared/relay/relay_validation.dart` | allowlist call after the shape checks | One hunk covers all four invite/deep-link call sites; placed after the existing checks so malformed input keeps its original error |
@@ -219,6 +222,41 @@ place.
| `scripts/mobile-worktree-overrides.sh` | branch-labelled debug name | Generates the gitignored per-worktree `APP_DISPLAY_NAME` |
| `scripts/test-mobile-worktree-overrides.sh` | assertions derive the production name from `mobile-worktree-overrides.sh` instead of matching the literal `Buzz` | Four assertions hardcoded `Buzz` and **failed CI on `main` for three commits** after the rename (`ff5e83c28`…`684a15f50`), cascading into `Desktop` and `Desktop E2E Integration` through their gate steps. Deriving the name tests the contract the file is for — release unlabelled, debug labelled, iOS and Android agreeing — so a future rename cannot fail it for the wrong reason |
+#### Bitcoin markets (fork-local, feature #31)
+
+A hidden-L2, Lightning-funded Bitcoin difficulty betting surface, added by
+`1e80e837a` (#31). It is a substantial fork-local feature — ~40 files, a new
+crate (`buzz-avnu-proxy`), a new `buzz-core` module, a new Tauri command module,
+and a new desktop route — and it went **eleven syncs with no row in the patch
+table above**. The 2026-08-20 sync paid for that: upstream #6003 conflicted in
+`AppSidebarPinnedHeader.tsx` and the resolver had nothing to consult, having to
+reconstruct the fork's intent from `git log` on the file. The rows are there now;
+keep them current.
+
+Almost all of it is additive and cannot conflict. What conflicts is the small set
+of *declaration* and *nav* sites listed in the table, and of those only
+`AppSidebarPinnedHeader.tsx` sits in a file upstream actively redesigns.
+
+**Its file-size relocation was superseded by upstream and has been deleted.** To
+shed 92 lines from `desktop/src-tauri/src/lib.rs` under the 1000-line ratchet, #31
+moved the PTT global-shortcut plugin construction out of `lib.rs` and into
+`ptt_shortcut.rs` as `global_shortcut_plugin()`. Upstream's #6024 then made the
+*same* relocation into the *same* file, naming it `install()` and additionally
+giving it a `cfg(test)` no-op arm. The fork's copy was dropped in the 2026-08-20
+sync and `ptt_shortcut.rs` is now byte-identical to upstream again.
+
+**The way that failure presents is worth remembering.** Both sides moved the same
+code to the same file, but into different regions of it, so git auto-merged and
+kept **both** copies with no conflict marker anywhere. `lib.rs` calls only
+`install()`, so the fork's copy became unreferenced — and it surfaced as
+``error: function `global_shortcut_plugin` is never used`` from
+`cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets`, which
+reads like a fresh upstream defect rather than a merge artifact. A duplicated
+relocation is invisible to `git diff --stat` on the merge and invisible to the
+file-size gate (it makes the file *bigger*, not smaller). Tauri clippy is the only
+gate that catches it, which is a reason not to skip it when a sync looks
+frontend-only.
+
#### Dependabot can open a fork-local divergence that looks like routine upkeep
`benchmarks/harbor-buzz-orchestra/testbed/uv.lock` is **deliberately byte-identical
diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md
index f2de6983282..7eeb070d17b 100644
--- a/crates/buzz-acp/src/base_prompt.md
+++ b/crates/buzz-acp/src/base_prompt.md
@@ -103,7 +103,7 @@ Your persistent workspace is in your working directory:
Knowledge files use `ALL_CAPS_WITH_UNDERSCORES.md` naming. `AGENTS.md` lists active agents and roles. See `AGENTS.md` in your working directory for full workspace conventions.
-These paths are relative to your working directory — keep exploration there. Never run `find` or recursive searches over `$HOME` or `/` hunting for workspace files: they live under your working directory, not elsewhere on disk.
+These paths are relative to your working directory — start there for your own files rather than scanning `$HOME` or `/`. When the user names a specific path, read it.
## Agent Memory
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 68b2df4d607..1352b31cad8 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -2038,19 +2038,6 @@ async fn tokio_main() -> Result<()> {
}
let owner_cache = OwnerCache::new(startup_owner.clone());
- // Relay `self` pubkey (NIP-11), used to recognize relay-signed workflow
- // messages in the inbound author gate. Best-effort: `None` simply means
- // workflow messages get no attributed-author exemption (pre-fix behavior),
- // so a fetch failure degrades gracefully instead of blocking startup.
- let relay_self: Option = relay.rest_client().fetch_relay_self().await;
- match &relay_self {
- Some(pk) => tracing::info!("relay self pubkey: {pk}"),
- None => tracing::warn!(
- "relay self pubkey unavailable (NIP-11 fetch failed or no stable relay key) — \
- relay-signed workflow messages will be dropped by the author gate"
- ),
- }
-
let mut relay_observer_control_rx = None;
let mut relay_observer_publisher_task = None;
let mut relay_observer_publisher = None;
@@ -2865,31 +2852,7 @@ async fn tokio_main() -> Result<()> {
// explicit pubkey list on top, for external people;
// it never revokes same-owner team bots.
{
- // Relay-signed workflow messages (workflow
- // `send_message` actions) are authored by the
- // relay keypair, not the workflow owner — the
- // plain author gate would drop them and the
- // scheduled @mention would silently never wake
- // the agent. Gate them on their *attributed*
- // author (the `buzz:workflow-owner` tag — the
- // pubkey that created the workflow) instead.
- // See `workflow_attributed_author`
- // for the recognition + trust argument.
- let author = match workflow_attributed_author(
- &buzz_event.event,
- relay_self.as_deref(),
- ) {
- Some(attributed) => {
- tracing::debug!(
- channel_id = %buzz_event.channel_id,
- relay_author = %buzz_event.event.pubkey.to_hex(),
- attributed_author = %attributed,
- "relay-signed workflow message — gating on attributed author"
- );
- attributed
- }
- None => buzz_event.event.pubkey.to_hex(),
- };
+ let author = buzz_event.event.pubkey.to_hex();
// DM hardening: resolve channel type (fail-closed
// to DM) so allowlist/anyone modes cannot be
// exercised by non-owner authors inside DMs.
@@ -3570,90 +3533,6 @@ fn event_mentions_agent(event: &nostr::Event, agent_pubkey_hex: &str) -> bool {
})
}
-/// If `event` is a relay-signed workflow message, return its *attributed*
-/// author for inbound author gating; otherwise `None`.
-///
-/// Workflow `send_message` actions are signed by the **relay keypair**
-/// (`event.pubkey` = the relay's NIP-11 `self` key), not by the human who owns
-/// the workflow — so the plain author gate would drop them even though they
-/// carry `p` tags meant to wake mentioned agents. The relay attributes the
-/// message to the **workflow owner** (the pubkey that created the workflow,
-/// `workflow.owner_pubkey` relay-side) via the explicit `buzz:workflow-owner`
-/// tag emitted by `workflow_sink.rs`, and it has already verified that owner's
-/// access to the destination channel before emitting the event.
-///
-/// Recognition requires ALL of the following, failing closed otherwise:
-/// 1. kind `9` (stream message) — the only kind the workflow sink emits;
-/// 2. a known, syntactically valid relay `self` pubkey (fetched from NIP-11
-/// at startup) — no `relay_self`, no exemption;
-/// 3. `event.pubkey` == relay `self`, with a **valid event signature**
-/// verified here. The relay verifies signatures on submission, but this
-/// gate re-checks locally so the exemption never rests on an upstream
-/// guarantee it can't see;
-/// 4. **exactly one** tag exactly equal to `["buzz:workflow", "true"]` — no
-/// duplicates, no extra fields, no other value;
-/// 5. **exactly one** tag exactly equal to `["buzz:workflow-owner", ]`
-/// where the owner parses as a full pubkey — no duplicates, no extra
-/// fields. Mention `p` tags are never used for attribution, so who is
-/// @mentioned in the message text has no bearing on whose authority the
-/// gate evaluates.
-///
-/// The returned pubkey is gated exactly like a direct author: owner/sibling
-/// under `owner-only`, plus the explicit list under `allowlist`. A workflow
-/// owned by a random channel member therefore still cannot wake an
-/// owner-only agent.
-fn workflow_attributed_author(event: &nostr::Event, relay_self: Option<&str>) -> Option {
- // 1. Kind gate first — cheapest check, and everything below only makes
- // sense for the kind:9 messages the workflow sink emits.
- if event.kind.as_u16() as u32 != KIND_STREAM_MESSAGE {
- return None;
- }
-
- // 2. Relay identity must be known AND syntactically valid.
- let relay_self = nostr::PublicKey::from_hex(relay_self?).ok()?;
- if event.pubkey != relay_self {
- return None;
- }
-
- // 4. Exactly one marker tag, exactly ["buzz:workflow", "true"]. Collect
- // every tag with the marker key so duplicates or shape/value mismatches
- // (extra fields, wrong value) disqualify instead of being skipped over.
- let markers: Vec<&[String]> = event
- .tags
- .iter()
- .map(|t| t.as_slice())
- .filter(|s| s.first().map(|k| k.as_str()) == Some("buzz:workflow"))
- .collect();
- if markers.len() != 1 || markers[0] != ["buzz:workflow", "true"] {
- return None;
- }
-
- // 5. Exactly one owner tag, exactly ["buzz:workflow-owner", ].
- // The owner must parse as a full pubkey — not merely look hex-ish —
- // before it is fed into the owner/sibling/allowlist comparison.
- let owners: Vec<&[String]> = event
- .tags
- .iter()
- .map(|t| t.as_slice())
- .filter(|s| s.first().map(|k| k.as_str()) == Some("buzz:workflow-owner"))
- .collect();
- let [owner_tag] = owners.as_slice() else {
- return None;
- };
- let [_, owner_value] = owner_tag else {
- return None;
- };
- let owner = nostr::PublicKey::from_hex(owner_value).ok()?;
-
- // 3. Signature check last — it is the most expensive step, so only pay
- // for it once every structural requirement has already passed.
- if event.verify().is_err() {
- return None;
- }
-
- Some(owner.to_hex())
-}
-
fn is_owner_control_command(
event: &nostr::Event,
kind_u32: u32,
@@ -5815,273 +5694,6 @@ mod author_gate_tests {
}
}
-#[cfg(test)]
-mod workflow_attributed_author_tests {
- use super::*;
- use nostr::{EventBuilder, Keys, Kind, Tag};
-
- /// Build a kind:9 event signed by `signer` with the given extra tags.
- fn make_event(signer: &Keys, tags: Vec) -> nostr::Event {
- EventBuilder::new(Kind::from(KIND_STREAM_MESSAGE as u16), "wake up")
- .tags(tags)
- .sign_with_keys(signer)
- .expect("sign test event")
- }
-
- fn workflow_tags(owner_hex: &str, mention_hex: &str) -> Vec {
- vec![
- Tag::parse(["p", owner_hex]).unwrap(),
- Tag::parse(["h", "3204e3f9-fd09-4e95-b749-76966794c287"]).unwrap(),
- Tag::parse(["buzz:workflow", "true"]).unwrap(),
- Tag::parse(["buzz:workflow-owner", owner_hex]).unwrap(),
- Tag::parse(["p", mention_hex]).unwrap(),
- ]
- }
-
- #[test]
- fn relay_signed_workflow_message_attributes_to_workflow_owner_tag() {
- let relay = Keys::generate();
- let owner = Keys::generate().public_key().to_hex();
- let agent = Keys::generate().public_key().to_hex();
- let event = make_event(&relay, workflow_tags(&owner, &agent));
- assert_eq!(
- workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
- Some(owner),
- "a relay-signed buzz:workflow event must attribute to the \
- buzz:workflow-owner tag, not any mentioned agent"
- );
- }
-
- #[test]
- fn attribution_ignores_p_tags_entirely() {
- // Only the explicit buzz:workflow-owner tag attributes; p tags
- // (owner attribution + mentions) must have no effect on the gate.
- let relay = Keys::generate();
- let someone = Keys::generate().public_key().to_hex();
- let event = make_event(
- &relay,
- vec![
- Tag::parse(["p", &someone]).unwrap(),
- Tag::parse(["buzz:workflow", "true"]).unwrap(),
- ],
- );
- assert_eq!(
- workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
- None,
- "without a buzz:workflow-owner tag there is no attributed author, \
- even when p tags are present"
- );
- }
-
- #[test]
- fn malformed_owner_tag_value_attributes_to_no_one() {
- let relay = Keys::generate();
- let event = make_event(
- &relay,
- vec![
- Tag::parse(["buzz:workflow", "true"]).unwrap(),
- Tag::parse(["buzz:workflow-owner", "not-a-pubkey"]).unwrap(),
- ],
- );
- assert_eq!(
- workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
- None,
- "a buzz:workflow-owner value that is not 64-hex must be rejected"
- );
- }
-
- #[test]
- fn no_relay_self_means_no_exemption() {
- let relay = Keys::generate();
- let owner = Keys::generate().public_key().to_hex();
- let agent = Keys::generate().public_key().to_hex();
- let event = make_event(&relay, workflow_tags(&owner, &agent));
- assert_eq!(
- workflow_attributed_author(&event, None),
- None,
- "without a known relay self pubkey the exemption must not apply (fail closed)"
- );
- }
-
- #[test]
- fn non_relay_author_gets_no_exemption_even_with_workflow_tag() {
- // A member forging the buzz:workflow tag on their own event must not
- // be able to attribute it to someone else via a p tag.
- let forger = Keys::generate();
- let relay = Keys::generate();
- let owner = Keys::generate().public_key().to_hex();
- let agent = Keys::generate().public_key().to_hex();
- let event = make_event(&forger, workflow_tags(&owner, &agent));
- assert_eq!(
- workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
- None,
- "a buzz:workflow tag on a non-relay-signed event must be ignored"
- );
- }
-
- #[test]
- fn relay_signed_message_without_workflow_tag_gets_no_exemption() {
- let relay = Keys::generate();
- let owner = Keys::generate().public_key().to_hex();
- let event = make_event(&relay, vec![Tag::parse(["p", &owner]).unwrap()]);
- assert_eq!(
- workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
- None,
- "relay-signed events without the buzz:workflow tag keep the plain author gate"
- );
- }
-
- #[test]
- fn workflow_message_without_owner_tag_attributes_to_no_one() {
- let relay = Keys::generate();
- let event = make_event(&relay, vec![Tag::parse(["buzz:workflow", "true"]).unwrap()]);
- assert_eq!(
- workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
- None,
- "a workflow message with no buzz:workflow-owner tag has no attributed \
- author and must fall through to the plain (relay-pubkey) author gate"
- );
- }
-
- #[test]
- fn duplicate_marker_tags_disqualify() {
- let relay = Keys::generate();
- let owner = Keys::generate().public_key().to_hex();
- let event = make_event(
- &relay,
- vec![
- Tag::parse(["buzz:workflow", "true"]).unwrap(),
- Tag::parse(["buzz:workflow", "true"]).unwrap(),
- Tag::parse(["buzz:workflow-owner", &owner]).unwrap(),
- ],
- );
- assert_eq!(
- workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
- None,
- "more than one buzz:workflow marker tag must fail closed"
- );
- }
-
- #[test]
- fn marker_value_mismatch_disqualifies() {
- let relay = Keys::generate();
- let owner = Keys::generate().public_key().to_hex();
- for bad_marker in [
- Tag::parse(["buzz:workflow", "false"]).unwrap(),
- Tag::parse(["buzz:workflow"]).unwrap(),
- Tag::parse(["buzz:workflow", "true", "extra"]).unwrap(),
- ] {
- let event = make_event(
- &relay,
- vec![
- bad_marker.clone(),
- Tag::parse(["buzz:workflow-owner", &owner]).unwrap(),
- ],
- );
- assert_eq!(
- workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
- None,
- "marker tag {:?} is not exactly [\"buzz:workflow\", \"true\"] and must fail closed",
- bad_marker.as_slice()
- );
- }
- }
-
- #[test]
- fn duplicate_owner_tags_disqualify() {
- // Two owner tags — even with identical values — are ambiguous
- // provenance and must not attribute to anyone.
- let relay = Keys::generate();
- let owner = Keys::generate().public_key().to_hex();
- let other = Keys::generate().public_key().to_hex();
- for second_owner in [&owner, &other] {
- let event = make_event(
- &relay,
- vec![
- Tag::parse(["buzz:workflow", "true"]).unwrap(),
- Tag::parse(["buzz:workflow-owner", &owner]).unwrap(),
- Tag::parse(["buzz:workflow-owner", second_owner]).unwrap(),
- ],
- );
- assert_eq!(
- workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
- None,
- "duplicate buzz:workflow-owner tags must fail closed"
- );
- }
- }
-
- #[test]
- fn owner_tag_with_extra_fields_disqualifies() {
- let relay = Keys::generate();
- let owner = Keys::generate().public_key().to_hex();
- let event = make_event(
- &relay,
- vec![
- Tag::parse(["buzz:workflow", "true"]).unwrap(),
- Tag::parse(["buzz:workflow-owner", &owner, "extra"]).unwrap(),
- ],
- );
- assert_eq!(
- workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
- None,
- "an owner tag with extra fields is not the exact shape the relay \
- emits and must fail closed"
- );
- }
-
- #[test]
- fn wrong_kind_disqualifies() {
- let relay = Keys::generate();
- let owner = Keys::generate().public_key().to_hex();
- let agent = Keys::generate().public_key().to_hex();
- let event = EventBuilder::new(Kind::from(1u16), "wake up")
- .tags(workflow_tags(&owner, &agent))
- .sign_with_keys(&relay)
- .expect("sign test event");
- assert_eq!(
- workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
- None,
- "only kind:9 stream messages may use the workflow exemption"
- );
- }
-
- #[test]
- fn tampered_event_fails_signature_check() {
- // Alter the content after signing: pubkey still matches relay_self
- // and the tags are pristine, but the signature no longer covers the
- // event — the local verify must reject it.
- let relay = Keys::generate();
- let owner = Keys::generate().public_key().to_hex();
- let agent = Keys::generate().public_key().to_hex();
- let event = make_event(&relay, workflow_tags(&owner, &agent));
- let mut json = serde_json::to_value(&event).expect("event to JSON");
- json["content"] = serde_json::Value::String("tampered".into());
- let tampered: nostr::Event = serde_json::from_value(json).expect("tampered event parses");
- assert_eq!(
- workflow_attributed_author(&tampered, Some(&relay.public_key().to_hex())),
- None,
- "a tampered event must fail the local signature check"
- );
- }
-
- #[test]
- fn syntactically_invalid_relay_self_means_no_exemption() {
- let relay = Keys::generate();
- let owner = Keys::generate().public_key().to_hex();
- let agent = Keys::generate().public_key().to_hex();
- let event = make_event(&relay, workflow_tags(&owner, &agent));
- let long_not_hex = "zz".repeat(32);
- for bad_self in ["", "not-hex", long_not_hex.as_str()] {
- assert_eq!(
- workflow_attributed_author(&event, Some(bad_self)),
- None,
- "an invalid NIP-11 self value {bad_self:?} must disable the exemption"
- );
- }
- }
-}
-
#[cfg(test)]
mod observer_snapshot_race_tests {
use super::*;
diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs
index 6e3a9b24fa5..2c173646bac 100644
--- a/crates/buzz-acp/src/pool.rs
+++ b/crates/buzz-acp/src/pool.rs
@@ -1657,8 +1657,8 @@ fn workspace_section(cwd: &str) -> Option {
"[Workspace]\nYour absolute working directory is `{cwd}`. All workspace \
files — `AGENTS.md`, `RESEARCH/`, `PLANS/`, `GUIDES/`, `WORK_LOGS/`, \
`OUTBOX/` — and any repositories you clone (under `{cwd}/REPOS/`) live \
- here. This is where you already are; do not search `$HOME` or other \
- directories for them."
+ here. This is where you already are, so start here rather than scanning \
+ `$HOME`. Any specific path the user names is fine to read."
))
} else {
None
diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs
index fc3a16ddb95..17a818867dd 100644
--- a/crates/buzz-acp/src/relay.rs
+++ b/crates/buzz-acp/src/relay.rs
@@ -410,37 +410,6 @@ impl RestClient {
.await
}
- /// Fetch the relay's own signing pubkey from the public NIP-11 `/info`
- /// document (the `self` field, hex, normalized to lowercase).
- ///
- /// Used by the inbound author gate to recognize relay-signed workflow
- /// messages (`buzz:workflow`-tagged kind:9 events authored by the relay
- /// keypair) and gate them on their *attributed* author instead.
- ///
- /// Returns `None` when the document is unreachable, unparseable, or has
- /// no valid `self` field (e.g. the relay runs with an ephemeral key).
- /// Callers must treat `None` as "no relay-signed exemption" — fail closed
- /// to the plain author gate, never guess a pubkey.
- pub async fn fetch_relay_self(&self) -> Option {
- let url = format!("{}/info", self.base_url);
- let resp = self
- .http
- .get(&url)
- .header("Accept", "application/nostr+json")
- .send()
- .await
- .ok()?;
- if !resp.status().is_success() {
- return None;
- }
- let doc: Value = resp.json().await.ok()?;
- let self_hex = doc.get("self")?.as_str()?;
- if self_hex.len() != 64 || !self_hex.chars().all(|c| c.is_ascii_hexdigit()) {
- return None;
- }
- Some(self_hex.to_ascii_lowercase())
- }
-
/// Query events via the HTTP bridge: `POST /query` with NIP-98 auth.
///
/// Accepts a slice of `nostr::Filter` (serialized as JSON array).
diff --git a/crates/buzz-dev-mcp/src/paths.rs b/crates/buzz-dev-mcp/src/paths.rs
index 1770d562fa2..2c75a112f78 100644
--- a/crates/buzz-dev-mcp/src/paths.rs
+++ b/crates/buzz-dev-mcp/src/paths.rs
@@ -1,8 +1,10 @@
//! Path resolution and file I/O shared across dev-mcp tools.
//!
//! `resolve_path` resolves and canonicalizes a user-supplied path against a
-//! workspace root. No containment enforcement — the resolved path may land
-//! anywhere on the filesystem (consistent with the `shell` tool's posture).
+//! workspace root. A leading `~` expands to the user's home directory (bare
+//! `~` or `~/...`), matching the shell tool. No containment enforcement — the
+//! resolved path may land anywhere on the filesystem (consistent with the
+//! `shell` tool's posture).
//!
//! `read_text_file` builds on `resolve_path` to provide the full
//! resolve → stat → size-check → read → UTF-8 decode pipeline shared by
@@ -28,6 +30,17 @@ pub(crate) fn resolve_path(root: &Path, path: &str) -> Result {
#[cfg(windows)]
let path = &msys_to_windows(path);
+ // Expand a leading `~` (bare or `~/...`) to the user's home directory,
+ // matching the shell tool's tilde semantics. Without this, a user-named
+ // path like `~/.claude/skills/x` takes the relative branch and resolves
+ // under the workspace root (`/~/.claude/...`), which never exists.
+ // We deliberately do NOT handle `~user` (another user's home): that needs
+ // a passwd lookup and is out of scope, mirroring the conservative posture
+ // for un-mappable MSYS forms above. `~user...` falls through untouched and
+ // fails with the clear `path not accessible` error rather than mis-mapping.
+ let expanded = expand_tilde(path, home_dir().as_deref());
+ let path: &str = expanded.as_deref().unwrap_or(path);
+
let raw = Path::new(path);
let candidate: PathBuf = if raw.is_absolute() {
raw.to_path_buf()
@@ -41,6 +54,88 @@ pub(crate) fn resolve_path(root: &Path, path: &str) -> Result {
Ok(resolved)
}
+/// Expand a leading `~` to the user's home directory, returning `Some(expanded)`
+/// when a rewrite happened and `None` when the input should be used unchanged.
+///
+/// Handles the two shell forms that map deterministically to a home directory:
+/// - bare `~` -> `home`
+/// - `~/rest` (or `~\rest` on Windows) -> `/rest`
+///
+/// A leading `~` followed by anything else (`~user`, `~+`, `~foo`) is a form we
+/// cannot resolve without extra state, so it is left untouched — consistent with
+/// how `msys_to_windows` leaves un-mappable inputs alone. Returns `None` when
+/// `home` is `None` (unset) so the caller falls back to the raw path. Kept pure
+/// (home passed in) so it is testable without mutating process environment.
+fn expand_tilde(path: &str, home: Option<&str>) -> Option {
+ let rest = path.strip_prefix('~')?;
+ // Only a bare `~` or a `~` immediately followed by a path separator is a
+ // home-relative reference. Anything else (`~user`) is left to the caller.
+ let is_sep = |c: char| c == '/' || (cfg!(windows) && c == '\\');
+ if !rest.is_empty() && !rest.starts_with(is_sep) {
+ return None;
+ }
+
+ let home = home?;
+ if home.is_empty() {
+ return None;
+ }
+
+ if rest.is_empty() {
+ // Bare `~` -> home directory.
+ return Some(home.to_string());
+ }
+ // `~/rest` -> `/rest`. `rest` begins with a separator, so strip it to
+ // avoid an absolute-looking join and let `Path` re-add the separator.
+ let tail = rest.trim_start_matches(is_sep);
+ let joined = Path::new(home).join(tail);
+ Some(joined.to_string_lossy().into_owned())
+}
+
+/// The user's home directory from the environment. Reads `$HOME` first, falling
+/// back to `%USERPROFILE%` on Windows, then hands the raw values to `select_home`
+/// (pure, so it is testable without mutating process env). Returns `None` if no
+/// usable value is set or the value is not UTF-8.
+fn home_dir() -> Option {
+ let home = std::env::var_os("HOME").and_then(|v| v.into_string().ok());
+ #[cfg(windows)]
+ let userprofile = std::env::var_os("USERPROFILE").and_then(|v| v.into_string().ok());
+ #[cfg(not(windows))]
+ let userprofile: Option = None;
+ select_home(home.as_deref(), userprofile.as_deref())
+}
+
+/// Choose the home directory from the two env candidates, preferring `$HOME`.
+///
+/// `$HOME` is preferred because that is exactly what bash — and therefore the
+/// `shell` tool — expands `~` against, and `HOME` is passed through to the MCP
+/// child on every platform (see `buzz-agent`'s `PASSTHROUGH_ENV`). Picking
+/// `USERPROFILE` first on Windows would diverge from the shell tool whenever the
+/// two differ (a git-bash `HOME=/c/Users/x`, or an `mcpServers[].env` override),
+/// which is precisely the "match the shell tool" contract this fix exists for.
+/// `USERPROFILE` is only a Windows fallback for when `HOME` is unset.
+///
+/// On Windows the chosen value is passed through `msys_to_windows` so an MSYS
+/// `HOME` (`/c/Users/x`) becomes a native path (`C:\Users\x`) — `~` expansion
+/// happens after `msys_to_windows` in `resolve_path`, so the spliced-in home
+/// would otherwise never be translated and `canonicalize` would reject it. An
+/// MSYS form with no Windows equivalent (`/home/x`) falls through untranslated
+/// and fails with the clear `path not accessible` error, the correct outcome.
+/// Empty strings are treated as unset.
+fn select_home(home: Option<&str>, userprofile: Option<&str>) -> Option {
+ fn non_empty(v: Option<&str>) -> Option<&str> {
+ v.filter(|s| !s.is_empty())
+ }
+ let chosen = non_empty(home).or_else(|| non_empty(userprofile))?;
+ #[cfg(windows)]
+ {
+ Some(msys_to_windows(chosen))
+ }
+ #[cfg(not(windows))]
+ {
+ Some(chosen.to_string())
+ }
+}
+
/// Translate the MSYS/Cygwin absolute path forms bash would accept into a
/// native Windows path, matching `cygpath -w` semantics so the file tools
/// resolve the same inputs the `shell` tool does. Anything that is not a
@@ -208,6 +303,80 @@ mod tests {
assert!(p.ends_with("file.txt"));
}
+ // `expand_tilde` is pure (home is passed in), so these cases need no env
+ // mutation and cannot race parallel tests.
+ #[test]
+ fn expand_tilde_forms() {
+ let home = "/home/agent";
+
+ // Non-tilde inputs are never rewritten.
+ assert_eq!(expand_tilde("file.txt", Some(home)), None);
+ assert_eq!(expand_tilde("/abs/path", Some(home)), None);
+ assert_eq!(expand_tilde("sub/~notleading", Some(home)), None);
+
+ // `~user` and other non-separator suffixes are left for the caller.
+ assert_eq!(expand_tilde("~user/x", Some(home)), None);
+ assert_eq!(expand_tilde("~foo", Some(home)), None);
+
+ // Bare `~` and `~/rest` expand against the supplied home.
+ assert_eq!(expand_tilde("~", Some(home)), Some(home.to_string()));
+ let expanded = expand_tilde("~/.claude/skills/x", Some(home)).expect("expands");
+ assert_eq!(
+ expanded,
+ Path::new(home).join(".claude/skills/x").to_string_lossy()
+ );
+
+ // Unset or empty home -> no rewrite, caller falls back to the raw path.
+ assert_eq!(expand_tilde("~/rest", None), None);
+ assert_eq!(expand_tilde("~", None), None);
+ assert_eq!(expand_tilde("~/rest", Some("")), None);
+ }
+
+ // `select_home` is pure (both env candidates passed in), so it exercises the
+ // HOME-first preference and empty/unset handling without mutating process
+ // env or racing parallel tests. `select_home` itself does not gate the
+ // fallback by platform — `home_dir` is what only supplies `userprofile` on
+ // Windows — so these assertions hold identically on every platform.
+ #[test]
+ fn select_home_prefers_home() {
+ // $HOME wins when both are set.
+ assert_eq!(
+ select_home(Some("/home/agent"), Some("/other")),
+ Some("/home/agent".to_string())
+ );
+ // Empty $HOME is treated as unset -> fall back to the second candidate.
+ assert_eq!(
+ select_home(Some(""), Some("/other")),
+ Some("/other".to_string())
+ );
+ // No usable candidate -> None.
+ assert_eq!(select_home(None, None), None);
+ assert_eq!(select_home(Some(""), Some("")), None);
+ }
+
+ // End-to-end through `resolve_path`, exercising the real `home_dir()` env
+ // read: a `~/...` path resolves against the actual home directory, not the
+ // workspace root. Uses a temp file created under the real home so it does
+ // not mutate the environment.
+ #[test]
+ fn resolve_path_expands_tilde_against_home() {
+ let home = match home_dir() {
+ Some(h) if !h.is_empty() => h,
+ _ => return, // No home in this environment (e.g. minimal CI) — skip.
+ };
+ let marker = format!(".dev-mcp-tilde-test-{}", std::process::id());
+ let target = Path::new(&home).join(&marker);
+ fs::write(&target, b"z").expect("write under home");
+
+ let workspace = tempdir().expect("tempdir");
+ let resolved = resolve_path(workspace.path(), &format!("~/{marker}"))
+ .expect("tilde path resolves against home, not workspace");
+ let want = std::fs::canonicalize(&target).expect("canon");
+ assert_eq!(resolved, want);
+
+ let _ = fs::remove_file(&target);
+ }
+
// Windows MSYS-absolute path translation. These test `msys_to_windows`
// directly (the pure rewrite) rather than `resolve_path`, because the latter
// canonicalizes against the real filesystem and we want deterministic
@@ -239,6 +408,39 @@ mod tests {
assert_eq!(msys_to_windows(r"C:\Users\x"), r"C:\Users\x");
}
+ // Windows `select_home` behavior: HOME still wins over USERPROFILE, and
+ // an MSYS-form HOME is translated to a native path so the value spliced
+ // in during `~` expansion (which runs after `msys_to_windows`) resolves.
+ // Both candidates are passed in, so this needs no process-env mutation
+ // and does not silently no-op the way a real-env read would when HOME is
+ // unset on CI.
+ #[test]
+ fn select_home_translates_msys_home_and_prefers_it() {
+ // Divergent HOME/USERPROFILE: HOME wins, and its MSYS cygdrive form
+ // is translated to the native path so canonicalize can use it.
+ assert_eq!(
+ select_home(Some("/c/Users/agent"), Some(r"C:\Users\other")),
+ Some(r"C:\Users\agent".to_string())
+ );
+ // A native-form HOME is preferred and passes through unchanged.
+ assert_eq!(
+ select_home(Some(r"C:\Users\agent"), Some(r"C:\Users\other")),
+ Some(r"C:\Users\agent".to_string())
+ );
+ // HOME unset -> fall back to USERPROFILE (already native).
+ assert_eq!(
+ select_home(None, Some(r"C:\Users\other")),
+ Some(r"C:\Users\other".to_string())
+ );
+ // An MSYS HOME with no Windows equivalent (`/home/x`) is left
+ // untranslated; it fails downstream with a clear error rather than
+ // being mis-mapped — the intended conservative outcome.
+ assert_eq!(
+ select_home(Some("/home/agent"), None),
+ Some("/home/agent".to_string())
+ );
+ }
+
#[test]
fn relative_path_passes_through_unchanged() {
// No leading slash — left for the caller's `root.join`.
diff --git a/crates/buzz-media/src/error.rs b/crates/buzz-media/src/error.rs
index 14ce4afe1e8..5abbea6f580 100644
--- a/crates/buzz-media/src/error.rs
+++ b/crates/buzz-media/src/error.rs
@@ -72,8 +72,10 @@ pub enum MediaError {
/// Video duration exceeds the 600-second limit.
#[error("video too long: duration exceeds 600 seconds")]
DurationTooLong,
- /// Video resolution exceeds 3840×2160.
- #[error("video resolution too high: maximum is 3840x2160")]
+ /// Video resolution exceeds the 2160 short-edge / 3840 long-edge envelope.
+ #[error(
+ "video resolution too high: maximum is 2160 on the short edge and 3840 on the long edge"
+ )]
ResolutionTooHigh,
/// MP4 moov atom appears after mdat — not fast-start.
#[error("moov atom not at front of file (not fast-start)")]
diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs
index dfc61c42751..706c354d043 100644
--- a/crates/buzz-media/src/validation.rs
+++ b/crates/buzz-media/src/validation.rs
@@ -294,7 +294,7 @@ pub fn validate_content(bytes: &[u8], config: &MediaConfig) -> Result Result 3840 || height > 2160 {
+ let short_edge = width.min(height);
+ let long_edge = width.max(height);
+ if short_edge > 2160 || long_edge > 3840 {
return Err(MediaError::ResolutionTooHigh);
}
@@ -2547,6 +2551,43 @@ mod tests {
);
}
+ #[test]
+ fn test_validate_video_accepts_portrait_resolution() {
+ let mp4_bytes = build_mp4_bytes(true, b"avc1", 1_000, 2160, 3840, false);
+ let tmp = tempfile::NamedTempFile::new().unwrap();
+ std::fs::write(tmp.path(), &mp4_bytes).unwrap();
+
+ let meta = validate_video_file(tmp.path(), &test_config())
+ .expect("portrait video within the 2160x3840 envelope should be accepted");
+ assert_eq!((meta.width, meta.height), (2160, 3840));
+ }
+
+ #[test]
+ fn test_validate_video_rejects_resolution_above_short_edge_limit() {
+ let mp4_bytes = build_mp4_bytes(true, b"avc1", 1_000, 2161, 3840, false);
+ let tmp = tempfile::NamedTempFile::new().unwrap();
+ std::fs::write(tmp.path(), &mp4_bytes).unwrap();
+
+ let result = validate_video_file(tmp.path(), &test_config());
+ assert!(
+ matches!(result, Err(MediaError::ResolutionTooHigh)),
+ "expected ResolutionTooHigh, got {result:?}"
+ );
+ }
+
+ #[test]
+ fn test_validate_video_rejects_resolution_above_long_edge_limit() {
+ let mp4_bytes = build_mp4_bytes(true, b"avc1", 1_000, 2160, 3841, false);
+ let tmp = tempfile::NamedTempFile::new().unwrap();
+ std::fs::write(tmp.path(), &mp4_bytes).unwrap();
+
+ let result = validate_video_file(tmp.path(), &test_config());
+ assert!(
+ matches!(result, Err(MediaError::ResolutionTooHigh)),
+ "expected ResolutionTooHigh, got {result:?}"
+ );
+ }
+
#[test]
fn test_validate_video_resolution_too_high() {
let config = test_config();
diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs
index e056ff736ad..97c31c25611 100644
--- a/crates/buzz-relay/src/workflow_sink.rs
+++ b/crates/buzz-relay/src/workflow_sink.rs
@@ -255,9 +255,6 @@ impl ActionSink for RelayActionSink {
// - `p` tag attributes the message to the workflow owner
// - `h` tag scopes to the channel (NIP-29, canonical UUID)
// - `buzz:workflow` tag prevents recursive workflow triggering
- // - `buzz:workflow-owner` tag names the workflow owner explicitly,
- // so consumers (e.g. the ACP inbound author gate) can attribute
- // the message without inferring ownership from `p`-tag order
// - one `p` tag per `@Name` that resolves to a channel member,
// so mentioned agents are woken (wake is `p`-tag gated)
let mut tags = vec![
@@ -267,8 +264,6 @@ impl ActionSink for RelayActionSink {
.map_err(|e| ActionSinkError::EventBuild(format!("h tag: {e}")))?,
Tag::parse(["buzz:workflow", "true"])
.map_err(|e| ActionSinkError::EventBuild(format!("workflow tag: {e}")))?,
- Tag::parse(["buzz:workflow-owner", &author_pubkey_hex])
- .map_err(|e| ActionSinkError::EventBuild(format!("workflow owner tag: {e}")))?,
];
// Resolve `@Name` mentions to channel-member pubkeys and append a
@@ -712,18 +707,5 @@ mod integration_tests {
p_tag_targets.contains(&agent_hex.as_str()),
"mentioned member {agent_hex} must be p-tagged so it wakes; got {p_tag_targets:?}"
);
-
- let owner_tag = stored
- .event
- .tags
- .iter()
- .find(|t| t.as_slice().first().map(|s| s.as_str()) == Some("buzz:workflow-owner"))
- .and_then(|t| t.as_slice().get(1).map(|s| s.as_str()));
- assert_eq!(
- owner_tag,
- Some(author_hex.as_str()),
- "workflow owner must be named explicitly via buzz:workflow-owner \
- so consumers never infer ownership from p-tag order"
- );
}
}
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 7d06c4da91b..0a3c49aa2f9 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -118,6 +118,7 @@ export default defineConfig({
"**/drafts-all-fix-screenshots.spec.ts",
"**/inbox-refactor-screenshots.spec.ts",
"**/buzz-theme-screenshots.spec.ts",
+ "**/appearance-previews.spec.ts",
"**/channel-sort.spec.ts",
"**/identity-lost.spec.ts",
"**/deep-link-invite.spec.ts",
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index dc10343cc88..79e5de527ed 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -1100,6 +1100,7 @@ dependencies = [
"buzz-sdk",
"buzz-terminal",
"buzz-voice",
+ "buzz-ws-client",
"bytes",
"bzip2 0.6.1",
"chrono",
@@ -1256,6 +1257,20 @@ dependencies = [
"tokenizers",
]
+[[package]]
+name = "buzz-ws-client"
+version = "0.1.0"
+dependencies = [
+ "futures-util",
+ "nostr 0.44.7",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-tungstenite 0.29.0",
+ "tracing",
+ "url",
+]
+
[[package]]
name = "by_address"
version = "1.2.1"
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 01504852b6f..db089fc13fa 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -108,6 +108,7 @@ buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" }
buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" }
buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" }
+buzz_ws_client_pkg = { package = "buzz-ws-client", path = "../../crates/buzz-ws-client" }
portable-pty = "0.9"
iroh = { version = "1.0.2", optional = true }
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true }
diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs
index d1784c01fa4..749e1b1d625 100644
--- a/desktop/src-tauri/src/app_state.rs
+++ b/desktop/src-tauri/src/app_state.rs
@@ -2,14 +2,13 @@ use std::{
collections::HashMap,
io::Write,
sync::{
- atomic::{AtomicBool, AtomicU16, AtomicU8},
+ atomic::{AtomicBool, AtomicU16, AtomicU64, AtomicU8},
Arc, Mutex,
},
};
use nostr::{Keys, ToBech32};
use tauri::{AppHandle, Manager};
-#[cfg(feature = "mesh-llm")]
use tokio::sync::Mutex as AsyncMutex;
use crate::huddle::HuddleState;
@@ -32,12 +31,10 @@ pub struct AppState {
/// response (surfaced as an error) so the auth token never leaves the
/// validated relay origin.
pub media_fetch_client: reqwest::Client,
- /// Workspace-provided relay URL override. Set by `apply_workspace` on app
- /// init and takes priority over env vars and compile-time defaults.
pub relay_url_override: Mutex>,
- /// Set during backend setup when managed agents are eligible for launch
- /// restore. `apply_workspace` consumes it after installing the workspace
- /// relay and identity, so agents never start against the fallback relay.
+ pub workspace_apply_lock: Arc>,
+ pub workspace_apply_generation: AtomicU64,
+ /// Defers managed-agent restore until `apply_workspace` installs relay and identity.
pub managed_agent_restore_pending: AtomicBool,
/// Disabled by agent-managed profiles so agent profile updates survive start/restore.
pub managed_agent_profile_reconcile_enabled: AtomicBool,
@@ -206,6 +203,8 @@ pub fn build_app_state() -> AppState {
header across origins (redirect-hop SSRF)",
),
relay_url_override: Mutex::new(None),
+ workspace_apply_lock: Arc::new(AsyncMutex::new(())),
+ workspace_apply_generation: AtomicU64::new(0),
managed_agent_restore_pending: AtomicBool::new(false),
managed_agent_profile_reconcile_enabled: AtomicBool::new(true),
shutdown_started: AtomicBool::new(false),
diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs
index 9f1458e96fa..81bc2133528 100644
--- a/desktop/src-tauri/src/archive/mod.rs
+++ b/desktop/src-tauri/src/archive/mod.rs
@@ -22,6 +22,7 @@ mod metric_store;
mod pipeline;
pub mod store;
mod store_migrations;
+pub mod sync;
use pipeline::{commit_archive, plan_archive, query_buckets};
@@ -150,8 +151,20 @@ pub async fn archive_events(
state: State<'_, AppState>,
candidates: Vec,
) -> Result {
- let identity_pk = identity_pubkey(&state)?;
- let relay_url = relay_ws_url_with_override(&state);
+ archive_candidates(&state, candidates).await
+}
+
+/// The body of [`archive_events`], callable without a command invocation.
+///
+/// The native sync task archives through this directly: routing its batches
+/// back out to the renderer just to have the renderer invoke the command would
+/// reintroduce the IPC round trip the move exists to delete.
+pub(crate) async fn archive_candidates(
+ state: &AppState,
+ candidates: Vec,
+) -> Result {
+ let identity_pk = identity_pubkey(state)?;
+ let relay_url = relay_ws_url_with_override(state);
let now = now_secs();
// ── Phase 1: plan (blocking SQLite) ─────────────────────────────────────
@@ -163,8 +176,7 @@ pub async fn archive_events(
.await?;
// ── Phase 2: relay queries (async) ───────────────────────────────────────
- let state_ref: &AppState = &state;
- let bucket_results = query_buckets(plan.buckets, state_ref).await;
+ let bucket_results = query_buckets(plan.buckets, state).await;
// ── Phase 3: persist (blocking SQLite) ──────────────────────────────────
let owner_keys = {
@@ -286,6 +298,7 @@ fn validate_ephemeral_frame(
#[tauri::command]
pub async fn create_save_subscription(
state: State<'_, AppState>,
+ sync_state: State<'_, sync::ArchiveSyncState>,
scope_type: ScopeType,
scope_value: String,
kinds: Vec,
@@ -333,7 +346,9 @@ pub async fn create_save_subscription(
&scope_value,
&kinds_json,
now,
- )
+ )?;
+ sync_state.notify_subscriptions_changed().await;
+ Ok(())
}
/// Probe: the current user has access to `channel_id` (kind 39002 lists them).
@@ -426,6 +441,7 @@ async fn probe_event_readable(state: &AppState, event_id: &str) -> Result<(), St
#[tauri::command]
pub async fn merge_save_subscription_kinds(
state: State<'_, AppState>,
+ sync_state: State<'_, sync::ArchiveSyncState>,
kind: u32,
) -> Result<(), String> {
if kind > u32::from(u16::MAX) {
@@ -439,7 +455,9 @@ pub async fn merge_save_subscription_kinds(
run_archive_db_task(move |conn| {
store::merge_owner_p_kinds(conn, &identity_pk, &relay_url, &owner_pk, kind, now)
})
- .await
+ .await?;
+ sync_state.notify_subscriptions_changed().await;
+ Ok(())
}
// ── remove_save_subscription_kind ────────────────────────────────────────────
@@ -459,6 +477,7 @@ pub async fn merge_save_subscription_kinds(
#[tauri::command]
pub async fn remove_save_subscription_kind(
state: State<'_, AppState>,
+ sync_state: State<'_, sync::ArchiveSyncState>,
kind: u32,
) -> Result<(), String> {
if kind > u32::from(u16::MAX) {
@@ -471,7 +490,9 @@ pub async fn remove_save_subscription_kind(
run_archive_db_task(move |conn| {
store::remove_owner_p_kind(conn, &identity_pk, &relay_url, &owner_pk, kind)
})
- .await
+ .await?;
+ sync_state.notify_subscriptions_changed().await;
+ Ok(())
}
// ── list_save_subscriptions ──────────────────────────────────────────────────
@@ -496,12 +517,13 @@ pub async fn list_save_subscriptions(
#[tauri::command]
pub async fn delete_save_subscription(
state: State<'_, AppState>,
+ sync_state: State<'_, sync::ArchiveSyncState>,
scope_type: ScopeType,
scope_value: String,
) -> Result {
let identity_pk = identity_pubkey(&state)?;
let relay_url = relay_ws_url_with_override(&state);
- run_archive_db_task(move |conn| {
+ let removed = run_archive_db_task(move |conn| {
store::delete_save_subscription(
conn,
&identity_pk,
@@ -510,7 +532,11 @@ pub async fn delete_save_subscription(
&scope_value,
)
})
- .await
+ .await?;
+ if removed {
+ sync_state.notify_subscriptions_changed().await;
+ }
+ Ok(removed)
}
// ── read_archived_events ─────────────────────────────────────────────────────
diff --git a/desktop/src-tauri/src/archive/sync.rs b/desktop/src-tauri/src/archive/sync.rs
new file mode 100644
index 00000000000..4fe4452c818
--- /dev/null
+++ b/desktop/src-tauri/src/archive/sync.rs
@@ -0,0 +1,615 @@
+//! Rust archive sync task — the backend replacement for the renderer's
+//! `archiveSyncManager`.
+//!
+//! Opens one live relay subscription per saved archive config and forwards
+//! matched events to the existing archive pipeline in debounced batches. The
+//! renderer no longer sees archive traffic at all: previously every matched
+//! event crossed the IPC boundary twice (relay -> renderer, renderer ->
+//! `archive_events`) purely to be written to a SQLite file the backend owns.
+//!
+//! # Start gate
+//!
+//! The task is NOT self-starting. Kind 24200 is relay-*ephemeral*: frames that
+//! arrive before the listener opens are permanently lost, so the renderer must
+//! finish observer reconciliation (which seeds kind 24200 into the owner_p
+//! subscription) before any listener opens. That ordering is the whole reason
+//! `useArchiveSync` gated on `observerReconciled`, and it survives the move as
+//! an explicit `start_archive_sync` command issued after the same gate.
+
+use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc, time::Duration};
+
+use nostr::JsonUtil;
+use serde_json::json;
+use tauri::{AppHandle, Emitter, Manager, State};
+use tokio::{
+ sync::{mpsc, Mutex, Notify},
+ time::Instant,
+};
+use tokio_util::sync::CancellationToken;
+
+use super::{
+ store::SaveSubscription, ArchiveBatchResult, ArchiveCandidate, MatchedScope, ScopeType,
+};
+use crate::app_state::AppState;
+use crate::native_relay_client::{MatchedEvent, NativeRelayClient, RelaySession, Subscription};
+
+/// Flush once this many events are buffered. Parity with the renderer manager.
+const FLUSH_BATCH_SIZE: usize = 25;
+/// Maximum time an event waits in the buffer before being flushed.
+///
+/// This is a deadline measured from the FIRST buffered event, not an idle
+/// timer that each arrival extends. The renderer constant was named
+/// `FLUSH_IDLE_MS`, but its `scheduleFlush` returned early when a timer was
+/// already pending, so a steady trickle still flushed every 2s rather than
+/// never. The behavior is preserved; the name is corrected.
+const FLUSH_DEADLINE: Duration = Duration::from_millis(2_000);
+
+/// Emitted after a batch persists new agent-metric rows, so the renderer can
+/// invalidate its usage queries. Replaces the in-process `notifyAgentMetrics
+/// Changed()` call the manager made on the JS side of that same batch.
+const AGENT_METRICS_CHANGED_EVENT: &str = "archive-agent-metrics-changed";
+
+type BoxFuture<'a, T> = Pin + Send + 'a>>;
+
+/// Everything the sync loop needs from the outside world.
+///
+/// Injected rather than reached for so the loop's batching, demultiplexing,
+/// and reload behavior are testable without a relay, a database, or a Tauri
+/// app handle.
+pub(crate) trait ArchiveSyncIo: Send + Sync + 'static {
+ fn list_subscriptions(&self) -> BoxFuture<'_, Result, String>>;
+ fn set_subscriptions(&self, subscriptions: Vec) -> BoxFuture<'_, ()>;
+ fn archive(
+ &self,
+ candidates: Vec,
+ ) -> BoxFuture<'_, Result>;
+ fn notify_agent_metrics_changed(&self);
+}
+
+// ── Subscription planning ────────────────────────────────────────────────────
+
+/// The relay subscription set for `subscriptions`, plus the scope each
+/// subscription id maps back to when its events arrive.
+///
+/// The id encodes scope AND kinds, so a kinds change produces a different id:
+/// the session then closes the old subscription and opens the new one instead
+/// of leaving a stale filter live. Same reason the renderer keyed on both.
+fn plan_subscriptions(
+ subscriptions: &[SaveSubscription],
+) -> (Vec, HashMap) {
+ let mut planned = Vec::new();
+ let mut scopes = HashMap::new();
+
+ for sub in subscriptions {
+ let Some(scope_type) = parse_scope_type(&sub.scope_type) else {
+ eprintln!(
+ "buzz-desktop: archive sync: unknown scope_type {:?}, skipping",
+ sub.scope_type
+ );
+ continue;
+ };
+ // A malformed `kinds` column decodes as empty, matching the renderer
+ // decoder. The resulting filter matches nothing, which is the correct
+ // failure for a row we cannot interpret: archive nothing, drop nothing.
+ let kinds: Vec = serde_json::from_str(&sub.kinds).unwrap_or_default();
+ let id = subscription_id(&scope_type, &sub.scope_value, &kinds);
+ if scopes.contains_key(&id) {
+ continue;
+ }
+ planned.push(Subscription {
+ id: id.clone(),
+ filter: build_filter(&scope_type, &sub.scope_value, &kinds),
+ });
+ scopes.insert(
+ id,
+ MatchedScope {
+ scope_type,
+ scope_value: sub.scope_value.clone(),
+ },
+ );
+ }
+
+ (planned, scopes)
+}
+
+fn parse_scope_type(raw: &str) -> Option {
+ match raw {
+ "channel_h" => Some(ScopeType::ChannelH),
+ "owner_p" => Some(ScopeType::OwnerP),
+ "referenced_e" => Some(ScopeType::ReferencedE),
+ _ => None,
+ }
+}
+
+/// `limit: 0` — live tail only. Stored events are archived by the explicit
+/// backfill paths, so a non-zero limit would re-deliver history on every
+/// reconnect.
+fn build_filter(scope_type: &ScopeType, scope_value: &str, kinds: &[u64]) -> serde_json::Value {
+ let tag = match scope_type {
+ ScopeType::ChannelH => "#h",
+ ScopeType::OwnerP => "#p",
+ ScopeType::ReferencedE => "#e",
+ };
+ json!({ "kinds": kinds, "limit": 0, tag: [scope_value] })
+}
+
+fn subscription_id(scope_type: &ScopeType, scope_value: &str, kinds: &[u64]) -> String {
+ let mut sorted = kinds.to_vec();
+ sorted.sort_unstable();
+ let kinds = sorted
+ .iter()
+ .map(|k| k.to_string())
+ .collect::>()
+ .join(",");
+ format!("archive:{}:{scope_value}:{kinds}", scope_type.as_str())
+}
+
+// ── Batching ─────────────────────────────────────────────────────────────────
+
+/// Buffered candidates plus the deadline of the oldest one.
+#[derive(Default)]
+struct PendingBatch {
+ candidates: Vec,
+ /// Set when the buffer goes from empty to non-empty, cleared on take. The
+ /// deadline belongs to the oldest buffered event, so a steady trickle of
+ /// arrivals cannot postpone its flush indefinitely.
+ deadline: Option,
+}
+
+impl PendingBatch {
+ fn push(&mut self, candidate: ArchiveCandidate) {
+ if self.candidates.is_empty() {
+ self.deadline = Some(Instant::now() + FLUSH_DEADLINE);
+ }
+ self.candidates.push(candidate);
+ }
+
+ fn is_full(&self) -> bool {
+ self.candidates.len() >= FLUSH_BATCH_SIZE
+ }
+
+ fn take(&mut self) -> Vec {
+ self.deadline = None;
+ std::mem::take(&mut self.candidates)
+ }
+}
+
+// ── Sync loop ────────────────────────────────────────────────────────────────
+
+/// Drives one archive sync session until `cancel` fires.
+///
+/// Reload requests coalesce: `Notify::notify_one` stores at most one permit, so
+/// any number of subscription changes arriving during a reload produce exactly
+/// one follow-up pass — the same guarantee the renderer's single-flight
+/// `reloadPending` loop provided, without the bookkeeping.
+async fn run_sync(
+ io: &I,
+ reload: Arc,
+ mut events: mpsc::Receiver,
+ cancel: CancellationToken,
+) {
+ let mut scopes: HashMap = HashMap::new();
+ let mut pending = PendingBatch::default();
+
+ reconcile(io, &mut scopes).await;
+
+ loop {
+ // `Instant::far_future()` is not public; a long sleep stands in for
+ // "no deadline" so the select arm can be unconditional.
+ let deadline = pending
+ .deadline
+ .unwrap_or_else(|| Instant::now() + Duration::from_secs(3600));
+
+ tokio::select! {
+ _ = cancel.cancelled() => break,
+ _ = reload.notified() => {
+ reconcile(io, &mut scopes).await;
+ }
+ _ = tokio::time::sleep_until(deadline), if pending.deadline.is_some() => {
+ flush(io, pending.take()).await;
+ }
+ received = events.recv() => {
+ let Some(event) = received else { break };
+ // A subscription we already closed can still have events in
+ // flight; without its scope we cannot assert a match, and the
+ // backend re-verifies scope claims anyway, so drop it.
+ let Some(scope) = scopes.get(&event.subscription_id) else { continue };
+ pending.push(ArchiveCandidate {
+ raw_event_json: event.event.as_json(),
+ matched_scope: MatchedScope {
+ scope_type: scope.scope_type.clone(),
+ scope_value: scope.scope_value.clone(),
+ },
+ });
+ if pending.is_full() {
+ flush(io, pending.take()).await;
+ }
+ }
+ }
+ }
+
+ // Buffered events are already off the relay; dropping them on shutdown
+ // would lose them permanently for the ephemeral scope.
+ flush(io, pending.take()).await;
+}
+
+/// Reloads the saved subscriptions and applies them to the session.
+///
+/// A failed load leaves the previous set live rather than tearing everything
+/// down: a transient SQLite error must not silently stop archiving.
+async fn reconcile(io: &I, scopes: &mut HashMap) {
+ let subscriptions = match io.list_subscriptions().await {
+ Ok(subscriptions) => subscriptions,
+ Err(error) => {
+ eprintln!("buzz-desktop: archive sync: list_save_subscriptions failed: {error}");
+ return;
+ }
+ };
+ let (planned, next_scopes) = plan_subscriptions(&subscriptions);
+ io.set_subscriptions(planned).await;
+ *scopes = next_scopes;
+}
+
+/// Awaited rather than spawned: back-pressure through the session's bounded
+/// event channel is what keeps a catch-up storm from queueing unbounded
+/// archive work. The renderer's fire-and-forget was a property of living in
+/// an event loop it could not block, not a behavior worth porting.
+async fn flush(io: &I, candidates: Vec) {
+ if candidates.is_empty() {
+ return;
+ }
+ match io.archive(candidates).await {
+ // The backend is authoritative: a duplicate-only batch or one with no
+ // kind-44200 events must not invalidate usage queries.
+ Ok(result) if result.persisted_agent_metrics > 0 => io.notify_agent_metrics_changed(),
+ Ok(_) => {}
+ Err(error) => eprintln!("buzz-desktop: archive sync: archive_events failed: {error}"),
+ }
+}
+
+// ── Production wiring ────────────────────────────────────────────────────────
+
+struct AppIo {
+ app: AppHandle,
+ session: Arc,
+}
+
+impl ArchiveSyncIo for AppIo {
+ fn list_subscriptions(&self) -> BoxFuture<'_, Result, String>> {
+ Box::pin(async move {
+ let state: State<'_, AppState> = self.app.state();
+ let identity_pk = super::identity_pubkey(&state)?;
+ let relay_url = crate::relay::relay_ws_url_with_override(&state);
+ super::run_archive_db_task(move |conn| {
+ super::store::list_save_subscriptions(conn, &identity_pk, &relay_url)
+ })
+ .await
+ })
+ }
+
+ fn set_subscriptions(&self, subscriptions: Vec) -> BoxFuture<'_, ()> {
+ Box::pin(async move { self.session.set_subscriptions(subscriptions).await })
+ }
+
+ fn archive(
+ &self,
+ candidates: Vec,
+ ) -> BoxFuture<'_, Result> {
+ Box::pin(async move {
+ let state: State<'_, AppState> = self.app.state();
+ super::archive_candidates(&state, candidates).await
+ })
+ }
+
+ fn notify_agent_metrics_changed(&self) {
+ let _ = self.app.emit(AGENT_METRICS_CHANGED_EVENT, ());
+ }
+}
+
+/// Managed handle for the running sync task.
+#[derive(Default)]
+pub struct ArchiveSyncState {
+ running: Mutex>,
+ /// Highest `(epoch, lease)` this process has seen from either command.
+ ///
+ /// The renderer allocates leases synchronously in effect order, so they are
+ /// the app's intent order — which the IPC completion order is not. Both
+ /// commands ignore anything older, which is what makes a stale cleanup
+ /// harmless and a delayed start unable to resurrect a stopped task.
+ ///
+ /// The epoch is minted here, not in the renderer, because a lease counter
+ /// only exists for as long as the JS realm that holds it. A renderer reload
+ /// (`useReloadShortcut`, `RootErrorBoundary`, `useCommunityInit`) resets the
+ /// counter to zero while this state persists in the Tauri process, so
+ /// without an epoch the first post-reload start looks older than what the
+ /// backend already saw and is rejected forever. Ordering lexicographically
+ /// on `(epoch, lease)` means a newer realm outranks the old one no matter
+ /// where its local counter restarted.
+ ///
+ /// Every boundary the intent-order authority crosses, and why it holds:
+ ///
+ /// - effect remount, same realm: leases strictly increase within a realm.
+ /// - IPC arrival order: the lease is minted before `invoke`, so intent
+ /// order is fixed before the calls can race.
+ /// - renderer realm reload: a new epoch from this authority outranks the
+ /// dead realm's, whatever its counter said.
+ /// - webview recreation of the owning window: same as reload.
+ /// - a second window: it does not participate, by ownership rule. Archive
+ /// sync is app-global and main-window-owned, exactly as the main window
+ /// remains the owner of microphone capture (see `huddle::window`).
+ /// Epochs order realms in time; a companion window is a second realm in
+ /// space, and newest-wins cannot model two concurrent owners — a
+ /// companion's cleanup would cancel the live main-window task. Secondary
+ /// realms therefore never announce and never issue lifecycle commands.
+ /// Any future second-realm mount must revisit this.
+ /// - Tauri process restart: both clocks die together, so there is nothing
+ /// to order against.
+ latest: Mutex<(u64, u64)>,
+}
+
+struct RunningSync {
+ /// Identity + relay this task is bound to. A start request for the same
+ /// scope is a no-op, so a renderer remount does not churn the socket.
+ scope: (String, String),
+ cancel: CancellationToken,
+ reload: Arc,
+}
+
+/// Proof that the holder is the current archive-sync owner, and the lock that
+/// makes it true. Minted only by [`ArchiveSyncState::begin`], and required by
+/// [`NativeRelayClient::archive_session`].
+///
+/// Acquiring the shared relay session has to happen *inside* the ownership
+/// critical section, not after it. `NativeRelayClient::ensure_session` shuts
+/// down the previous scope's socket and installs its own within its own lock,
+/// and `attach_archive` replaces the session's archive event sender outright.
+/// Both are destructive on entry, so a superseded start that merely
+/// re-validated its mark *after* acquiring would already have torn down the
+/// newer owner's session — with nothing to restore it from, since a session is
+/// spawned rather than handed back. The damage is done by the call, so the
+/// fence has to be around the call.
+///
+/// Holding both guards across that acquisition is sound because it performs no
+/// I/O: `ensure_session` and `attach_archive` await only mutex acquisitions,
+/// `shutdown` is a synchronous cancel, and the socket connects on the task
+/// `start_managed` spawns. If either half ever grows an awaited network
+/// round-trip, this design must be revisited rather than quietly extended.
+///
+/// The lock order through the whole unit is `latest` -> `running` -> `current`
+/// -> `archive_events`, and nothing acquires in the reverse direction:
+/// [`ArchiveSyncState::end`] and
+/// [`ArchiveSyncState::notify_subscriptions_changed`] take the archive locks in
+/// the same order and never reach into the session, and the session's own paths
+/// (`session`, `fetch_events`, `run_session`) never reach back into archive
+/// state. So there is no cycle to deadlock on.
+///
+/// **What this token does not cover.** It serializes archive lifecycle against
+/// archive lifecycle, and nothing else needs it to: [`NativeRelayClient::session`]
+/// — the persona catalog's and unread catch-up's entry point — cannot replace
+/// the installed scope at all. It shares the session only on an exact scope
+/// match and otherwise leases a private one, so a finite request that arrives
+/// while a different scope is installed can neither shut this session down nor
+/// steal its archive sender. That is the only reason holding the token across
+/// acquisition is sufficient rather than merely necessary; if a second
+/// destructive path is ever added, it must take this token too.
+///
+/// The fields are private and the type is un-constructible outside this module,
+/// so the stale-start path is a compile error rather than a race to remember.
+/// Dropping the token releases ownership, which is why the command holds it
+/// until the sync task is spawned.
+pub(crate) struct ArchiveOwnership<'a> {
+ /// Field order is the lock order `begin` and `end` both take: `latest`,
+ /// then `running`. Rust drops fields in declaration order, so releasing
+ /// mirrors acquiring and the two halves can never interleave.
+ _latest: tokio::sync::MutexGuard<'a, (u64, u64)>,
+ _running: tokio::sync::MutexGuard<'a, Option>,
+}
+
+impl ArchiveSyncState {
+ /// Wakes the sync task so it reloads saved subscriptions.
+ ///
+ /// Called by the archive commands that mutate `save_subscriptions`. This
+ /// replaces the renderer's `onSubscriptionChange` notifier: the mutations
+ /// were already backend commands, so routing the signal through JS only
+ /// created a window where a write landed but nothing resubscribed.
+ pub(super) async fn notify_subscriptions_changed(&self) {
+ if let Some(running) = self.running.lock().await.as_ref() {
+ running.reload.notify_one();
+ }
+ }
+
+ /// Mints the epoch a renderer realm must hold before it may issue any
+ /// lifecycle command, and publishes it as the current mark in the same
+ /// critical section.
+ ///
+ /// A realm has to obtain this *before* its archive effect runs, and the
+ /// renderer awaits it. If announcing were just another unawaited `invoke`
+ /// beside the lifecycle calls, it would race them and recreate the
+ /// arrival-order bug one level up — the epoch would order announcements
+ /// rather than realms.
+ ///
+ /// Minting and publishing are one lock acquisition because announcing is
+ /// what supersedes the old realm. Holding the epoch counter separately —
+ /// so a mint could not block an in-flight lifecycle call — leaves a window
+ /// between mint and first use in which `latest` still names the dead realm,
+ /// and its delayed `start`/`stop` with any lease still wins. The new realm
+ /// cannot close that window itself: the reconciliation gate can keep its
+ /// first lifecycle call arbitrarily far behind its announcement.
+ ///
+ /// The published lease is `0`, the one value that outranks every mark the
+ /// previous realm can hold while still sitting below this realm's own first
+ /// lease. Publishing higher would reject the announcing realm's own start
+ /// and leave sync permanently unstarted.
+ async fn announce(&self) -> u64 {
+ let mut latest = self.latest.lock().await;
+ let epoch = latest.0 + 1;
+ *latest = (epoch, 0);
+ epoch
+ }
+
+ /// Takes ownership for a start under `(epoch, lease)`, installing the task
+ /// when it wins. Returns `None` when the caller must not proceed — either
+ /// the mark is stale or an equivalent task is already running.
+ ///
+ /// The whole ownership policy lives here rather than in the command so the
+ /// regression tests drive the same code production does. A test that
+ /// re-implemented "claim, then install" would pass against a command that
+ /// had stopped calling either one.
+ ///
+ /// The mark must be strictly newer than anything seen: that rejects both a
+ /// start a newer start already superseded, and one delayed past its own
+ /// stop. Comparison is lexicographic on `(epoch, lease)`, so any call from
+ /// a superseded realm loses regardless of how far its lease counter ran.
+ ///
+ /// The winner receives an [`ArchiveOwnership`] that keeps both guards held,
+ /// and [`NativeRelayClient::archive_session`] cannot be called without one.
+ /// Acquiring the shared session is therefore inside this critical section
+ /// rather than after it — see [`ArchiveOwnership`] for why "revalidate the
+ /// mark afterwards" cannot work here.
+ async fn begin(
+ &self,
+ mark: (u64, u64),
+ scope: (String, String),
+ cancel: CancellationToken,
+ reload: Arc,
+ ) -> Option> {
+ let mut latest = self.latest.lock().await;
+ if mark <= *latest {
+ return None;
+ }
+ *latest = mark;
+
+ let mut running = self.running.lock().await;
+ // A same-scope remount keeps its socket: reinstalling would tear down a
+ // healthy relay session to replace it with an identical one.
+ if running
+ .as_ref()
+ .is_some_and(|current| current.scope == scope)
+ {
+ return None;
+ }
+ if let Some(previous) = running.take() {
+ previous.cancel.cancel();
+ }
+ *running = Some(RunningSync {
+ scope,
+ cancel,
+ reload,
+ });
+ Some(ArchiveOwnership {
+ _latest: latest,
+ _running: running,
+ })
+ }
+
+ /// Releases ownership for a stop under `(epoch, lease)`, cancelling the
+ /// running task when it wins.
+ ///
+ /// Equality succeeds here, unlike [`Self::begin`]: a stop is the
+ /// counterpart of the start that minted its lease, so its own mark is
+ /// exactly the case it must act on. Advancing the mark is what stops a
+ /// start delayed past its own cleanup from resurrecting the task.
+ ///
+ /// The guard is held across the cancellation, exactly as [`Self::begin`]
+ /// holds it across the install. Releasing it first would reopen the very
+ /// window this ordering closes: a stop could clear its check, yield, let a
+ /// newer start install its task, and then cancel that task on resume —
+ /// stale cleanup stranding the newest owner. Both halves take `latest` then
+ /// `running`, so the two can never interleave and the order is deadlock-free.
+ async fn end(&self, mark: (u64, u64)) {
+ let mut latest = self.latest.lock().await;
+ if mark < *latest {
+ return;
+ }
+ *latest = mark;
+
+ if let Some(running) = self.running.lock().await.take() {
+ running.cancel.cancel();
+ }
+ }
+}
+
+/// Announce a renderer realm and obtain its epoch.
+///
+/// The renderer awaits this before its archive effect may issue any lifecycle
+/// command; see [`ArchiveSyncState::latest`] for the boundaries this closes.
+/// Only the main window announces — archive sync is app-global and
+/// main-window-owned.
+#[tauri::command]
+pub async fn announce_archive_sync_epoch(
+ sync_state: State<'_, ArchiveSyncState>,
+) -> Result {
+ Ok(sync_state.announce().await)
+}
+
+/// Start archive sync for the current identity.
+///
+/// Idempotent for the same identity + relay. Issued by the renderer only after
+/// observer reconciliation completes — see the module docs for why that gate
+/// cannot be moved into the backend.
+///
+/// `epoch` identifies the calling realm and `lease` orders this call against
+/// that realm's other lifecycle calls; see [`ArchiveSyncState::latest`].
+#[tauri::command]
+pub async fn start_archive_sync(
+ app: AppHandle,
+ state: State<'_, AppState>,
+ sync_state: State<'_, ArchiveSyncState>,
+ relay_client: State<'_, NativeRelayClient>,
+ epoch: u64,
+ lease: u64,
+) -> Result<(), String> {
+ let keys = state.signing_keys()?;
+ let relay_url = crate::relay::relay_ws_url_with_override(&state);
+ let scope = (keys.public_key().to_hex(), relay_url.clone());
+
+ // Only cheap handles before `begin`: a start that lost its mark, or a
+ // same-scope remount, must not open a relay socket just to drop it again.
+ let cancel = CancellationToken::new();
+ let reload = Arc::new(Notify::new());
+ let Some(ownership) = sync_state
+ .begin((epoch, lease), scope, cancel.clone(), Arc::clone(&reload))
+ .await
+ else {
+ return Ok(());
+ };
+
+ // No NIP-OA auth tag: this is the owner's own session, authenticated as
+ // the identity itself, exactly like the renderer's relay client.
+ //
+ // Inside the ownership critical section, holding `ownership`: acquiring the
+ // shared session is destructive to whatever scope holds it, so a superseded
+ // start must not be able to reach this line at all. See [`ArchiveOwnership`].
+ let (session, events) = relay_client
+ .archive_session(relay_url, keys, &ownership)
+ .await;
+
+ let io = AppIo {
+ app: app.clone(),
+ session: Arc::clone(&session),
+ };
+ tauri::async_runtime::spawn(async move {
+ run_sync(&io, reload, events, cancel).await;
+ session.set_subscriptions(Vec::new()).await;
+ });
+ Ok(())
+}
+
+/// Stop archive sync. Mirrors the renderer teardown that ran when the gate
+/// closed (identity change, community switch, unmount).
+///
+/// `(epoch, lease)` is the mark its own start allocated; a cleanup that has
+/// been superseded is a no-op rather than cancelling a newer owner's task.
+#[tauri::command]
+pub async fn stop_archive_sync(
+ sync_state: State<'_, ArchiveSyncState>,
+ epoch: u64,
+ lease: u64,
+) -> Result<(), String> {
+ sync_state.end((epoch, lease)).await;
+ Ok(())
+}
+
+#[cfg(test)]
+#[path = "sync_tests.rs"]
+mod sync_tests;
diff --git a/desktop/src-tauri/src/archive/sync_tests.rs b/desktop/src-tauri/src/archive/sync_tests.rs
new file mode 100644
index 00000000000..3a39b5d5856
--- /dev/null
+++ b/desktop/src-tauri/src/archive/sync_tests.rs
@@ -0,0 +1,983 @@
+//! Tests for the native archive sync loop.
+//!
+//! The loop is driven through the real `run_sync` body with a fake
+//! [`ArchiveSyncIo`] and a real event channel, so batching, demultiplexing,
+//! reload coalescing, and shutdown flush are exercised as the production task
+//! runs them — not as a struct poked directly.
+
+use super::*;
+use nostr::{EventBuilder, Keys, Kind, Tag};
+use std::sync::Mutex as StdMutex;
+
+// ── Test doubles ─────────────────────────────────────────────────────────────
+
+#[derive(Default)]
+struct FakeIo {
+ /// Successive results for `list_subscriptions`; the last one repeats so a
+ /// reload that outruns the script does not panic.
+ listings: StdMutex>>,
+ applied: StdMutex>>,
+ batches: StdMutex>>,
+ /// What `archive` returns; drives the notify-on-metrics assertion.
+ persisted_agent_metrics: StdMutex,
+ archive_fails: StdMutex,
+ metrics_notifications: StdMutex,
+}
+
+impl FakeIo {
+ fn with_listings(listings: Vec>) -> Self {
+ Self {
+ listings: StdMutex::new(listings),
+ ..Default::default()
+ }
+ }
+
+ fn applied(&self) -> Vec> {
+ self.applied.lock().unwrap().clone()
+ }
+
+ /// Flattened candidates in delivery order, as `(scope_value, event_id)`.
+ fn archived(&self) -> Vec> {
+ self.batches
+ .lock()
+ .unwrap()
+ .iter()
+ .map(|batch| {
+ batch
+ .iter()
+ .map(|c| c.matched_scope.scope_value.clone())
+ .collect()
+ })
+ .collect()
+ }
+}
+
+impl ArchiveSyncIo for FakeIo {
+ fn list_subscriptions(&self) -> BoxFuture<'_, Result, String>> {
+ Box::pin(async move {
+ let mut listings = self.listings.lock().unwrap();
+ if listings.is_empty() {
+ return Ok(Vec::new());
+ }
+ if listings.len() == 1 {
+ return Ok(listings[0].clone());
+ }
+ Ok(listings.remove(0))
+ })
+ }
+
+ fn set_subscriptions(&self, subscriptions: Vec) -> BoxFuture<'_, ()> {
+ Box::pin(async move {
+ self.applied.lock().unwrap().push(subscriptions);
+ })
+ }
+
+ fn archive(
+ &self,
+ candidates: Vec,
+ ) -> BoxFuture<'_, Result> {
+ Box::pin(async move {
+ self.batches.lock().unwrap().push(candidates);
+ if *self.archive_fails.lock().unwrap() {
+ return Err("archive failed".to_string());
+ }
+ Ok(ArchiveBatchResult {
+ persisted: 0,
+ persisted_agent_metrics: *self.persisted_agent_metrics.lock().unwrap(),
+ dropped: 0,
+ })
+ })
+ }
+
+ fn notify_agent_metrics_changed(&self) {
+ *self.metrics_notifications.lock().unwrap() += 1;
+ }
+}
+
+/// Yields until `condition` holds, then returns; fails the test if it never
+/// does. An unbounded spin turns a broken flush into a HUNG test instead of a
+/// failing one — and under a paused clock it also starves tokio's auto-advance,
+/// so the deadline that would have masked the bug never even fires.
+async fn wait_for(label: &str, mut condition: impl FnMut() -> bool) {
+ for _ in 0..10_000 {
+ if condition() {
+ return;
+ }
+ tokio::task::yield_now().await;
+ }
+ panic!("timed out waiting for {label}");
+}
+
+fn saved(scope_type: &str, scope_value: &str, kinds: &str) -> SaveSubscription {
+ SaveSubscription {
+ identity_pubkey: "owner".into(),
+ relay_url: "wss://relay.test".into(),
+ scope_type: scope_type.into(),
+ scope_value: scope_value.into(),
+ kinds: kinds.into(),
+ created_at: 0,
+ }
+}
+
+fn matched(subscription_id: &str) -> MatchedEvent {
+ let event = EventBuilder::new(Kind::Custom(9), "hello")
+ .tags([Tag::parse(vec!["h", "channel-a"]).unwrap()])
+ .sign_with_keys(&Keys::generate())
+ .unwrap();
+ MatchedEvent {
+ subscription_id: subscription_id.to_string(),
+ event: Box::new(event),
+ }
+}
+
+/// Runs `run_sync` on a task, handing back the controls the tests drive it
+/// with. Every test cancels and joins, so a loop that fails to observe
+/// cancellation hangs the test rather than passing silently.
+fn spawn_sync(
+ io: Arc,
+) -> (
+ mpsc::Sender,
+ Arc,
+ CancellationToken,
+ tokio::task::JoinHandle<()>,
+) {
+ let (tx, rx) = mpsc::channel(64);
+ let reload = Arc::new(Notify::new());
+ let cancel = CancellationToken::new();
+ let handle = {
+ let io = Arc::clone(&io);
+ let reload = Arc::clone(&reload);
+ let cancel = cancel.clone();
+ tokio::spawn(async move { run_sync(io.as_ref(), reload, rx, cancel).await })
+ };
+ (tx, reload, cancel, handle)
+}
+
+async fn stop(cancel: CancellationToken, handle: tokio::task::JoinHandle<()>) {
+ cancel.cancel();
+ handle.await.expect("sync task panicked");
+}
+
+// ── Filter construction ──────────────────────────────────────────────────────
+
+#[test]
+fn filters_match_the_renderer_shape_for_every_scope() {
+ // Verbatim parity with `buildFilter` in archiveSyncManager.ts: the tag key
+ // per scope and `limit: 0` are the contract with the relay, and a wrong
+ // tag key silently archives nothing.
+ let (planned, scopes) = plan_subscriptions(&[
+ saved("channel_h", "channel-a", "[9,40002]"),
+ saved("owner_p", "owner-pk", "[24200]"),
+ saved("referenced_e", "event-id", "[1]"),
+ ]);
+
+ let filters: Vec<_> = planned.iter().map(|s| s.filter.clone()).collect();
+ assert_eq!(
+ filters,
+ vec![
+ json!({ "kinds": [9, 40002], "limit": 0, "#h": ["channel-a"] }),
+ json!({ "kinds": [24200], "limit": 0, "#p": ["owner-pk"] }),
+ json!({ "kinds": [1], "limit": 0, "#e": ["event-id"] }),
+ ]
+ );
+ assert_eq!(scopes.len(), 3);
+ let scope = &scopes[&planned[0].id];
+ assert_eq!(scope.scope_type, ScopeType::ChannelH);
+ assert_eq!(scope.scope_value, "channel-a");
+}
+
+#[test]
+fn subscription_id_changes_when_kinds_change() {
+ // The id doubles as the relay subscription id, so a kinds change MUST
+ // produce a different one — otherwise the session sees the same id with a
+ // new filter and the old filter can stay live.
+ let (before, _) = plan_subscriptions(&[saved("channel_h", "channel-a", "[9]")]);
+ let (after, _) = plan_subscriptions(&[saved("channel_h", "channel-a", "[9,40002]")]);
+ assert_ne!(before[0].id, after[0].id);
+}
+
+#[test]
+fn subscription_id_is_stable_across_kind_ordering() {
+ // Same set written in a different order is the same subscription; without
+ // the sort it would churn the socket on every reload.
+ let (a, _) = plan_subscriptions(&[saved("channel_h", "channel-a", "[40002,9]")]);
+ let (b, _) = plan_subscriptions(&[saved("channel_h", "channel-a", "[9,40002]")]);
+ assert_eq!(a[0].id, b[0].id);
+}
+
+#[test]
+fn unknown_scope_type_is_skipped_not_guessed() {
+ let (planned, scopes) = plan_subscriptions(&[
+ saved("wat", "x", "[9]"),
+ saved("channel_h", "channel-a", "[9]"),
+ ]);
+ assert_eq!(planned.len(), 1);
+ assert_eq!(scopes.len(), 1);
+ assert_eq!(scopes[&planned[0].id].scope_value, "channel-a");
+}
+
+#[test]
+fn malformed_kinds_column_yields_a_matchless_filter() {
+ // Mirrors the renderer decoder: a row we cannot interpret archives
+ // nothing rather than subscribing to everything.
+ let (planned, _) = plan_subscriptions(&[saved("channel_h", "channel-a", "not json")]);
+ assert_eq!(
+ planned[0].filter,
+ json!({ "kinds": [], "limit": 0, "#h": ["channel-a"] })
+ );
+}
+
+#[test]
+fn duplicate_rows_produce_one_subscription() {
+ let (planned, _) = plan_subscriptions(&[
+ saved("channel_h", "channel-a", "[9]"),
+ saved("channel_h", "channel-a", "[9]"),
+ ]);
+ assert_eq!(planned.len(), 1);
+}
+
+// ── Loop behavior ────────────────────────────────────────────────────────────
+
+#[tokio::test]
+async fn subscribes_to_saved_configs_on_start() {
+ let io = Arc::new(FakeIo::with_listings(vec![vec![saved(
+ "channel_h",
+ "channel-a",
+ "[9]",
+ )]]));
+ let (_tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io));
+
+ // The first reconcile races the cancel; wait for it to land.
+ wait_for("initial subscribe", || !io.applied().is_empty()).await;
+ assert_eq!(io.applied()[0].len(), 1);
+ stop(cancel, handle).await;
+}
+
+#[tokio::test]
+async fn reload_signal_resubscribes_with_the_new_set() {
+ let io = Arc::new(FakeIo::with_listings(vec![
+ vec![saved("channel_h", "channel-a", "[9]")],
+ vec![
+ saved("channel_h", "channel-a", "[9]"),
+ saved("owner_p", "owner-pk", "[24200]"),
+ ],
+ ]));
+ let (_tx, reload, cancel, handle) = spawn_sync(Arc::clone(&io));
+
+ wait_for("initial subscribe", || !io.applied().is_empty()).await;
+ reload.notify_one();
+ wait_for("resubscribe", || io.applied().len() >= 2).await;
+
+ assert_eq!(io.applied()[1].len(), 2);
+ stop(cancel, handle).await;
+}
+
+#[tokio::test(start_paused = true)]
+async fn flushes_when_the_batch_size_is_reached() {
+ // Paused clock: the deadline can never fire, so a flush here is the size
+ // bound and nothing else. Without this the test passes on an off-by-one
+ // `is_full` — the deadline flushes the same 25 events 2s later and the
+ // assertion cannot tell the two apart.
+ let io = Arc::new(FakeIo::with_listings(vec![vec![saved(
+ "channel_h",
+ "channel-a",
+ "[9]",
+ )]]));
+ let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io));
+ wait_for("initial subscribe", || !io.applied().is_empty()).await;
+ let id = io.applied()[0][0].id.clone();
+
+ for _ in 0..(FLUSH_BATCH_SIZE - 1) {
+ tx.send(matched(&id)).await.unwrap();
+ }
+ // One short of the bound: nothing may flush.
+ wait_for("loop to drain the channel", || {
+ tx.capacity() == tx.max_capacity()
+ })
+ .await;
+ assert!(
+ io.archived().is_empty(),
+ "flushed before reaching the batch size"
+ );
+
+ tx.send(matched(&id)).await.unwrap();
+ wait_for("flush", || !io.archived().is_empty()).await;
+
+ // Exactly one batch of exactly FLUSH_BATCH_SIZE — a flush at the wrong
+ // boundary shows up here as a split or an oversized batch.
+ let archived = io.archived();
+ assert_eq!(archived.len(), 1);
+ assert_eq!(archived[0].len(), FLUSH_BATCH_SIZE);
+ assert!(archived[0].iter().all(|scope| scope == "channel-a"));
+ stop(cancel, handle).await;
+}
+
+#[tokio::test(start_paused = true)]
+async fn flushes_a_partial_batch_after_the_deadline() {
+ let io = Arc::new(FakeIo::with_listings(vec![vec![saved(
+ "channel_h",
+ "channel-a",
+ "[9]",
+ )]]));
+ let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io));
+ wait_for("initial subscribe", || !io.applied().is_empty()).await;
+ let id = io.applied()[0][0].id.clone();
+
+ tx.send(matched(&id)).await.unwrap();
+ // Just under the deadline: still buffered.
+ tokio::time::sleep(FLUSH_DEADLINE - Duration::from_millis(1)).await;
+ assert!(io.archived().is_empty(), "flushed before the deadline");
+
+ tokio::time::sleep(Duration::from_millis(2)).await;
+ wait_for("flush", || !io.archived().is_empty()).await;
+ assert_eq!(io.archived()[0].len(), 1);
+ stop(cancel, handle).await;
+}
+
+#[tokio::test(start_paused = true)]
+async fn a_trickle_cannot_postpone_the_deadline_indefinitely() {
+ // The deadline belongs to the OLDEST buffered event. An idle timer reset
+ // on each arrival would leave a steady trickle unflushed forever.
+ let io = Arc::new(FakeIo::with_listings(vec![vec![saved(
+ "channel_h",
+ "channel-a",
+ "[9]",
+ )]]));
+ let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io));
+ wait_for("initial subscribe", || !io.applied().is_empty()).await;
+ let id = io.applied()[0][0].id.clone();
+
+ for _ in 0..4 {
+ tx.send(matched(&id)).await.unwrap();
+ tokio::time::sleep(FLUSH_DEADLINE / 2).await;
+ }
+ wait_for("flush", || !io.archived().is_empty()).await;
+ assert!(!io.archived().is_empty());
+ stop(cancel, handle).await;
+}
+
+#[tokio::test]
+async fn events_for_an_unknown_subscription_are_dropped() {
+ let io = Arc::new(FakeIo::with_listings(vec![vec![saved(
+ "channel_h",
+ "channel-a",
+ "[9]",
+ )]]));
+ let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io));
+ wait_for("initial subscribe", || !io.applied().is_empty()).await;
+
+ // An event from a subscription we already closed: no scope, no archive.
+ tx.send(matched("archive:channel_h:gone:[9]"))
+ .await
+ .unwrap();
+ cancel.cancel();
+ handle.await.unwrap();
+
+ assert!(
+ io.archived().is_empty(),
+ "archived an event with no known scope"
+ );
+}
+
+#[tokio::test]
+async fn buffered_events_flush_on_shutdown() {
+ // Ephemeral kind 24200 cannot be re-fetched, so a buffered event dropped
+ // at teardown is lost permanently.
+ let io = Arc::new(FakeIo::with_listings(vec![vec![saved(
+ "owner_p", "owner-pk", "[24200]",
+ )]]));
+ let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io));
+ wait_for("initial subscribe", || !io.applied().is_empty()).await;
+ let id = io.applied()[0][0].id.clone();
+
+ tx.send(matched(&id)).await.unwrap();
+ // Wait until the loop has actually taken the event off the channel;
+ // cancelling first would test a race, not the shutdown flush.
+ wait_for("loop to drain the channel", || {
+ tx.capacity() == tx.max_capacity()
+ })
+ .await;
+ cancel.cancel();
+ handle.await.unwrap();
+
+ let archived = io.archived();
+ assert_eq!(archived.len(), 1, "shutdown did not flush the buffer");
+ assert_eq!(archived[0], vec!["owner-pk".to_string()]);
+}
+
+#[tokio::test]
+async fn notifies_agent_metrics_only_when_the_backend_persisted_some() {
+ let io = Arc::new(FakeIo::with_listings(vec![vec![saved(
+ "owner_p", "owner-pk", "[44200]",
+ )]]));
+ *io.persisted_agent_metrics.lock().unwrap() = 2;
+ let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io));
+ wait_for("initial subscribe", || !io.applied().is_empty()).await;
+ let id = io.applied()[0][0].id.clone();
+
+ for _ in 0..FLUSH_BATCH_SIZE {
+ tx.send(matched(&id)).await.unwrap();
+ }
+ wait_for("flush", || !io.archived().is_empty()).await;
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ assert_eq!(*io.metrics_notifications.lock().unwrap(), 1);
+ stop(cancel, handle).await;
+}
+
+#[tokio::test]
+async fn does_not_notify_when_nothing_was_persisted() {
+ let io = Arc::new(FakeIo::with_listings(vec![vec![saved(
+ "owner_p", "owner-pk", "[44200]",
+ )]]));
+ // persisted_agent_metrics stays 0: a duplicate-only batch must not
+ // invalidate the renderer's usage queries.
+ let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io));
+ wait_for("initial subscribe", || !io.applied().is_empty()).await;
+ let id = io.applied()[0][0].id.clone();
+
+ for _ in 0..FLUSH_BATCH_SIZE {
+ tx.send(matched(&id)).await.unwrap();
+ }
+ wait_for("flush", || !io.archived().is_empty()).await;
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ assert_eq!(*io.metrics_notifications.lock().unwrap(), 0);
+ stop(cancel, handle).await;
+}
+
+#[tokio::test]
+async fn a_failed_archive_call_does_not_notify_or_stop_the_loop() {
+ let io = Arc::new(FakeIo::with_listings(vec![vec![saved(
+ "channel_h",
+ "channel-a",
+ "[9]",
+ )]]));
+ *io.archive_fails.lock().unwrap() = true;
+ let (tx, _reload, cancel, handle) = spawn_sync(Arc::clone(&io));
+ wait_for("initial subscribe", || !io.applied().is_empty()).await;
+ let id = io.applied()[0][0].id.clone();
+
+ for _ in 0..(FLUSH_BATCH_SIZE * 2) {
+ tx.send(matched(&id)).await.unwrap();
+ }
+ wait_for("second flush", || io.archived().len() >= 2).await;
+ assert_eq!(*io.metrics_notifications.lock().unwrap(), 0);
+ stop(cancel, handle).await;
+}
+
+#[tokio::test]
+async fn a_failed_listing_leaves_the_previous_subscriptions_live() {
+ // A transient SQLite error must not silently stop archiving.
+ struct FailingList(Arc, StdMutex);
+ impl ArchiveSyncIo for FailingList {
+ fn list_subscriptions(&self) -> BoxFuture<'_, Result, String>> {
+ Box::pin(async move {
+ let mut failed = self.1.lock().unwrap();
+ if *failed {
+ return Err("db is busy".into());
+ }
+ *failed = true;
+ Ok(vec![saved("channel_h", "channel-a", "[9]")])
+ })
+ }
+ fn set_subscriptions(&self, subscriptions: Vec) -> BoxFuture<'_, ()> {
+ self.0.set_subscriptions(subscriptions)
+ }
+ fn archive(
+ &self,
+ candidates: Vec,
+ ) -> BoxFuture<'_, Result> {
+ self.0.archive(candidates)
+ }
+ fn notify_agent_metrics_changed(&self) {
+ self.0.notify_agent_metrics_changed();
+ }
+ }
+
+ let inner = Arc::new(FakeIo::default());
+ let io = Arc::new(FailingList(Arc::clone(&inner), StdMutex::new(false)));
+ let (tx, rx) = mpsc::channel(8);
+ let reload = Arc::new(Notify::new());
+ let cancel = CancellationToken::new();
+ let handle = {
+ let io = Arc::clone(&io);
+ let reload = Arc::clone(&reload);
+ let cancel = cancel.clone();
+ tokio::spawn(async move { run_sync(io.as_ref(), reload, rx, cancel).await })
+ };
+
+ wait_for("initial subscribe", || !inner.applied().is_empty()).await;
+ let id = inner.applied()[0][0].id.clone();
+ reload.notify_one();
+ tokio::time::sleep(Duration::from_millis(10)).await;
+
+ // The failed reload applied nothing, and the original scope still
+ // demultiplexes — so events keep being archived.
+ assert_eq!(inner.applied().len(), 1);
+ for _ in 0..FLUSH_BATCH_SIZE {
+ tx.send(matched(&id)).await.unwrap();
+ }
+ wait_for("flush", || !inner.archived().is_empty()).await;
+ stop(cancel, handle).await;
+}
+
+// ── Lifecycle ownership ──────────────────────────────────────────────────────
+//
+// The renderer fires `start_archive_sync` and `stop_archive_sync` without
+// awaiting them, and Tauri commands may complete in any order. These tests
+// drive `ArchiveSyncState` through the same `claim_start`/`claim_stop`/
+// `install`/`stop` seam the commands use, applying the halves in a chosen
+// order — which is the one thing the mounted-hook test cannot do, because its
+// mock `invoke` resolves immediately and can only observe call order.
+
+/// Runs the ownership half of `start_archive_sync` under `lease`, returning the
+/// installed task's cancel token. `None` means the start did not take
+/// ownership. Mirrors the command minus the relay session and spawned loop,
+/// which the ordering invariant does not involve.
+///
+/// The ownership token is dropped before returning, so these tests apply the
+/// halves sequentially as before. The test that needs it held across an
+/// acquisition calls [`ArchiveSyncState::begin`] directly.
+///
+/// The token is the task's identity: two starts produce distinct tokens, so a
+/// test can name WHICH task survived an interleaving rather than only that one
+/// did. "Something is running" is satisfiable by the stale start's task.
+async fn start_half(
+ state: &ArchiveSyncState,
+ mark: (u64, u64),
+ scope: (&str, &str),
+) -> Option {
+ let cancel = CancellationToken::new();
+ state
+ .begin(
+ mark,
+ (scope.0.to_string(), scope.1.to_string()),
+ cancel.clone(),
+ Arc::new(Notify::new()),
+ )
+ .await
+ .is_some()
+ .then_some(cancel)
+}
+
+/// Runs `stop_archive_sync` under `mark`.
+async fn stop_half(state: &ArchiveSyncState, mark: (u64, u64)) {
+ state.end(mark).await;
+}
+
+async fn is_running(state: &ArchiveSyncState) -> bool {
+ state.running.lock().await.is_some()
+}
+
+/// Whether the installed task is the one `cancel` belongs to.
+///
+/// `CancellationToken` is not `PartialEq`, so identity is checked through the
+/// shared state the clones observe: cancelling the candidate must cancel the
+/// installed task if and only if they are the same instance. The token is
+/// consumed by the check, so callers assert identity last.
+async fn running_is(state: &ArchiveSyncState, cancel: &CancellationToken) -> bool {
+ let installed = match state.running.lock().await.as_ref() {
+ Some(running) => running.cancel.clone(),
+ None => return false,
+ };
+ cancel.cancel();
+ installed.is_cancelled()
+}
+
+const SCOPE: (&str, &str) = ("owner-pubkey", "wss://relay.example");
+
+/// Wren's schedule: `start2` reaches the backend before the delayed `start1`,
+/// then the old effect's cleanup runs.
+///
+/// A backend-issued generation fails here — `start1` arrives last, so it mints
+/// the newest token and hands it to the stalest caller, whose `stop` then
+/// legitimately cancels the task the new effect depends on. The lease is
+/// allocated in the renderer in effect order, so `start1` is stale on arrival.
+#[tokio::test]
+async fn a_start_that_arrives_after_a_newer_one_cannot_supersede_it() {
+ let state = ArchiveSyncState::default();
+
+ // Effect 2 wins the race to the backend.
+ let start2 = start_half(&state, (1, 2), SCOPE)
+ .await
+ .expect("start2 installs");
+ // Effect 1's delayed start lands second and must not take ownership.
+ assert!(
+ start_half(&state, (1, 1), SCOPE).await.is_none(),
+ "a start older than the newest lease must not install"
+ );
+ // Effect 1's cleanup, holding lease 1.
+ stop_half(&state, (1, 1)).await;
+
+ assert!(
+ !start2.is_cancelled(),
+ "start2's task must not have been cancelled by lease 1's stop"
+ );
+ // Identity, not survival: a lease mutant that keeps the WRONG task alive
+ // would satisfy "something is running", so name the instance.
+ assert!(
+ running_is(&state, &start2).await,
+ "the surviving task must be start2's instance — stale cleanup cancelled \
+ the newest start, the exact stranding this lease prevents"
+ );
+}
+
+/// The other half of the invariant: a start delayed past its own cleanup must
+/// not resurrect sync after the renderer gate closed.
+#[tokio::test]
+async fn a_start_that_arrives_after_its_own_stop_cannot_resurrect_sync() {
+ let state = ArchiveSyncState::default();
+
+ stop_half(&state, (1, 3)).await;
+ assert!(
+ start_half(&state, (1, 3), SCOPE).await.is_none(),
+ "a start whose own stop already ran must not install"
+ );
+
+ assert!(
+ !is_running(&state).await,
+ "sync was resurrected after its owner stopped"
+ );
+}
+
+/// The ordinary sequence still works: each remount's start takes ownership and
+/// its own cleanup stops it.
+#[tokio::test]
+async fn ordered_start_and_stop_still_take_effect() {
+ let state = ArchiveSyncState::default();
+
+ let first = start_half(&state, (1, 1), SCOPE)
+ .await
+ .expect("first start");
+ assert!(is_running(&state).await, "sync must be running after start");
+
+ stop_half(&state, (1, 1)).await;
+ assert!(!is_running(&state).await, "its own stop must take effect");
+ assert!(first.is_cancelled(), "the stopped task must be cancelled");
+
+ let second = start_half(&state, (1, 2), SCOPE)
+ .await
+ .expect("newer start");
+ assert!(
+ running_is(&state, &second).await,
+ "the newer start's own task must be the installed one"
+ );
+
+ stop_half(&state, (1, 2)).await;
+ assert!(!is_running(&state).await, "the newer stop must take effect");
+}
+
+/// A same-scope remount that reaches the backend in order is still a no-op at
+/// the socket, so the lease does not undo the idempotence the port relies on.
+#[tokio::test]
+async fn a_same_scope_restart_does_not_churn_the_running_task() {
+ let state = ArchiveSyncState::default();
+
+ let first = start_half(&state, (1, 1), SCOPE)
+ .await
+ .expect("first start");
+ assert!(
+ start_half(&state, (1, 2), SCOPE).await.is_none(),
+ "a newer start for the same scope must not reinstall"
+ );
+ assert!(
+ !first.is_cancelled(),
+ "the original task must not be torn down"
+ );
+ assert!(
+ running_is(&state, &first).await,
+ "the original task must still be the installed one"
+ );
+}
+
+/// An identity or relay change must replace the task rather than leaving the
+/// old scope's socket live.
+#[tokio::test]
+async fn a_scope_change_replaces_the_running_task() {
+ let state = ArchiveSyncState::default();
+
+ let first = start_half(&state, (1, 1), SCOPE)
+ .await
+ .expect("first start");
+ let second = start_half(&state, (1, 2), ("other-pubkey", SCOPE.1))
+ .await
+ .expect("a different scope must install");
+
+ assert!(
+ first.is_cancelled(),
+ "the replaced task must be cancelled, not leaked"
+ );
+ assert!(
+ running_is(&state, &second).await,
+ "the new scope's task must be the installed one"
+ );
+}
+
+/// A stop must hold its lease guard across the cancellation, not just across
+/// the check.
+///
+/// The other lifecycle tests apply the two halves sequentially, so they cannot
+/// see this: they pass against an `end` that releases `latest_lease` before
+/// taking `running`. That version leaves a window — a stop clears its lease
+/// check, yields, a newer start installs its task, and the resuming stop
+/// cancels it. Stale cleanup strands the newest owner, which is the exact
+/// failure the lease exists to prevent.
+///
+/// Rather than race it (unreliable either way), this observes the invariant
+/// directly: hold `running` so a concurrent `end` must park after its lease
+/// check, then ask whether `latest_lease` is still held. Held means the stop
+/// and a competing start are mutually exclusive over the whole operation.
+#[tokio::test]
+async fn a_stop_holds_its_lease_guard_across_the_cancellation() {
+ let state = Arc::new(ArchiveSyncState::default());
+
+ start_half(&state, (1, 1), SCOPE)
+ .await
+ .expect("first start");
+
+ // Block the second half of `end` by owning the lock it must acquire.
+ let running_guard = state.running.lock().await;
+
+ let stopper = tokio::spawn({
+ let state = Arc::clone(&state);
+ async move { state.end((1, 2)).await }
+ });
+
+ // Let the stop run until it blocks on `running`.
+ tokio::task::yield_now().await;
+ tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+
+ // The parked stop is past its lease check. If it still holds the lease
+ // guard, no concurrent start can install a task for it to cancel.
+ let lease_held = state.latest.try_lock().is_err();
+
+ drop(running_guard);
+ stopper.await.expect("stop task");
+
+ assert!(
+ lease_held,
+ "a stop parked mid-cancellation released its lease guard: a newer start \
+ can install a task in that window, which this stop then cancels — \
+ stale cleanup stranding the newest owner"
+ );
+}
+
+// ── Realm epochs ─────────────────────────────────────────────────────────────
+//
+// A lease counter lives and dies with its JS realm, but this state lives for
+// the whole Tauri process. A renderer reload (useReloadShortcut,
+// RootErrorBoundary, useCommunityInit) restarts the counter at zero, so
+// ordering on the lease alone makes every post-reload call look stale. The
+// epoch is minted here so successive realms can be ordered by an authority
+// that outlives them.
+
+/// After a renderer reload, the new realm's first start must take ownership
+/// even though its lease counter restarted below what the backend has seen.
+///
+/// This is the regression for the reload boundary: seed the backend as if a
+/// realm had already run, then have a fresh realm announce and start from
+/// lease 1. Ordering on the lease alone rejects it forever — and because the
+/// RootErrorBoundary reload is the recovery path for a renderer crash, that
+/// would make crash recovery the thing that permanently kills archive sync.
+#[tokio::test]
+async fn a_realm_that_reloaded_owns_sync_despite_restarting_its_lease() {
+ let state = ArchiveSyncState::default();
+
+ // Realm 1 ran and got as far as lease 2 (StrictMode alone reaches this).
+ let first_epoch = state.announce().await;
+ start_half(&state, (first_epoch, 1), SCOPE)
+ .await
+ .expect("realm 1 start");
+ stop_half(&state, (first_epoch, 2)).await;
+
+ // The realm is destroyed by reload; the JS lease counter restarts at 1.
+ let second_epoch = state.announce().await;
+ assert!(
+ second_epoch > first_epoch,
+ "each announcing realm must outrank the last"
+ );
+ let reloaded = start_half(&state, (second_epoch, 1), SCOPE)
+ .await
+ .expect("the first start after a reload must own sync");
+
+ assert!(
+ running_is(&state, &reloaded).await,
+ "the post-reload realm's task must be the installed one — ordering on \
+ the lease alone leaves sync permanently absent after any reload"
+ );
+}
+
+/// A call from a realm that has already been superseded must lose from the
+/// moment the new realm ANNOUNCES — not merely once the new realm has managed
+/// to land a lifecycle call of its own.
+///
+/// This is the stale-cleanup invariant replayed one level up: the dead realm's
+/// in-flight cleanup arrives after the new realm has taken over, and a lease
+/// comparison alone would let its larger counter win.
+///
+/// The old calls are sent BEFORE any epoch-2 lifecycle call deliberately.
+/// That gap is reachable in production and can be arbitrarily long: the new
+/// realm awaits its announcement, then waits on observer reconciliation before
+/// it may issue a start at all. A version that mints the epoch without
+/// publishing it passes any schedule where the new realm calls first, because
+/// the new call — not the announcement — is what advanced the mark.
+#[tokio::test]
+async fn a_delayed_call_from_a_superseded_realm_cannot_supersede_the_new_one() {
+ let state = ArchiveSyncState::default();
+
+ // The old realm ran and owns the installed task.
+ let old_epoch = state.announce().await;
+ let stale = start_half(&state, (old_epoch, 1), SCOPE)
+ .await
+ .expect("the old realm installs");
+
+ // The new realm announces. It has issued no lifecycle call yet.
+ let new_epoch = state.announce().await;
+
+ // The dead realm's delayed start and cleanup, both with high leases, land
+ // in the gap between the new realm's announcement and its first call.
+ //
+ // The delayed start carries a DIFFERENT scope on purpose. Under `SCOPE` it
+ // would hit the same-scope remount no-op and return `None` whatever the
+ // mark said, so the assertion would hold against a backend that had stopped
+ // comparing marks entirely — a pass for a benign reason is not a pass.
+ assert!(
+ start_half(&state, (old_epoch, 99), ("stale-realm-pubkey", SCOPE.1))
+ .await
+ .is_none(),
+ "a superseded realm's start must not install, whatever its lease — \
+ announcing is what supersedes it, not the new realm's first call"
+ );
+ stop_half(&state, (old_epoch, 99)).await;
+ assert!(
+ !stale.is_cancelled(),
+ "a superseded realm's cleanup must not cancel a task it no longer owns"
+ );
+
+ // And the announcement must not have locked the new realm out of its own
+ // start: publishing a mark too high is blocker 3 rebuilt from the far side.
+ // A different scope so the start installs rather than taking the
+ // same-scope no-op path, which would report `None` for a benign reason and
+ // blur what this assertion is for.
+ let current = start_half(&state, (new_epoch, 1), ("other-pubkey", SCOPE.1))
+ .await
+ .expect("the announcing realm's own first start must still install");
+ assert!(
+ running_is(&state, ¤t).await,
+ "the surviving task must be the new realm's instance"
+ );
+}
+
+/// Epochs are handed out strictly increasing, so an announcement can never tie
+/// with or fall behind one already given out.
+#[tokio::test]
+async fn announced_epochs_strictly_increase() {
+ let state = ArchiveSyncState::default();
+
+ let mut previous = 0;
+ for _ in 0..5 {
+ let epoch = state.announce().await;
+ assert!(
+ epoch > previous,
+ "epoch {epoch} did not outrank its predecessor {previous}"
+ );
+ previous = epoch;
+ }
+}
+
+// ── Session acquisition ──────────────────────────────────────────────────────
+//
+// Ordering alone is not enough once a start has to acquire the shared relay
+// session: `ensure_session` shuts down a different scope's socket and
+// `attach_archive` replaces the archive sender, both destructively on entry.
+// A start that checked its mark, yielded, and acquired afterwards would already
+// have torn down the newer owner's session by the time it discovered it lost.
+//
+// Two things close that, and only one of them is testable here. That a
+// superseded start cannot call `archive_session` at all is the token's job and
+// is enforced by the compiler, not by a test — `ArchiveOwnership` is
+// un-constructible outside this module, so the bypass does not compile. What
+// this test pins is the property the token's usefulness rests on: while a
+// winner holds it, no other start can claim.
+
+/// While a start holds its ownership token, a newer start cannot claim — so the
+/// window in which the shared session is acquired is exclusive.
+///
+/// This is the mutant that motivated the design and the one a test has to
+/// catch: keeping the token but releasing the guards inside `begin`. That
+/// compiles, keeps every other lifecycle test green, and restores exactly the
+/// race — B claims, yields into `archive_session`, C claims and installs its
+/// own session, then B's acquisition shuts C's socket down and attaches the
+/// archive stream to a task whose token is already cancelled.
+///
+/// The competing start uses a DIFFERENT scope on purpose: under `SCOPE` it
+/// would take the same-scope remount no-op and report `None` whatever the locks
+/// did, so the assertion would hold for a benign reason.
+#[tokio::test]
+async fn a_newer_start_cannot_claim_while_the_owner_holds_its_token() {
+ let state = Arc::new(ArchiveSyncState::default());
+
+ let first = CancellationToken::new();
+ let ownership = state
+ .begin(
+ (1, 1),
+ (SCOPE.0.to_string(), SCOPE.1.to_string()),
+ first.clone(),
+ Arc::new(Notify::new()),
+ )
+ .await
+ .expect("the first start claims ownership");
+
+ let second = CancellationToken::new();
+ let claimed = Arc::new(std::sync::atomic::AtomicBool::new(false));
+ let contender = tokio::spawn({
+ let state = Arc::clone(&state);
+ let claimed = Arc::clone(&claimed);
+ let second = second.clone();
+ async move {
+ let won = state
+ .begin(
+ (1, 2),
+ ("other-pubkey".to_string(), SCOPE.1.to_string()),
+ second,
+ Arc::new(Notify::new()),
+ )
+ .await
+ .is_some();
+ claimed.store(won, std::sync::atomic::Ordering::SeqCst);
+ }
+ });
+
+ // Give the contender every chance to claim while the owner still holds the
+ // token. Yield first so it is polled at all, then a real sleep so a slow
+ // scheduler cannot make this pass by never running it.
+ tokio::task::yield_now().await;
+ tokio::time::sleep(Duration::from_millis(50)).await;
+
+ assert!(
+ !claimed.load(std::sync::atomic::Ordering::SeqCst),
+ "a newer start claimed while the owner still held its token: the owner's \
+ session acquisition is no longer exclusive, so a superseded start can \
+ shut down the newer scope's socket"
+ );
+ assert!(
+ !first.is_cancelled(),
+ "the owner's task was cancelled while it still held ownership"
+ );
+
+ // Releasing the token is what lets the newer start through — and it must
+ // then win, or this test would also pass against a `begin` that deadlocked.
+ drop(ownership);
+ contender.await.expect("contender task");
+ assert!(
+ claimed.load(std::sync::atomic::Ordering::SeqCst),
+ "the newer start never claimed after the owner released its token"
+ );
+ assert!(
+ first.is_cancelled(),
+ "the superseded task must be cancelled once the newer start installs"
+ );
+ assert!(
+ running_is(&state, &second).await,
+ "the newer start's task must be the installed one"
+ );
+}
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index ed7a33d397d..33b6ae44620 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -26,6 +26,14 @@ pub(super) fn workspace_owner_hex(state: &AppState) -> Result {
Ok(keys.public_key().to_hex())
}
+#[path = "agents_pending.rs"]
+mod pending;
+#[cfg(test)]
+use pending::build_agent_archive_request;
+pub(crate) use pending::{
+ archive_managed_agent_pending, retain_managed_agent_pending, tombstone_managed_agent_pending,
+};
+
/// Build a summary from fresh disk state (personas, teams, global config).
/// For one-shot command paths only — the 5s list poll calls
/// `build_managed_agent_summary` directly with stores loaded once per call,
@@ -48,174 +56,6 @@ pub(super) fn summarize_from_disk(
)
}
-/// Retain a freshly authored managed-agent event in the local store, flagged
-/// for relay sync. MUST be called inside the `managed_agents_store_lock`-held
-/// body after `save_managed_agents`, NEVER across an `.await`: it acquires
-/// `state.keys` and a retention-db connection, both `std::sync` guards, and
-/// drops them before returning.
-///
-/// Owner-authored, mirroring `commands::personas::retain_persona_pending`: the
-/// owner keys sign, the d_tag is the agent's pubkey, so the coordinate is
-/// `30177::`. The event content is the opt-IN
-/// [`agent_event_content`] projection — the retention upsert's content-equality
-/// guard compares this projection, so an operational start/stop that mutates
-/// only runtime fields produces an identical row and never re-enqueues a
-/// publish. Best-effort: a failure here is logged and swallowed so a retention
-/// hiccup never blocks the disk-authoritative write.
-pub(super) fn retain_managed_agent_pending(
- app: &AppHandle,
- state: &AppState,
- record: &ManagedAgentRecord,
-) {
- use crate::managed_agents::{reconcile::retain_agent_record, retention::open_retention_db};
-
- let result = (|| -> Result<(), String> {
- let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
- let conn = open_retention_db(&scope.db_path)?;
- // Shared engine with the boot-time reconcile: projection content diff
- // (no republish for runtime-only churn) + monotonic created_at bump
- // past the retained head (NIP-AP step 3).
- retain_agent_record(&conn, &scope.owner_keys, record).map(|_| ())
- })();
- if let Err(e) = result {
- eprintln!("buzz-desktop: agent-retain: {e}");
- }
-}
-
-/// Purge a deleted agent's pending row and enqueue a NIP-09 tombstone, both
-/// inside the `managed_agents_store_lock`-held delete body and NEVER across an
-/// `.await`.
-///
-/// Mirrors `commands::personas::tombstone_persona_pending`: the agent row at
-/// `(30177, owner, agent_pubkey)` is purged first so an unpublished edit can
-/// never resurrect it after the tombstone publishes, then the kind:5 tombstone
-/// is retained at its own `(5, owner, agent_pubkey)` coordinate with
-/// `pending_sync = 1`. The `d_tag` is the agent's pubkey. Best-effort: a
-/// failure is logged and swallowed so a retention hiccup never blocks the
-/// disk-authoritative delete.
-pub(super) fn tombstone_managed_agent_pending(
- app: &AppHandle,
- state: &AppState,
- agent_pubkey: &str,
-) {
- use crate::managed_agents::{
- agent_events::build_agent_delete,
- retention::{
- delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag,
- RetainedEvent,
- },
- };
- use buzz_core_pkg::kind::KIND_MANAGED_AGENT;
- use nostr::JsonUtil;
-
- const KIND_DELETE: u32 = 5;
-
- let result = (|| -> Result<(), String> {
- let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
- let owner_pubkey = scope.owner_keys.public_key().to_hex();
- let event = build_agent_delete(agent_pubkey, &owner_pubkey)?
- .sign_with_keys(&scope.owner_keys)
- .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?;
- let conn = open_retention_db(&scope.db_path)?;
- delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?;
- retain_event(
- &conn,
- &RetainedEvent {
- kind: KIND_DELETE,
- pubkey: owner_pubkey,
- // Key by the target coordinate so cross-kind d-tag tombstones
- // occupy distinct rows (F2c).
- d_tag: tombstone_retention_d_tag(KIND_MANAGED_AGENT, agent_pubkey),
- content: event.content.to_string(),
- created_at: event.created_at.as_secs() as i64,
- raw_event: event.as_json(),
- pending_sync: true,
- },
- )
- })();
- if let Err(e) = result {
- eprintln!("buzz-desktop: agent-tombstone: {e}");
- }
-}
-
-/// Build an owner-authenticated NIP-IA `kind:9035` archive request for a deleted agent.
-/// Definition-linked agents carry the persona id in `content`, where it survives the
-/// kind:30177 tombstone as owner-signed historical alias data. The request uses the
-/// same builder as the GUI Archive action and the NIP-IA `retired` reason.
-pub(super) fn build_agent_archive_request(
- keys: &nostr::Keys,
- agent_pubkey: &str,
- persona_id: Option<&str>,
-) -> Result {
- let auth_tag = if keys
- .public_key()
- .to_hex()
- .eq_ignore_ascii_case(agent_pubkey)
- {
- None
- } else {
- let agent = nostr::PublicKey::from_hex(agent_pubkey)
- .map_err(|e| format!("invalid agent pubkey: {e}"))?;
- let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(keys, &agent, "")
- .map_err(|e| format!("failed to build owner auth tag: {e}"))?;
- let parts: Vec = serde_json::from_str(&tag_json)
- .map_err(|e| format!("failed to parse owner auth tag: {e}"))?;
- Some(
- <[String; 4]>::try_from(parts)
- .map_err(|_| "owner auth tag must have four elements".to_string())?,
- )
- };
- let content = persona_id
- .filter(|id| !id.trim().is_empty())
- .map(|id| serde_json::json!({ "persona_id": id }).to_string())
- .unwrap_or_default();
- crate::events::build_archive_identity_request(
- agent_pubkey,
- &content,
- Some("retired"),
- None,
- auth_tag.as_ref(),
- )?
- .sign_with_keys(keys)
- .map_err(|e| format!("failed to sign archive request: {e}"))
-}
-
-/// Durably enqueue the archive request next to the kind:5 tombstone. The flush
-/// loop re-signs it with a relay-fresh timestamp. Best-effort and lock-scoped,
-/// matching `tombstone_managed_agent_pending`.
-pub(super) fn archive_managed_agent_pending(
- app: &AppHandle,
- state: &AppState,
- agent_pubkey: &str,
- persona_id: Option<&str>,
-) {
- use crate::managed_agents::retention::{open_retention_db, retain_event, RetainedEvent};
- use buzz_core_pkg::kind::KIND_IA_ARCHIVE_REQUEST;
- use nostr::JsonUtil;
-
- let result = (|| -> Result<(), String> {
- let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
- let owner_pubkey = scope.owner_keys.public_key().to_hex();
- let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey, persona_id)?;
- let conn = open_retention_db(&scope.db_path)?;
- retain_event(
- &conn,
- &RetainedEvent {
- kind: KIND_IA_ARCHIVE_REQUEST,
- pubkey: owner_pubkey,
- d_tag: agent_pubkey.to_string(),
- content: event.content.to_string(),
- created_at: event.created_at.as_secs() as i64,
- raw_event: event.as_json(),
- pending_sync: true,
- },
- )
- })();
- if let Err(e) = result {
- eprintln!("buzz-desktop: agent-archive: {e}");
- }
-}
-
fn normalize_relay_mesh(
config: Option<&RelayMeshConfig>,
backend: &BackendKind,
@@ -366,8 +206,9 @@ pub(super) async fn start_local_agent_with_preflight(
app: &AppHandle,
state: &AppState,
pubkey: &str,
- owner_hex: &str,
allow_fresh_create_start: bool,
+ expected_relay_url: Option<&str>,
+ expected_signer_pubkey: Option<&str>,
) -> Result {
let record_snapshot = {
let _store_guard = state
@@ -403,6 +244,24 @@ pub(super) async fn start_local_agent_with_preflight(
);
ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?;
+ // The mesh preflight above is the suspension window Projects callbacks
+ // capture their scope against: a community switch during that await
+ // would otherwise spawn this pair keyed to the *new* workspace relay.
+ // Read the workspace relay ONCE, assert the caller's captured scope
+ // against that exact read, and hand the same bound value to the spawn
+ // below — the check is tied to its use, so a switch landing after this
+ // point can no longer retarget the spawn (it only changes state this
+ // call no longer consults).
+ let workspace_relay_url = crate::relay::bind_expected_relay_scope(
+ expected_relay_url,
+ crate::relay::relay_ws_url_with_override(state),
+ )?;
+ // Bind the active owner after the same final await as the relay. A
+ // same-relay identity replacement during mesh preflight must not release
+ // the stale preflight owner to spawn.
+ let workspace_owner =
+ crate::relay::bind_expected_signer(expected_signer_pubkey, workspace_owner_hex(state)?)?;
+
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -437,7 +296,13 @@ pub(super) async fn start_local_agent_with_preflight(
}
}
}
- start_managed_agent_process(app, record, &mut runtimes, Some(owner_hex))?;
+ start_managed_agent_process(
+ app,
+ record,
+ &mut runtimes,
+ Some(workspace_owner.as_str()),
+ &workspace_relay_url,
+ )?;
save_managed_agents(app, &records)?;
if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) {
retain_managed_agent_pending(app, state, saved_record);
@@ -551,10 +416,6 @@ pub async fn create_managed_agent(
);
}
- // Snapshot the workspace owner pubkey for the legacy-record auth_tag
- // fallback. Computed outside the records lock to keep lock ordering simple.
- let owner_hex = workspace_owner_hex(&state)?;
-
// ── Phase 1: generate keys (sync lock) ────────────────────────────────────
let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input) = {
let _store_guard = state
@@ -779,7 +640,7 @@ pub async fn create_managed_agent(
linked_persona.as_ref(),
)?;
- let record = crate::managed_agents::ManagedAgentRecord {
+ let record = ManagedAgentRecord {
pubkey: pubkey.clone(),
name: name.clone(),
persona_id: requested_persona_id.clone(),
@@ -888,7 +749,7 @@ pub async fn create_managed_agent(
// ── Phase 3b: local spawn (async preflight outside store lock) ───────────
let mut spawn_error = None;
let agent = if input.spawn_after_create && input.backend == BackendKind::Local {
- match start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, true).await {
+ match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None).await {
Ok(agent) => agent,
Err(error) => {
let _store_guard = state
@@ -952,7 +813,11 @@ pub async fn create_managed_agent(
.ok_or_else(|| "agent disappeared".to_string())?;
build_deploy_payload(&app, &state, rec)?
};
- match deploy_to_provider(&app, &state, &pubkey, id, config, agent_json, None).await {
+ match deploy_to_provider(
+ &app, &state, &pubkey, id, config, agent_json, None, None, None,
+ )
+ .await
+ {
Ok(()) => spawn_error,
Err(e) => Some(e),
}
@@ -995,12 +860,39 @@ pub async fn create_managed_agent(
#[tauri::command]
pub async fn start_managed_agent(
pubkey: String,
+ expected_relay_url: Option,
+ expected_signer_pubkey: Option,
app: AppHandle,
state: State<'_, AppState>,
) -> Result {
// Snapshot the workspace owner pubkey for the legacy auth_tag fallback.
// Read outside the records lock to keep lock ordering simple.
let owner_hex = workspace_owner_hex(&state)?;
+ // Callers with a captured tenant scope (Projects agent sends) pass
+ // `expected_relay_url` / `expected_signer_pubkey`. Starting an agent
+ // activates the (agent, relay) pair — a channel/tool-capable side effect
+ // — so a stale callback must fail closed here before any spawn or deploy
+ // when the active community or identity changed while it was suspended.
+ // After the mesh-preflight awaits, the local path re-checks and BINDS
+ // the workspace relay (`bind_expected_relay_scope`) so the spawn consumes
+ // the checked value rather than re-reading mutable state; the provider
+ // path asserts against the relay embedded in the deploy payload before
+ // deploying.
+ crate::relay::assert_expected_relay_scope(
+ expected_relay_url.as_deref(),
+ &crate::relay::relay_api_base_url_with_override(&state),
+ )?;
+ crate::relay::assert_expected_signer(expected_signer_pubkey.as_deref(), &owner_hex)?;
+ // Pin the relay for the fire-and-forget profile reconciliation spawned
+ // after a successful start: one validated workspace-relay read, captured
+ // NOW. The background task may execute long after this command returns —
+ // resolving the relay at execution time would let a community switch
+ // landing in between retarget the kind:0 query/publish to the new
+ // tenant's relay under authorization the caller only gave for this one.
+ let reconcile_relay = crate::relay::bind_expected_relay_scope(
+ expected_relay_url.as_deref(),
+ relay_ws_url_with_override(&state),
+ )?;
enum StartTarget {
Local,
Provider {
@@ -1038,7 +930,14 @@ pub async fn start_managed_agent(
// profile reconcile (the create-time snapshot may be empty or stale for
// a persona-inherited harness).
let reconcile_personas = load_personas(&app).unwrap_or_default();
- let reconcile = profile_reconcile_data(record, &reconcile_personas);
+ let mut reconcile = profile_reconcile_data(record, &reconcile_personas);
+ // Pin the startup relay (the bound, caller-validated read) so the
+ // fire-and-forget task can never resolve a post-switch workspace.
+ // Mirrors `load_pending_profile_reconciliations`.
+ reconcile.target_relay_url = Some(crate::relay::effective_agent_relay_url(
+ &record.relay_url,
+ reconcile_relay.as_str(),
+ ));
let target = if record.backend == BackendKind::Local {
StartTarget::Local
@@ -1055,13 +954,25 @@ pub async fn start_managed_agent(
let result = match target {
StartTarget::Local => {
- start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, false).await
+ start_local_agent_with_preflight(
+ &app,
+ &state,
+ &pubkey,
+ false,
+ expected_relay_url.as_deref(),
+ expected_signer_pubkey.as_deref(),
+ )
+ .await
}
StartTarget::Provider {
backend: BackendKind::Provider { id, config },
cached_binary_path,
agent_json,
} => {
+ // The caller's captured scope is asserted INSIDE deploy_to_provider
+ // against the payload rebuilt after the deploy lock — the exact
+ // payload invoked — so a switch racing the lock wait cannot deploy
+ // the agent into the new tenant on behalf of a stale callback.
deploy_to_provider(
&app,
&state,
@@ -1070,6 +981,8 @@ pub async fn start_managed_agent(
&config,
agent_json,
cached_binary_path.as_deref(),
+ expected_relay_url.as_deref(),
+ expected_signer_pubkey.as_deref(),
)
.await?;
diff --git a/desktop/src-tauri/src/commands/agents/provider_access.rs b/desktop/src-tauri/src/commands/agents/provider_access.rs
index 5b90500ee66..34c06d25919 100644
--- a/desktop/src-tauri/src/commands/agents/provider_access.rs
+++ b/desktop/src-tauri/src/commands/agents/provider_access.rs
@@ -98,6 +98,8 @@ pub(crate) async fn reconcile_on_workspace_apply(
&config,
agent_json,
cached_binary_path.as_deref(),
+ None,
+ None,
)
.await
{
diff --git a/desktop/src-tauri/src/commands/agents/provider_deploy.rs b/desktop/src-tauri/src/commands/agents/provider_deploy.rs
index cdbdd787e7e..bb56a67eaa4 100644
--- a/desktop/src-tauri/src/commands/agents/provider_deploy.rs
+++ b/desktop/src-tauri/src/commands/agents/provider_deploy.rs
@@ -23,6 +23,15 @@ use super::build_deploy_payload;
/// revocation semantics to the provider implementation (deferred to v2).
/// Returns Ok(()) on success, Err(message) on failure. Either way the record is
/// updated and saved before returning.
+///
+/// Callers with a captured tenant scope (Projects agent starts) pass
+/// `expected_relay_url` / `expected_signer_pubkey`; they are asserted against
+/// the payload REBUILT after the deploy lock — the exact value invoked — so a
+/// workspace or identity switch landing while this call waited behind another
+/// deployment fails closed instead of deploying a stale start into the new
+/// tenant under the new tenant's owner identity. `None` preserves the
+/// unscoped behavior for callers without a tenant boundary.
+#[allow(clippy::too_many_arguments)]
pub(crate) async fn deploy_to_provider(
app: &AppHandle,
state: &AppState,
@@ -31,6 +40,8 @@ pub(crate) async fn deploy_to_provider(
_config: &serde_json::Value,
_agent_json: serde_json::Value,
_cached_binary_path: Option<&str>,
+ expected_relay_url: Option<&str>,
+ expected_signer_pubkey: Option<&str>,
) -> Result<(), String> {
let deploy_lock = {
let mut locks = state
@@ -68,6 +79,10 @@ pub(crate) async fn deploy_to_provider(
build_deploy_payload(app, state, record)?,
)
};
+ // The rebuild above re-read the live workspace relay and owner identity.
+ // Assert the caller's captured scope against THIS payload — the exact
+ // value invoked below — not the pre-lock snapshot its caller validated.
+ assert_payload_scope(&agent_json, expected_relay_url, expected_signer_pubkey)?;
// Resolve via discovered candidates only. Cached path must match BOTH
// "is a discovered candidate" AND "belongs to this provider_id". A tampered
// record cannot redirect deploys to a different provider's binary.
@@ -106,6 +121,44 @@ pub(crate) async fn deploy_to_provider(
result
}
+/// Assert a caller-captured tenant scope against the payload that will
+/// actually be invoked. The relay lives at the payload's top-level
+/// `relay_url`; the deploying identity lives at `launch.owner_pubkey` — both
+/// were re-resolved from live workspace state by `build_deploy_payload`, so
+/// this is the check tied to the use. When the caller carries an expectation
+/// a missing payload field fails closed: an unverifiable payload must never
+/// deploy on behalf of a scoped callback.
+fn assert_payload_scope(
+ agent_json: &serde_json::Value,
+ expected_relay_url: Option<&str>,
+ expected_signer_pubkey: Option<&str>,
+) -> Result<(), String> {
+ let has_expectation =
+ |expected: Option<&str>| expected.map(str::trim).filter(|s| !s.is_empty()).is_some();
+ match agent_json.get("relay_url").and_then(|v| v.as_str()) {
+ Some(embedded_relay) => crate::relay::assert_expected_relay_scope(
+ expected_relay_url,
+ &crate::relay::relay_http_base_url(embedded_relay),
+ )?,
+ None if has_expectation(expected_relay_url) => {
+ return Err("deploy payload carries no relay; not deployed".to_string());
+ }
+ None => {}
+ }
+ match agent_json
+ .get("launch")
+ .and_then(|launch| launch.get("owner_pubkey"))
+ .and_then(|v| v.as_str())
+ {
+ Some(owner) => crate::relay::assert_expected_signer(expected_signer_pubkey, owner)?,
+ None if has_expectation(expected_signer_pubkey) => {
+ return Err("deploy payload carries no owner identity; not deployed".to_string());
+ }
+ None => {}
+ }
+ Ok(())
+}
+
fn policy_matches_payload(
record: &crate::managed_agents::ManagedAgentRecord,
deployed_agent_json: &serde_json::Value,
@@ -162,6 +215,74 @@ mod tests {
serde_json::json!({"respond_to": respond_to, "respond_to_allowlist": []})
}
+ fn scoped_payload(relay: &str, owner: &str) -> serde_json::Value {
+ serde_json::json!({
+ "relay_url": relay,
+ "launch": { "owner_pubkey": owner },
+ })
+ }
+
+ // ── assert_payload_scope: post-lock rebuilt-payload validation ──────────
+
+ #[test]
+ fn matching_scope_and_signer_pass_on_the_rebuilt_payload() {
+ assert_payload_scope(
+ &scoped_payload("wss://tenant-a.example", "aa11"),
+ Some("wss://tenant-a.example"),
+ Some("aa11"),
+ )
+ .unwrap();
+ }
+
+ #[test]
+ fn relay_switch_during_the_lock_wait_fails_closed() {
+ // Round-8 P1: a stale Projects-A start waited behind another deploy;
+ // the rebuild resolved tenant B. The payload actually invoked must be
+ // refused — the pre-lock snapshot its caller validated is irrelevant.
+ let error = assert_payload_scope(
+ &scoped_payload("wss://tenant-b.example", "aa11"),
+ Some("wss://tenant-a.example"),
+ Some("aa11"),
+ )
+ .unwrap_err();
+ assert!(error.contains("active community changed"), "{error}");
+ }
+
+ #[test]
+ fn same_relay_identity_switch_during_the_lock_wait_fails_closed() {
+ // Same relay, different owner: an identity switch alone must also be
+ // refused — the rebuilt launch.owner_pubkey belongs to a tenant the
+ // caller never validated.
+ let error = assert_payload_scope(
+ &scoped_payload("wss://tenant-a.example", "bb22"),
+ Some("wss://tenant-a.example"),
+ Some("aa11"),
+ )
+ .unwrap_err();
+ assert!(error.contains("active identity changed"), "{error}");
+ }
+
+ #[test]
+ fn scoped_caller_with_an_unverifiable_payload_fails_closed() {
+ let payload = serde_json::json!({});
+ let relay_error =
+ assert_payload_scope(&payload, Some("wss://tenant-a.example"), None).unwrap_err();
+ assert!(relay_error.contains("no relay"), "{relay_error}");
+ let signer_error = assert_payload_scope(&payload, None, Some("aa11")).unwrap_err();
+ assert!(signer_error.contains("no owner identity"), "{signer_error}");
+ }
+
+ #[test]
+ fn unscoped_callers_deploy_any_payload() {
+ assert_payload_scope(
+ &scoped_payload("wss://anywhere.example", "cc33"),
+ None,
+ None,
+ )
+ .unwrap();
+ assert_payload_scope(&serde_json::json!({}), None, None).unwrap();
+ }
+
#[test]
fn successful_deploy_acknowledges_pending_policy() {
let mut record = record();
diff --git a/desktop/src-tauri/src/commands/agents_pending.rs b/desktop/src-tauri/src/commands/agents_pending.rs
new file mode 100644
index 00000000000..8b9564942c6
--- /dev/null
+++ b/desktop/src-tauri/src/commands/agents_pending.rs
@@ -0,0 +1,177 @@
+//! Retention-queue helpers for managed-agent lifecycle events: pending
+//! upserts, NIP-09 tombstones, and NIP-IA archive requests. Split from
+//! `agents.rs` (which mounts this as `mod pending`) purely along the
+//! retention seam; every function runs inside the
+//! `managed_agents_store_lock`-held body and NEVER across an `.await`.
+
+use tauri::AppHandle;
+
+use crate::{app_state::AppState, managed_agents::ManagedAgentRecord};
+
+/// Retain a freshly authored managed-agent event in the local store, flagged
+/// for relay sync. MUST be called inside the `managed_agents_store_lock`-held
+/// body after `save_managed_agents`, NEVER across an `.await`: it acquires
+/// `state.keys` and a retention-db connection, both `std::sync` guards, and
+/// drops them before returning.
+///
+/// Owner-authored, mirroring `commands::personas::retain_persona_pending`: the
+/// owner keys sign, the d_tag is the agent's pubkey, so the coordinate is
+/// `30177::`. The event content is the opt-IN
+/// [`agent_event_content`] projection — the retention upsert's content-equality
+/// guard compares this projection, so an operational start/stop that mutates
+/// only runtime fields produces an identical row and never re-enqueues a
+/// publish. Best-effort: a failure here is logged and swallowed so a retention
+/// hiccup never blocks the disk-authoritative write.
+pub(crate) fn retain_managed_agent_pending(
+ app: &AppHandle,
+ state: &AppState,
+ record: &ManagedAgentRecord,
+) {
+ use crate::managed_agents::{reconcile::retain_agent_record, retention::open_retention_db};
+
+ let result = (|| -> Result<(), String> {
+ let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
+ let conn = open_retention_db(&scope.db_path)?;
+ // Shared engine with the boot-time reconcile: projection content diff
+ // (no republish for runtime-only churn) + monotonic created_at bump
+ // past the retained head (NIP-AP step 3).
+ retain_agent_record(&conn, &scope.owner_keys, record).map(|_| ())
+ })();
+ if let Err(e) = result {
+ eprintln!("buzz-desktop: agent-retain: {e}");
+ }
+}
+
+/// Purge a deleted agent's pending row and enqueue a NIP-09 tombstone, both
+/// inside the `managed_agents_store_lock`-held delete body and NEVER across an
+/// `.await`.
+///
+/// Mirrors `commands::personas::tombstone_persona_pending`: the agent row at
+/// `(30177, owner, agent_pubkey)` is purged first so an unpublished edit can
+/// never resurrect it after the tombstone publishes, then the kind:5 tombstone
+/// is retained at its own `(5, owner, agent_pubkey)` coordinate with
+/// `pending_sync = 1`. The `d_tag` is the agent's pubkey. Best-effort: a
+/// failure is logged and swallowed so a retention hiccup never blocks the
+/// disk-authoritative delete.
+pub(crate) fn tombstone_managed_agent_pending(
+ app: &AppHandle,
+ state: &AppState,
+ agent_pubkey: &str,
+) {
+ use crate::managed_agents::{
+ agent_events::build_agent_delete,
+ retention::{
+ delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag,
+ RetainedEvent,
+ },
+ };
+ use buzz_core_pkg::kind::KIND_MANAGED_AGENT;
+ use nostr::JsonUtil;
+
+ const KIND_DELETE: u32 = 5;
+
+ let result = (|| -> Result<(), String> {
+ let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
+ let owner_pubkey = scope.owner_keys.public_key().to_hex();
+ let event = build_agent_delete(agent_pubkey, &owner_pubkey)?
+ .sign_with_keys(&scope.owner_keys)
+ .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?;
+ let conn = open_retention_db(&scope.db_path)?;
+ delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?;
+ retain_event(
+ &conn,
+ &RetainedEvent {
+ kind: KIND_DELETE,
+ pubkey: owner_pubkey,
+ // Key by the target coordinate so cross-kind d-tag tombstones
+ // occupy distinct rows (F2c).
+ d_tag: tombstone_retention_d_tag(KIND_MANAGED_AGENT, agent_pubkey),
+ content: event.content.to_string(),
+ created_at: event.created_at.as_secs() as i64,
+ raw_event: event.as_json(),
+ pending_sync: true,
+ },
+ )
+ })();
+ if let Err(e) = result {
+ eprintln!("buzz-desktop: agent-tombstone: {e}");
+ }
+}
+
+/// Build an owner-authenticated NIP-IA `kind:9035` archive request for a deleted agent.
+/// Definition-linked agents carry the persona id in `content`, where it survives the
+/// kind:30177 tombstone as owner-signed historical alias data. The request uses the
+/// same builder as the GUI Archive action and the NIP-IA `retired` reason.
+pub(crate) fn build_agent_archive_request(
+ keys: &nostr::Keys,
+ agent_pubkey: &str,
+ persona_id: Option<&str>,
+) -> Result {
+ let auth_tag = if keys
+ .public_key()
+ .to_hex()
+ .eq_ignore_ascii_case(agent_pubkey)
+ {
+ None
+ } else {
+ let agent = nostr::PublicKey::from_hex(agent_pubkey)
+ .map_err(|e| format!("invalid agent pubkey: {e}"))?;
+ let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(keys, &agent, "")
+ .map_err(|e| format!("failed to build owner auth tag: {e}"))?;
+ let parts: Vec = serde_json::from_str(&tag_json)
+ .map_err(|e| format!("failed to parse owner auth tag: {e}"))?;
+ Some(
+ <[String; 4]>::try_from(parts)
+ .map_err(|_| "owner auth tag must have four elements".to_string())?,
+ )
+ };
+ let content = persona_id
+ .filter(|id| !id.trim().is_empty())
+ .map(|id| serde_json::json!({ "persona_id": id }).to_string())
+ .unwrap_or_default();
+ crate::events::build_archive_identity_request(
+ agent_pubkey,
+ &content,
+ Some("retired"),
+ None,
+ auth_tag.as_ref(),
+ )?
+ .sign_with_keys(keys)
+ .map_err(|e| format!("failed to sign archive request: {e}"))
+}
+
+/// Durably enqueue the archive request next to the kind:5 tombstone. The flush
+/// loop re-signs it with a relay-fresh timestamp. Best-effort and lock-scoped,
+/// matching `tombstone_managed_agent_pending`.
+pub(crate) fn archive_managed_agent_pending(
+ app: &AppHandle,
+ state: &AppState,
+ agent_pubkey: &str,
+ persona_id: Option<&str>,
+) {
+ use crate::managed_agents::retention::{open_retention_db, retain_event, RetainedEvent};
+ use buzz_core_pkg::kind::KIND_IA_ARCHIVE_REQUEST;
+ use nostr::JsonUtil;
+
+ let result = (|| -> Result<(), String> {
+ let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
+ let owner_pubkey = scope.owner_keys.public_key().to_hex();
+ let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey, persona_id)?;
+ let conn = open_retention_db(&scope.db_path)?;
+ retain_event(
+ &conn,
+ &RetainedEvent {
+ kind: KIND_IA_ARCHIVE_REQUEST,
+ pubkey: owner_pubkey,
+ d_tag: agent_pubkey.to_string(),
+ content: event.content.to_string(),
+ created_at: event.created_at.as_secs() as i64,
+ raw_event: event.as_json(),
+ pending_sync: true,
+ },
+ )
+ })();
+ if let Err(e) = result {
+ eprintln!("buzz-desktop: agent-archive: {e}");
+ }
+}
diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs
index d28b58b50a7..16a1538c753 100644
--- a/desktop/src-tauri/src/commands/agents_profile.rs
+++ b/desktop/src-tauri/src/commands/agents_profile.rs
@@ -19,9 +19,11 @@ pub(crate) struct ProfileReconcileData {
pub(crate) private_key_nsec: String,
pub(crate) name: String,
pub(crate) relay_url: String,
- /// Exact relay for migration work captured while a community is active.
- /// Ordinary runtime reconciliation leaves this unset and resolves against
- /// the current workspace at execution time.
+ /// Exact relay pinned by the caller for the deferred task — captured
+ /// while the authorizing workspace/spawn was active (UI start, boot
+ /// restore, migration queue). When set it wins unconditionally; a task
+ /// left unpinned (no tenant boundary) resolves the current workspace at
+ /// execution time. See `resolve_reconcile_relay`.
pub(crate) target_relay_url: Option,
/// Expected avatar URL for the published profile. `None` for legacy records
/// that predate the `avatar_url` field — these will be backfilled from the
@@ -59,6 +61,27 @@ pub(super) fn resolve_legacy_avatar(
.unwrap_or_default()
}
+/// Resolve the relay a reconciliation task will query and publish on. The
+/// pure core of the `reconcile_agent_profile` relay choice, extracted so the
+/// pinning contract is unit-testable: a caller-pinned `target_relay_url`
+/// (captured while the authorizing workspace was active) wins UNCONDITIONALLY
+/// over the execution-time workspace read — otherwise a community switch
+/// landing between spawn and execution would retarget the kind:0
+/// query/publish to a tenant the caller never authorized. Only an unpinned
+/// task (no tenant boundary) resolves the live workspace.
+pub(super) fn resolve_reconcile_relay(
+ target_relay_url: Option<&str>,
+ record_relay_url: &str,
+ workspace_relay_at_execution: &str,
+) -> String {
+ match target_relay_url {
+ Some(pinned) => pinned.to_string(),
+ None => {
+ crate::relay::effective_agent_relay_url(record_relay_url, workspace_relay_at_execution)
+ }
+ }
+}
+
pub(crate) fn profile_reconcile_data(
record: &crate::managed_agents::ManagedAgentRecord,
personas: &[crate::managed_agents::AgentDefinition],
@@ -153,11 +176,13 @@ pub(crate) fn mark_profile_reconciled(
/// profile — and persists the updated record. After backfill, normal
/// reconciliation proceeds.
///
-/// Query and publish target the relay returned by `effective_agent_relay_url`
-/// for every agent regardless of backend: an explicit per-agent `relay_url`
-/// wins, and a blank one falls back to the active workspace relay. This keeps
-/// reconciliation following the session's relay for never-pinned agents while
-/// honoring a deliberate pin wherever it points.
+/// Query and publish target the caller-pinned `target_relay_url` when set
+/// (UI start, boot restore, migration queue — captured while the authorizing
+/// workspace was active); an unpinned task falls back to
+/// `effective_agent_relay_url` against the workspace at execution time. This
+/// keeps deferred reconciliation from following a community switch it was
+/// never authorized for while honoring a deliberate per-agent pin wherever
+/// it points.
pub(crate) async fn reconcile_agent_profile(
state: &AppState,
app: &AppHandle,
@@ -166,12 +191,13 @@ pub(crate) async fn reconcile_agent_profile(
) -> Result {
use crate::relay::{query_agent_profile, sync_managed_agent_profile};
- // An explicit per-agent relay wins; an empty one falls back to the active
- // workspace relay. Resolved once and used for both the read and write-back.
- let workspace_relay = relay_ws_url_with_override(state);
- let relay_url = data.target_relay_url.clone().unwrap_or_else(|| {
- crate::relay::effective_agent_relay_url(&data.relay_url, &workspace_relay)
- });
+ // Resolved ONCE and used for both the read and the write-back. A pinned
+ // `target_relay_url` wins unconditionally — see `resolve_reconcile_relay`.
+ let relay_url = resolve_reconcile_relay(
+ data.target_relay_url.as_deref(),
+ &data.relay_url,
+ &relay_ws_url_with_override(state),
+ );
if !state
.managed_agent_profile_reconcile_enabled
diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs
index 61a2d8a1459..1c222ae23a4 100644
--- a/desktop/src-tauri/src/commands/agents_tests.rs
+++ b/desktop/src-tauri/src/commands/agents_tests.rs
@@ -323,6 +323,31 @@ fn profile_needs_sync_when_missing() {
assert!(profile_needs_sync(None, "Duncan", Some("https://x/a.png")));
}
+// ── resolve_reconcile_relay: deferred-task relay pinning ────────────────────
+
+#[test]
+fn pinned_reconcile_relay_wins_over_a_post_switch_workspace() {
+ // Round-8 P1: the fire-and-forget reconciliation spawned by a scoped
+ // start may execute after an A→B community switch. The pinned relay —
+ // captured while A was the validated workspace — must win over the
+ // workspace read at execution time, so the kind:0 query/publish can
+ // never land on B under A's authorization.
+ let relay = resolve_reconcile_relay(
+ Some("wss://tenant-a.example"),
+ "", // never-pinned record
+ "wss://tenant-b.example", // the switch landed before execution
+ );
+ assert_eq!(relay, "wss://tenant-a.example");
+}
+
+#[test]
+fn unpinned_reconcile_relay_resolves_the_execution_time_workspace() {
+ // No tenant boundary: legacy behavior — follow the live workspace via
+ // effective_agent_relay_url (which ignores the record pin by design).
+ let relay = resolve_reconcile_relay(None, "wss://stale-pin.example", "wss://tenant-b.example");
+ assert_eq!(relay, "wss://tenant-b.example");
+}
+
#[test]
fn profile_needs_sync_when_missing_even_without_expected_avatar() {
assert!(profile_needs_sync(None, "Duncan", None));
diff --git a/desktop/src-tauri/src/commands/dms.rs b/desktop/src-tauri/src/commands/dms.rs
index dcac491b16d..5f6ca279802 100644
--- a/desktop/src-tauri/src/commands/dms.rs
+++ b/desktop/src-tauri/src/commands/dms.rs
@@ -6,7 +6,10 @@ use crate::{
events,
models::ChannelInfo,
nostr_convert,
- relay::{parse_command_response, query_relay, submit_event},
+ relay::{
+ assert_expected_relay_scope, assert_expected_signer, parse_command_response,
+ query_relay_at_with_keys, submit_event, submit_event_at_with_keys,
+ },
};
#[derive(Deserialize)]
@@ -17,23 +20,47 @@ struct OpenDmAck {
#[tauri::command]
pub async fn open_dm(
pubkeys: Vec,
+ expected_relay_url: Option,
+ expected_signer_pubkey: Option,
state: State<'_, AppState>,
) -> Result {
+ // Resolve the relay AND the signing identity once for the open + metadata
+ // read pair. Callers with a captured tenant scope (Projects agent sends)
+ // pass `expected_relay_url` and `expected_signer_pubkey`; a mismatch on
+ // either means the active community changed while their callback was
+ // suspended. The relay check alone is not enough: relay and keys mutate
+ // under separate locks during a workspace switch, so a switch landing
+ // between the URL check and the key read would otherwise create the
+ // tenant-A DM signed as tenant B's identity — fail closed instead, and
+ // use this exact key snapshot for both the event signature and the
+ // NIP-98 auth of every request in this command.
+ let api_base_url = crate::relay::relay_api_base_url_with_override(&state);
+ assert_expected_relay_scope(expected_relay_url.as_deref(), &api_base_url)?;
+ let keys = state.signing_keys()?;
+ assert_expected_signer(
+ expected_signer_pubkey.as_deref(),
+ &keys.public_key().to_hex(),
+ )?;
+
// Submit a kind:41010 dm-open event; the relay replies with the channel id
// in its OK message payload.
let builder = events::build_dm_open(&pubkeys)?;
- let result = submit_event(builder, &state).await?;
+ let result = submit_event_at_with_keys(builder, &state, &api_base_url, &keys).await?;
let ack: OpenDmAck = parse_command_response(&result.message)?;
// Re-fetch the channel metadata so the frontend gets the same `ChannelInfo`
- // shape as `get_channel_details`.
- let metadata = query_relay(
+ // shape as `get_channel_details` — through the same scope-checked base and
+ // the same pinned identity.
+ let metadata = query_relay_at_with_keys(
&state,
+ &api_base_url,
&[serde_json::json!({
"kinds": [39000],
"#d": [ack.channel_id],
"limit": 1
})],
+ &keys,
+ None,
)
.await?;
diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs
index 168be1ecf60..31559777d2b 100644
--- a/desktop/src-tauri/src/commands/messages.rs
+++ b/desktop/src-tauri/src/commands/messages.rs
@@ -17,7 +17,10 @@ use crate::{
SendChannelMessageResponse, ThreadRepliesResponse,
},
nostr_convert,
- relay::{query_relay, submit_event, submit_event_with_keys},
+ relay::{
+ assert_expected_relay_scope, assert_expected_signer, query_relay, submit_event,
+ submit_event_at_created_at, submit_event_with_keys_created_at,
+ },
};
// ── Reads (pure-nostr) ──────────────────────────────────────────────────────
@@ -431,54 +434,8 @@ pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result Result {
- let parent_eid =
- EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?;
-
- let evs = query_relay(
- state,
- &[serde_json::json!({
- "ids": [parent_event_id],
- "kinds": [9, 40002, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED],
- "limit": 1
- })],
- )
- .await?;
-
- let parent = evs
- .first()
- .ok_or_else(|| "parent event not found".to_string())?;
-
- // Walk tags looking for NIP-10 root/reply markers.
- let (mut root, mut reply) = (None, None);
- for tag in parent.tags.iter() {
- let s = tag.as_slice();
- if s.len() >= 4 && s[0] == "e" {
- match s[3].as_str() {
- "root" => root = Some(s[1].clone()),
- "reply" => reply = Some(s[1].clone()),
- _ => {}
- }
- }
- }
- let root_hex = root.or(reply);
-
- let root_eid = match root_hex {
- Some(hex) if hex != parent_event_id => {
- EventId::from_hex(&hex).map_err(|e| format!("invalid root event ID: {e}"))?
- }
- _ => parent_eid,
- };
-
- Ok(events::ThreadRef {
- root_event_id: root_eid,
- parent_event_id: parent_eid,
- })
-}
+mod thread_ref;
+use thread_ref::resolve_thread_ref;
#[tauri::command]
#[allow(clippy::too_many_arguments)]
@@ -493,6 +450,8 @@ pub async fn send_channel_message(
sent_from_thread_tag: Option>,
mention_pubkeys: Option>,
kind: Option,
+ expected_relay_url: Option,
+ expected_signer_pubkey: Option,
state: State<'_, AppState>,
) -> Result {
let channel_uuid = uuid::Uuid::parse_str(&channel_id)
@@ -503,7 +462,23 @@ pub async fn send_channel_message(
let emoji = emoji_tags.unwrap_or_default();
let mention_refs_only = mention_tags.unwrap_or_default();
let link_previews = link_preview_tags.unwrap_or_default();
+ // Resolve the relay AND the signing identity once and use them for every
+ // read and the submission. Callers that captured a tenant scope before an
+ // await (Projects agent sends) pass `expected_relay_url` and
+ // `expected_signer_pubkey`; a mismatch on either means the active
+ // community changed mid-flight and the send must fail closed rather than
+ // publish the captured tenant's content to the new tenant's relay — or
+ // sign it under the new tenant's identity. The relay check alone cannot
+ // catch the latter: relay and keys mutate under separate locks during a
+ // workspace switch, so the keys are snapshotted here, asserted, and that
+ // exact snapshot signs the event and its NIP-98 auth below.
let relay_base = crate::relay::relay_api_base_url_with_override(&state);
+ assert_expected_relay_scope(expected_relay_url.as_deref(), &relay_base)?;
+ let signing_keys = state.signing_keys()?;
+ assert_expected_signer(
+ expected_signer_pubkey.as_deref(),
+ &signing_keys.public_key().to_hex(),
+ )?;
let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE);
if sent_from_thread_tag.is_some() && kind_num != buzz_core_pkg::kind::KIND_STREAM_MESSAGE {
return Err("sent-from-thread provenance requires a stream message".into());
@@ -523,7 +498,8 @@ pub async fn send_channel_message(
let parent_id = parent_event_id
.as_deref()
.ok_or("forum comment requires parent_event_id")?;
- let thread_ref = resolve_thread_ref(parent_id, &state).await?;
+ let thread_ref =
+ resolve_thread_ref(parent_id, &state, &relay_base, Some(&signing_keys)).await?;
resolved_root = Some(thread_ref.root_event_id.to_hex());
events::build_forum_comment(
channel_uuid,
@@ -537,7 +513,8 @@ pub async fn send_channel_message(
_ => {
let thread_ref = match parent_event_id.as_deref() {
Some(pid) => {
- let tr = resolve_thread_ref(pid, &state).await?;
+ let tr =
+ resolve_thread_ref(pid, &state, &relay_base, Some(&signing_keys)).await?;
resolved_root = Some(tr.root_event_id.to_hex());
Some(tr)
}
@@ -558,7 +535,13 @@ pub async fn send_channel_message(
}
};
- let result = submit_event(builder, &state).await?;
+ // `created_at` is the signed event's own second, not a post-publication
+ // clock read — persisted as an event cursor by the Projects opener.
+ // Submit through the base resolved (and scope-checked) above and the
+ // identity snapshotted (and signer-checked) above — a re-resolve or key
+ // re-read here would reopen the mid-command switch window.
+ let (result, created_at) =
+ submit_event_at_created_at(builder, &state, &relay_base, &signing_keys).await?;
let depth = match (&parent_event_id, &resolved_root) {
(None, _) => 0,
@@ -572,7 +555,7 @@ pub async fn send_channel_message(
root_event_id: resolved_root,
parent_event_id,
depth,
- created_at: chrono::Utc::now().timestamp(),
+ created_at,
})
}
@@ -775,7 +758,18 @@ pub async fn send_managed_agent_channel_message(
let submission_auth_tag =
managed_agent_submission_auth_tag(&record, &state, &keys.public_key())?;
let thread_ref = match parent_event_id.as_deref() {
- Some(parent_id) => Some(resolve_thread_ref(parent_id, &state).await?),
+ Some(parent_id) => Some(
+ // Same active-relay resolution as before — this path has no
+ // caller-captured tenant scope (yet), so resolve the override
+ // here and read through it with the active identity.
+ resolve_thread_ref(
+ parent_id,
+ &state,
+ &crate::relay::relay_api_base_url_with_override(&state),
+ None,
+ )
+ .await?,
+ ),
None => None,
};
@@ -820,15 +814,18 @@ pub async fn send_managed_agent_channel_message(
&mentions,
&client_tags,
)?;
- let result =
- submit_event_with_keys(builder, &state, &keys, submission_auth_tag.as_deref()).await?;
+ // Same contract as `send_channel_message`: `created_at` is the signed
+ // event's, not a post-publication clock read.
+ let (result, created_at) =
+ submit_event_with_keys_created_at(builder, &state, &keys, submission_auth_tag.as_deref())
+ .await?;
Ok(SendChannelMessageResponse {
event_id: result.event_id,
parent_event_id: parent_event_id.clone(),
root_event_id: thread_ref.map(|reference| reference.root_event_id.to_hex()),
depth: if parent_event_id.is_some() { 1 } else { 0 },
- created_at: chrono::Utc::now().timestamp(),
+ created_at,
})
}
diff --git a/desktop/src-tauri/src/commands/messages/thread_ref.rs b/desktop/src-tauri/src/commands/messages/thread_ref.rs
new file mode 100644
index 00000000000..97a03fdad5b
--- /dev/null
+++ b/desktop/src-tauri/src/commands/messages/thread_ref.rs
@@ -0,0 +1,65 @@
+use nostr::EventId;
+
+use crate::{
+ app_state::AppState,
+ events,
+ relay::{query_relay_at, query_relay_at_with_keys},
+};
+
+/// Fetch a parent event and extract the thread root from its NIP-10 e-tags.
+///
+/// Reads through the explicit `api_base_url` the calling command resolved —
+/// never re-resolving the workspace override — so a mid-command community
+/// switch cannot split one logical send across two relays. Callers that
+/// pinned a signer snapshot pass it as `keys` so this read's NIP-98 auth is
+/// minted by the same identity that signs the eventual event; `None`
+/// preserves the active-identity read for unpinned callers.
+pub(super) async fn resolve_thread_ref(
+ parent_event_id: &str,
+ state: &AppState,
+ api_base_url: &str,
+ keys: Option<&nostr::Keys>,
+) -> Result {
+ let parent_eid =
+ EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?;
+
+ let filters = [serde_json::json!({
+ "ids": [parent_event_id],
+ "kinds": [9, 40002, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED],
+ "limit": 1
+ })];
+ let evs = match keys {
+ Some(keys) => query_relay_at_with_keys(state, api_base_url, &filters, keys, None).await?,
+ None => query_relay_at(state, api_base_url, &filters).await?,
+ };
+
+ let parent = evs
+ .first()
+ .ok_or_else(|| "parent event not found".to_string())?;
+
+ // Walk tags looking for NIP-10 root/reply markers.
+ let (mut root, mut reply) = (None, None);
+ for tag in parent.tags.iter() {
+ let s = tag.as_slice();
+ if s.len() >= 4 && s[0] == "e" {
+ match s[3].as_str() {
+ "root" => root = Some(s[1].clone()),
+ "reply" => reply = Some(s[1].clone()),
+ _ => {}
+ }
+ }
+ }
+ let root_hex = root.or(reply);
+
+ let root_eid = match root_hex {
+ Some(hex) if hex != parent_event_id => {
+ EventId::from_hex(&hex).map_err(|e| format!("invalid root event ID: {e}"))?
+ }
+ _ => parent_eid,
+ };
+
+ Ok(events::ThreadRef {
+ root_event_id: root_eid,
+ parent_event_id: parent_eid,
+ })
+}
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 0d816bbf609..0fa2f7813f0 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -51,6 +51,7 @@ mod project_git;
mod project_git_branches;
mod project_git_diff;
mod project_git_exec;
+mod project_git_file_content;
mod project_git_merge_error;
mod project_git_push;
mod project_git_recipient_notes;
@@ -111,6 +112,7 @@ pub use profile::*;
pub use project_git::*;
pub use project_git_branches::*;
pub use project_git_diff::*;
+pub use project_git_file_content::*;
pub use project_git_recipient_notes::*;
pub use project_git_workflow::*;
pub use project_terminal::*;
diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs
index c322e6cb6e8..5214dd5a27e 100644
--- a/desktop/src-tauri/src/commands/personas/inbound.rs
+++ b/desktop/src-tauri/src/commands/personas/inbound.rs
@@ -124,6 +124,8 @@ pub async fn reconcile_inbound_persona_event(
&config,
agent_json,
cached_binary_path.as_deref(),
+ None,
+ None,
)
.await
.map_err(|error| {
diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs
index 201f3a05079..8a86a803df7 100644
--- a/desktop/src-tauri/src/commands/project_git.rs
+++ b/desktop/src-tauri/src/commands/project_git.rs
@@ -2,12 +2,21 @@ use super::project_git_exec::{
build_git_auth_config, clean_branch, clean_target_ref, run_git, validate_workspace_clone_url,
GitAuthConfig,
};
+use super::project_git_file_content::{checkout_project_repo, read_preview_content};
use super::project_git_push::push_project_local_repository_blocking;
use super::project_repo_paths::{canonical_repos_roots, find_local_repo_dir};
use crate::app_state::AppState;
use serde::Serialize;
use std::time::UNIX_EPOCH;
use tauri::State;
+
+// Bound eager content without truncating the repository tree.
+const MAX_EAGER_FILE_PREVIEWS: usize = 250;
+
+#[cfg(test)]
+#[path = "project_git_tests.rs"]
+mod tests;
+
#[derive(Clone, Serialize)]
pub struct ProjectRepoCommitInfo {
pub hash: String,
@@ -134,30 +143,6 @@ fn has_untracked_files(output: &str) -> bool {
output.lines().any(|line| line.starts_with("??"))
}
-fn read_preview_content(
- repo_dir: &std::path::Path,
- path: &str,
- size: Option,
-) -> Option {
- const MAX_PREVIEW_BYTES: u64 = 64 * 1024;
- if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) {
- return None;
- }
-
- let full_path = repo_dir.join(path);
- let normalized = full_path.canonicalize().ok()?;
- let repo_root = repo_dir.canonicalize().ok()?;
- if !normalized.starts_with(repo_root) {
- return None;
- }
-
- let bytes = std::fs::read(normalized).ok()?;
- if bytes.contains(&0) {
- return None;
- }
- String::from_utf8(bytes).ok()
-}
-
fn parse_commits(output: &str) -> Vec {
output
.lines()
@@ -254,24 +239,26 @@ fn parse_worktree_files(
.filter_map(|path| {
let full_path = repo_dir.join(path);
let metadata = std::fs::metadata(&full_path).ok()?;
- if !metadata.is_file() {
- return None;
- }
+ metadata.is_file().then_some((path, full_path, metadata))
+ })
+ .enumerate()
+ .map(|(index, (path, full_path, metadata))| {
let size = Some(metadata.len());
let latest_commit = latest_commit_by_path.get(path).cloned();
- Some(ProjectRepoFileInfo {
+ ProjectRepoFileInfo {
path: path.to_string(),
kind: "blob".to_string(),
size,
- preview_content: read_preview_content(repo_dir, path, size),
+ preview_content: (index < MAX_EAGER_FILE_PREVIEWS)
+ .then(|| read_preview_content(repo_dir, path, size))
+ .flatten(),
last_changed_at: latest_commit
.as_ref()
.map(|commit| commit.timestamp)
.or_else(|| path_modified_at(&full_path)),
latest_commit,
- })
+ }
})
- .take(250)
.collect()
}
@@ -314,6 +301,7 @@ fn parse_ls_tree(
output: &str,
latest_commit_by_path: &std::collections::HashMap,
) -> Vec {
+ let mut blob_index = 0;
output
.lines()
.filter_map(|line| {
@@ -323,11 +311,12 @@ fn parse_ls_tree(
let kind = parts.next()?.to_string();
let _object = parts.next()?;
let size = parts.next().and_then(|value| value.parse::().ok());
- let preview_content = if kind == "blob" {
- read_preview_content(repo_dir, path, size)
- } else {
- None
- };
+ if kind == "blob" {
+ blob_index += 1;
+ }
+ let preview_content = (kind == "blob" && blob_index <= MAX_EAGER_FILE_PREVIEWS)
+ .then(|| read_preview_content(repo_dir, path, size))
+ .flatten();
Some(ProjectRepoFileInfo {
path: path.to_string(),
kind,
@@ -339,7 +328,6 @@ fn parse_ls_tree(
latest_commit: latest_commit_by_path.get(path).cloned(),
})
})
- .take(250)
.collect()
}
@@ -727,61 +715,14 @@ pub async fn get_project_repo_snapshot(
tauri::async_runtime::spawn_blocking(move || {
let temp_dir = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?;
let repo_dir = temp_dir.path().join("repo");
- let repo_path = repo_dir
- .to_str()
- .ok_or_else(|| "temporary repository path is not UTF-8".to_string())?;
-
- let explicit_target = target_ref.as_deref().or(target_commit.as_deref());
- if let Some(fetch_ref) = explicit_target {
- run_git(
- &[
- "clone",
- "--filter=blob:none",
- "--no-checkout",
- clone_url.as_str(),
- repo_path,
- ],
- None,
- &auth,
- )?;
- run_git(
- &["fetch", "--depth=100", "origin", fetch_ref],
- Some(&repo_dir),
- &auth,
- )?;
- if let Some(expected_commit) = target_commit.as_deref() {
- let fetched_commit = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
- .ok()
- .and_then(|output| first_output_line(&output))
- .map(|commit| commit.to_ascii_lowercase())
- .ok_or_else(|| "Could not resolve the requested repository ref.".to_string())?;
- if fetched_commit != expected_commit {
- return Err(
- "The requested repository ref changed. Refresh and try again.".to_string(),
- );
- }
- }
- run_git(
- &["checkout", "--detach", "FETCH_HEAD"],
- Some(&repo_dir),
- &auth,
- )?;
- } else {
- let mut clone_args = vec!["clone", "--filter=blob:none"];
- if let Some(ref branch) = branch {
- clone_args.push("--branch");
- clone_args.push(branch.as_str());
- }
- clone_args.push(clone_url.as_str());
- clone_args.push(repo_path);
- if run_git(&clone_args, None, &auth).is_err() && branch.is_some() {
- run_git(
- &["clone", "--filter=blob:none", clone_url.as_str(), repo_path],
- None,
- &auth,
- )?;
- }
- }
+ checkout_project_repo(
+ &repo_dir,
+ &clone_url,
+ branch.as_deref(),
+ target_ref.as_deref(),
+ target_commit.as_deref(),
+ &auth,
+ )?;
let snapshot =
snapshot_from_repo(&repo_dir, &auth, branch.as_deref(), base_branch.as_deref());
diff --git a/desktop/src-tauri/src/commands/project_git_file_content.rs b/desktop/src-tauri/src/commands/project_git_file_content.rs
new file mode 100644
index 00000000000..1ada9f664fc
--- /dev/null
+++ b/desktop/src-tauri/src/commands/project_git_file_content.rs
@@ -0,0 +1,178 @@
+use super::project_git::first_output_line;
+use super::project_git_exec::{
+ build_git_auth_config, clean_branch, clean_target_ref, run_git, validate_workspace_clone_url,
+ GitAuthConfig,
+};
+use super::project_repo_paths::find_local_repo_dir;
+use crate::app_state::AppState;
+use tauri::State;
+
+const MAX_PREVIEW_BYTES: u64 = 64 * 1024;
+
+pub(crate) fn read_preview_content(
+ repo_dir: &std::path::Path,
+ path: &str,
+ size: Option,
+) -> Option {
+ if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) {
+ return None;
+ }
+
+ let full_path = repo_dir.join(path);
+ if std::fs::symlink_metadata(&full_path)
+ .ok()?
+ .file_type()
+ .is_symlink()
+ {
+ return None;
+ }
+ let normalized = full_path.canonicalize().ok()?;
+ let repo_root = repo_dir.canonicalize().ok()?;
+ if !normalized.starts_with(repo_root) {
+ return None;
+ }
+
+ let metadata = std::fs::metadata(&normalized).ok()?;
+ if !metadata.is_file() || metadata.len() > MAX_PREVIEW_BYTES {
+ return None;
+ }
+ let bytes = std::fs::read(normalized).ok()?;
+ if bytes.contains(&0) {
+ return None;
+ }
+ String::from_utf8(bytes).ok()
+}
+
+pub(crate) fn validate_repo_file_path(path: &str) -> Result<(), String> {
+ if path.is_empty()
+ || std::path::Path::new(path)
+ .components()
+ .any(|component| !matches!(component, std::path::Component::Normal(_)))
+ {
+ return Err("Repository file path must be a relative file path.".to_string());
+ }
+ Ok(())
+}
+
+pub(crate) fn checkout_project_repo(
+ repo_dir: &std::path::Path,
+ clone_url: &str,
+ branch: Option<&str>,
+ target_ref: Option<&str>,
+ target_commit: Option<&str>,
+ auth: &GitAuthConfig,
+) -> Result<(), String> {
+ let repo_path = repo_dir
+ .to_str()
+ .ok_or_else(|| "temporary repository path is not UTF-8".to_string())?;
+ let explicit_target = target_ref.or(target_commit);
+
+ if let Some(fetch_ref) = explicit_target {
+ run_git(
+ &[
+ "clone",
+ "--filter=blob:none",
+ "--no-checkout",
+ clone_url,
+ repo_path,
+ ],
+ None,
+ auth,
+ )?;
+ run_git(
+ &["fetch", "--depth=100", "origin", fetch_ref],
+ Some(repo_dir),
+ auth,
+ )?;
+ if let Some(expected_commit) = target_commit {
+ let fetched_commit = run_git(&["rev-parse", "FETCH_HEAD"], Some(repo_dir), auth)
+ .ok()
+ .and_then(|output| first_output_line(&output))
+ .map(|commit| commit.to_ascii_lowercase())
+ .ok_or_else(|| "Could not resolve the requested repository ref.".to_string())?;
+ if fetched_commit != expected_commit {
+ return Err(
+ "The requested repository ref changed. Refresh and try again.".to_string(),
+ );
+ }
+ }
+ run_git(
+ &["checkout", "--detach", "FETCH_HEAD"],
+ Some(repo_dir),
+ auth,
+ )?;
+ return Ok(());
+ }
+
+ let mut clone_args = vec!["clone", "--filter=blob:none"];
+ if let Some(branch) = branch {
+ clone_args.push("--branch");
+ clone_args.push(branch);
+ }
+ clone_args.push(clone_url);
+ clone_args.push(repo_path);
+ if run_git(&clone_args, None, auth).is_err() && branch.is_some() {
+ run_git(
+ &["clone", "--filter=blob:none", clone_url, repo_path],
+ None,
+ auth,
+ )?;
+ }
+ Ok(())
+}
+
+#[tauri::command]
+pub async fn get_project_repo_file_content(
+ clone_url: String,
+ default_branch: Option,
+ target_ref: Option,
+ target_commit: Option,
+ path: String,
+ state: State<'_, AppState>,
+) -> Result, String> {
+ validate_workspace_clone_url(&clone_url, &state)?;
+ validate_repo_file_path(&path)?;
+ let auth = build_git_auth_config(&state)?;
+ let branch = clean_branch(default_branch);
+ let target_ref = clean_target_ref(target_ref);
+ let target_commit = target_commit
+ .map(|value| value.to_ascii_lowercase())
+ .filter(|value| matches!(value.len(), 40 | 64))
+ .filter(|value| value.chars().all(|c| c.is_ascii_hexdigit()));
+
+ tauri::async_runtime::spawn_blocking(move || {
+ let temp_dir = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?;
+ let repo_dir = temp_dir.path().join("repo");
+ checkout_project_repo(
+ &repo_dir,
+ &clone_url,
+ branch.as_deref(),
+ target_ref.as_deref(),
+ target_commit.as_deref(),
+ &auth,
+ )?;
+ Ok(read_preview_content(&repo_dir, &path, None))
+ })
+ .await
+ .map_err(|error| format!("repo file content task failed: {error}"))?
+}
+
+#[tauri::command]
+pub async fn get_project_local_repo_file_content(
+ repos_dir: Option,
+ project_dtag: String,
+ clone_url: Option,
+ path: String,
+) -> Result, String> {
+ validate_repo_file_path(&path)?;
+ tauri::async_runtime::spawn_blocking(move || {
+ let Some(repo_dir) =
+ find_local_repo_dir(repos_dir.as_deref(), &project_dtag, clone_url.as_deref())?
+ else {
+ return Ok(None);
+ };
+ Ok(read_preview_content(&repo_dir, &path, None))
+ })
+ .await
+ .map_err(|error| format!("local repo file content task failed: {error}"))?
+}
diff --git a/desktop/src-tauri/src/commands/project_git_tests.rs b/desktop/src-tauri/src/commands/project_git_tests.rs
new file mode 100644
index 00000000000..99e31d77748
--- /dev/null
+++ b/desktop/src-tauri/src/commands/project_git_tests.rs
@@ -0,0 +1,111 @@
+use super::super::project_git_file_content::validate_repo_file_path;
+use super::*;
+
+#[test]
+fn parse_ls_tree_keeps_paths_after_eager_preview_limit() {
+ let repo_dir = tempfile::tempdir().expect("create temporary repository");
+ std::fs::create_dir(repo_dir.path().join("src")).expect("create source directory");
+ std::fs::write(repo_dir.path().join("README.md"), "# Deferred README")
+ .expect("write deferred README");
+ std::fs::write(
+ repo_dir.path().join("src/application.rs"),
+ "fn deferred() {}",
+ )
+ .expect("write deferred source file");
+ let hidden_entries = (0..MAX_EAGER_FILE_PREVIEWS)
+ .map(|index| {
+ format!(
+ "100644 blob {} 1\t.agents/generated-{index:03}.txt",
+ "a".repeat(40)
+ )
+ })
+ .collect::>()
+ .join("\n");
+ let output = format!(
+ "{hidden_entries}\n100644 blob {} 17\tREADME.md\n100644 blob {} 16\tsrc/application.rs",
+ "b".repeat(40),
+ "c".repeat(40)
+ );
+
+ let files = parse_ls_tree(repo_dir.path(), &output, &std::collections::HashMap::new());
+
+ assert_eq!(files.len(), MAX_EAGER_FILE_PREVIEWS + 2);
+ let readme = files
+ .iter()
+ .find(|file| file.path == "README.md")
+ .expect("README metadata remains visible");
+ assert_eq!(readme.preview_content, None);
+ assert_eq!(
+ read_preview_content(repo_dir.path(), &readme.path, readme.size).as_deref(),
+ Some("# Deferred README")
+ );
+ assert_eq!(
+ files.last().map(|file| file.path.as_str()),
+ Some("src/application.rs")
+ );
+ let source = files.last().expect("source metadata remains visible");
+ assert_eq!(source.preview_content, None);
+ assert_eq!(
+ read_preview_content(repo_dir.path(), &source.path, source.size).as_deref(),
+ Some("fn deferred() {}")
+ );
+}
+
+#[test]
+fn repo_file_paths_reject_traversal_and_absolute_paths() {
+ assert!(validate_repo_file_path("src/application.rs").is_ok());
+ assert!(validate_repo_file_path("../outside.txt").is_err());
+ assert!(validate_repo_file_path("src/../outside.txt").is_err());
+ assert!(validate_repo_file_path("/absolute.txt").is_err());
+}
+
+#[test]
+fn parse_ls_tree_counts_only_blobs_toward_eager_preview_limit() {
+ let repo_dir = tempfile::tempdir().expect("create temporary repository");
+ std::fs::write(repo_dir.path().join("application.rs"), "fn main() {}")
+ .expect("write preview file");
+ let non_blob_entries = (0..MAX_EAGER_FILE_PREVIEWS)
+ .map(|index| {
+ format!(
+ "160000 commit {} -\tvendor/dependency-{index:03}",
+ "a".repeat(40)
+ )
+ })
+ .collect::>()
+ .join("\n");
+ let output = format!(
+ "{non_blob_entries}\n100644 blob {} 12\tapplication.rs",
+ "b".repeat(40)
+ );
+
+ let files = parse_ls_tree(repo_dir.path(), &output, &std::collections::HashMap::new());
+
+ assert_eq!(
+ files
+ .last()
+ .and_then(|file| file.preview_content.as_deref()),
+ Some("fn main() {}")
+ );
+}
+
+#[test]
+fn parse_worktree_files_counts_only_files_toward_eager_preview_limit() {
+ let repo_dir = tempfile::tempdir().expect("create temporary repository");
+ std::fs::create_dir(repo_dir.path().join("directory")).expect("create directory");
+ let paths = (0..MAX_EAGER_FILE_PREVIEWS)
+ .map(|index| {
+ let path = format!("file-{index:03}.txt");
+ std::fs::write(repo_dir.path().join(&path), "preview").expect("write preview file");
+ path
+ })
+ .collect::>();
+ let output = std::iter::once("directory")
+ .chain(paths.iter().map(String::as_str))
+ .collect::>()
+ .join("\0");
+
+ let files = parse_worktree_files(repo_dir.path(), &output, &std::collections::HashMap::new());
+
+ assert_eq!(files.len(), MAX_EAGER_FILE_PREVIEWS);
+ assert!(files.iter().all(|file| file.preview_content.is_some()));
+}
diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs
index 89e6e29cec3..77d519b94ba 100644
--- a/desktop/src-tauri/src/commands/workspace.rs
+++ b/desktop/src-tauri/src/commands/workspace.rs
@@ -10,6 +10,32 @@ use crate::managed_agents::{
};
use crate::relay;
+const WORKSPACE_APPLY_SUPERSEDED: &str = "workspace apply superseded by a newer request";
+
+fn next_apply_generation(generation: &std::sync::atomic::AtomicU64) -> u64 {
+ generation.fetch_add(1, Ordering::AcqRel).wrapping_add(1)
+}
+
+fn assert_current_apply_generation(
+ generation: &std::sync::atomic::AtomicU64,
+ ticket: u64,
+) -> Result<(), String> {
+ if generation.load(Ordering::Acquire) == ticket {
+ Ok(())
+ } else {
+ Err(WORKSPACE_APPLY_SUPERSEDED.to_string())
+ }
+}
+
+async fn begin_workspace_apply(
+ lock: std::sync::Arc>,
+ generation: &std::sync::atomic::AtomicU64,
+) -> (tokio::sync::OwnedMutexGuard<()>, u64) {
+ let guard = lock.lock_owned().await;
+ let ticket = next_apply_generation(generation);
+ (guard, ticket)
+}
+
/// Adopt the pre-scoping global retention database's pending rows into `scope`.
///
/// Best-effort: a failure is logged and the boot proceeds. The migration's own
@@ -131,11 +157,24 @@ pub async fn apply_workspace(
agent_managed_profiles: Option,
app: AppHandle,
) -> Result<(), String> {
+ let state = app.state::();
+ // Take the generation only after entering the serialized transaction. An
+ // apply that is already running remains authoritative until it releases
+ // the lock; the next apply then advances the generation. This keeps every
+ // awaited reconciliation/event-sync phase inside one ordered transaction.
+ let (apply_guard, apply_generation) = begin_workspace_apply(
+ state.workspace_apply_lock.clone(),
+ &state.workspace_apply_generation,
+ )
+ .await;
+
let restore_app = app.clone();
+ let apply_app = app.clone();
// Capture the caller's relay before the blocking apply. Reading shared
// state afterward could pick up a newer concurrent community switch.
let profile_reconcile_relay = relay_url.clone();
tokio::task::spawn_blocking(move || {
+ let app = apply_app;
let state = app.state::();
// ── Validate before mutating ──────────────────────────────────────────
@@ -166,6 +205,11 @@ pub async fn apply_workspace(
None => None,
};
+ // Defense in depth: this transaction still owns the serialized apply
+ // generation before making its first mutation. Normal queued applies
+ // cannot advance it until this transaction releases the guard.
+ assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?;
+
// ── Apply all state changes (nothing below can fail) ──────────────────
{
let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?;
@@ -214,6 +258,8 @@ pub async fn apply_workspace(
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
+ assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?;
+
let state = restore_app.state::();
super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?;
// The Bumble→Pollen migration may have renamed stopped agents. Reconcile
@@ -273,17 +319,15 @@ pub async fn apply_workspace(
.managed_agent_restore_pending
.swap(false, Ordering::AcqRel);
- // The coordinator starts before React applies the selected workspace, so
- // its startup publication may have used the fallback relay and placeholder
- // identity. Correct it off the command path so an unavailable relay cannot
- // hold the frontend on its loading gate. On initial launch, restore MeshLLM
- // first so a slow stopped-status request cannot overwrite a newly restored
- // serving status, then restore managed agents after the admission identity
- // has been published (or the bounded publication attempt has timed out).
+ // Transfer the apply guard to launch restoration. The command can return
+ // promptly, but a queued workspace cannot mutate relay/identity until the
+ // restore has completed every mutable workspace read and side effect.
#[cfg(feature = "mesh-llm")]
{
+ let restore_lock = apply_guard;
let app = restore_app.clone();
tauri::async_runtime::spawn(async move {
+ let _restore_lock = restore_lock;
let state = app.state::();
if restore_pending {
if let Err(error) =
@@ -301,12 +345,15 @@ pub async fn apply_workspace(
}
}
});
+ return Ok(());
}
#[cfg(not(feature = "mesh-llm"))]
if restore_pending {
+ let restore_lock = apply_guard;
let app = restore_app.clone();
tauri::async_runtime::spawn(async move {
+ let _restore_lock = restore_lock;
let state = app.state::();
if let Err(error) =
restore_managed_agents_on_launch(&app, &state.shutdown_started).await
@@ -314,7 +361,59 @@ pub async fn apply_workspace(
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
}
});
+ return Ok(());
}
+ assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?;
+
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::{
+ atomic::{AtomicU64, Ordering},
+ Arc,
+ };
+
+ use super::{assert_current_apply_generation, begin_workspace_apply, next_apply_generation};
+
+ #[test]
+ fn explicit_newer_generation_supersedes_older_ticket() {
+ let generation = AtomicU64::new(0);
+ let older = next_apply_generation(&generation);
+ let newer = next_apply_generation(&generation);
+
+ let error = assert_current_apply_generation(&generation, older).unwrap_err();
+ assert!(error.contains("superseded"), "{error}");
+ assert_current_apply_generation(&generation, newer).unwrap();
+ }
+
+ #[tokio::test]
+ async fn queued_apply_cannot_supersede_running_transaction_or_restore_phase() {
+ let lock = Arc::new(tokio::sync::Mutex::new(()));
+ let generation = Arc::new(AtomicU64::new(0));
+ let (running_guard, running_ticket) =
+ begin_workspace_apply(Arc::clone(&lock), &generation).await;
+
+ let queued_lock = Arc::clone(&lock);
+ let queued_generation = Arc::clone(&generation);
+ let queued = tokio::spawn(async move {
+ let (_guard, ticket) = begin_workspace_apply(queued_lock, &queued_generation).await;
+ ticket
+ });
+ tokio::task::yield_now().await;
+
+ // A queued workspace has not advanced the generation, so every awaited
+ // phase of the running transaction, including one-shot launch restore,
+ // remains authoritative while it holds the lock.
+ assert_eq!(generation.load(Ordering::Acquire), running_ticket);
+ assert_current_apply_generation(&generation, running_ticket).unwrap();
+ assert!(!queued.is_finished());
+
+ drop(running_guard);
+ let queued_ticket = queued.await.unwrap();
+ assert!(queued_ticket > running_ticket);
+ assert_current_apply_generation(&generation, queued_ticket).unwrap();
+ }
+}
diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs
index 39a7ecff74c..6fcf53fa23a 100644
--- a/desktop/src-tauri/src/deep_link.rs
+++ b/desktop/src-tauri/src/deep_link.rs
@@ -372,6 +372,10 @@ fn is_hex64(value: &str) -> bool {
value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())
}
+fn is_git_object_id(value: &str) -> bool {
+ matches!(value.len(), 40 | 64) && value.chars().all(|c| c.is_ascii_hexdigit())
+}
+
/// Mirrors `isValidDtag` in `entityLink.ts` — the link format addresses a
/// narrower d-tag charset than Nostr allows.
fn is_linkable_dtag(value: &str) -> bool {
@@ -416,13 +420,14 @@ fn parse_entity_deep_link(url: &Url) -> Option<()> {
let needs_event_id = host == "pr" || host == "issue";
let allows_tab = host == "repo" || host == "project";
- let (mut owner, mut dtag, mut id, mut tab) = (None, None, None, None);
+ let (mut owner, mut dtag, mut id, mut tab, mut commit) = (None, None, None, None, None);
for (key, value) in url.query_pairs() {
let slot = match key.as_ref() {
"owner" => &mut owner,
"d" => &mut dtag,
"id" if needs_event_id => &mut id,
"tab" if allows_tab => &mut tab,
+ "commit" if host == "repo" => &mut commit,
_ => return None,
};
if slot.is_some() {
@@ -440,8 +445,13 @@ fn parse_entity_deep_link(url: &Url) -> Option<()> {
if needs_event_id && !id.is_some_and(|id| is_hex64(&id)) {
return None;
}
- if let Some(tab) = tab {
- if !ENTITY_LINK_TABS.contains(&tab.as_str()) {
+ if let Some(tab) = tab.as_deref() {
+ if !ENTITY_LINK_TABS.contains(&tab) {
+ return None;
+ }
+ }
+ if let Some(commit) = commit {
+ if tab.as_deref() != Some("commits") || !is_git_object_id(&commit) {
return None;
}
}
diff --git a/desktop/src-tauri/src/deep_link_tests.rs b/desktop/src-tauri/src/deep_link_tests.rs
index eaddbb7a4c3..84a08c4c64e 100644
--- a/desktop/src-tauri/src/deep_link_tests.rs
+++ b/desktop/src-tauri/src/deep_link_tests.rs
@@ -34,6 +34,11 @@ fn parse_entity_deep_link_accepts_every_share_link_shape() {
"{raw}"
);
}
+ let commit_link = format!(
+ "buzz://repo?owner={owner}&d={dtag}&tab=commits&commit={}",
+ golden["eventId"].as_str().unwrap()
+ );
+ assert!(parse_entity_deep_link(&Url::parse(&commit_link).unwrap()).is_some());
let expected_tabs = golden["tabs"]
.as_array()
.unwrap()
@@ -64,6 +69,8 @@ fn parse_entity_deep_link_rejects_malformed_and_non_canonical_links() {
// Unknown tab value, duplicate tab, and tab on an event link.
format!("buzz://repo?owner={owner}&d=buzz-world&tab=overview"),
format!("buzz://repo?owner={owner}&d=buzz-world&tab=prs&tab=prs"),
+ format!("buzz://repo?owner={owner}&d=buzz-world&tab=files&commit={event_id}"),
+ format!("buzz://repo?owner={owner}&d=buzz-world&tab=commits&commit=short"),
format!("buzz://pr?id={event_id}&owner={owner}&d=buzz-world&tab=prs"),
format!("buzz://repo/extra?owner={owner}&d=buzz-world"),
format!("buzz://repo?owner={owner}&d=buzz-world#top"),
diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs
index aca2339a3c4..5c6bbc4c777 100644
--- a/desktop/src-tauri/src/huddle/tts.rs
+++ b/desktop/src-tauri/src/huddle/tts.rs
@@ -18,10 +18,9 @@
//! → cancel flag: a 10 ms barge-in monitor thread silences the player and
//! releases tts_active on the flag's rising edge (~15 ms flag-to-silence,
//! even mid-sentence while the worker is blocked in synth_chunk); the
-//! worker then consumes the flag — drain queue + clear + play (un-pause).
-//! Monitor clears and worker player mutations are serialized through the
-//! `player_ops` mutex, with the flag re-checked under the lock — see the
-//! monitor block in `tts_worker` for the race this closes.
+//! worker then consumes the flag and drains stale text. Every Player
+//! operation is serialized by `PlaybackCoordinator`; cancellation swaps in
+//! a fresh queue and drops the old Player after releasing the coordinator.
//! ```
//!
//! Lookahead pipelining spans *items*, not just sentences within one item:
@@ -55,6 +54,9 @@ use super::preprocessing::preprocess_for_tts;
#[path = "tts_voice_transition.rs"]
mod voice_transition;
use voice_transition::*;
+#[path = "tts_playback.rs"]
+mod playback;
+use playback::*;
#[path = "tts_startup.rs"]
mod startup;
use startup::await_worker_startup;
@@ -102,10 +104,26 @@ const SYNTH_STEPS: usize = 1;
/// the leading waveform is important.
const FADE_OUT_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.008) as usize;
-/// Length of the zero-sample cushion prepended when playback is idle, so the
-/// OS audio device / rodio mixer has a fully-quiet ramp-up window before the
-/// real onset hits. Continuously queued chunks receive no synthetic padding.
-const SENTENCE_LEAD_IN_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.020) as usize;
+/// rodio 0.22.2 bootstraps `UniformSourceIterator` when a source is added to
+/// the mixer (`conversions/uniform.rs:49-66`). Its empty queue's 512-sample
+/// span (`queue.rs::SourcesQueueInput::new`) can therefore retain placeholder
+/// format metadata until the next span. The lead-in covers that whole span,
+/// rounded up to the next millisecond, while preserving the product's existing
+/// 20 ms quiet ramp-up. Continuously queued chunks receive no synthetic padding.
+const SAMPLES_PER_MS: usize = SAMPLE_RATE as usize / 1_000;
+const PRODUCT_RAMP_UP_MS: usize = 20;
+const PRODUCT_RAMP_UP_SAMPLES: usize = PRODUCT_RAMP_UP_MS * SAMPLES_PER_MS;
+const RODIO_ADD_BOOTSTRAP_SPAN_SAMPLES: usize = 512;
+const RODIO_ADD_BOOTSTRAP_CUSHION_MS: usize =
+ RODIO_ADD_BOOTSTRAP_SPAN_SAMPLES.div_ceil(SAMPLES_PER_MS);
+const SENTENCE_LEAD_IN_SAMPLES: usize = {
+ let bootstrap_cushion = RODIO_ADD_BOOTSTRAP_CUSHION_MS * SAMPLES_PER_MS;
+ if PRODUCT_RAMP_UP_SAMPLES > bootstrap_cushion {
+ PRODUCT_RAMP_UP_SAMPLES
+ } else {
+ bootstrap_cushion
+ }
+};
type WorkerControlState = (
Arc,
@@ -332,7 +350,6 @@ fn tts_worker(
// ── 3. Initialise rodio output device ─────────────────────────────────────
use rodio::buffer::SamplesBuffer;
- use rodio::Player;
let sink_handle = match super::audio_output::open_output_sink_by_name(output_device.as_deref())
{
@@ -360,28 +377,24 @@ fn tts_worker(
}
};
- // Single persistent Player for the lifetime of the worker — all sentence
- // buffers from all text items append here, and rodio plays them gaplessly.
- // Persistence is what enables cross-item pipelining: the worker never
- // waits for one item to drain before synthesizing the next.
- //
- // Shared (Arc) with the barge-in monitor thread below, which needs to
- // silence it while this thread is blocked inside `synth_chunk`.
- let player = Arc::new(Player::connect_new(sink_handle.mixer()));
- playback_probe.install(Arc::clone(&player));
+ // One coordinator owns the current Player and every operation on it.
+ // Cancellation replaces its queue while the output stream and mixer stay
+ // alive, preserving cross-item pipelining without exposing Player handles.
+ let playback = Arc::new(PlaybackCoordinator::new(sink_handle.mixer()));
+ playback_probe.install(Arc::clone(&playback));
// Prime the audio output stream with a short silent buffer.
// On macOS, CoreAudio initializes the output device lazily on first use.
// Without this, the first real append races against device startup and
- // player.empty() returns true before audio has started draining — causing
+ // playback.empty() returns true before audio has started draining — causing
// the first TTS message to be truncated after a few words.
{
let silence = vec![0.0f32; SAMPLE_RATE as usize / 10]; // 100ms of silence
- player.append(SamplesBuffer::new(channels, rate, silence));
+ playback.append_untracked(SamplesBuffer::new(channels, rate, silence));
// Wait for the silent buffer to drain — this ensures the output stream
// is fully initialized before the first real utterance.
let deadline = std::time::Instant::now() + AUDIO_PRIME_TIMEOUT;
- while !player.empty() {
+ while !playback.empty() {
if std::time::Instant::now() >= deadline {
eprintln!("buzz-desktop: tts stage=startup status=failed reason=output_prime");
let _ = startup_tx.send(Err(
@@ -397,16 +410,14 @@ fn tts_worker(
}
eprintln!("buzz-desktop: tts stage=startup status=ready");
- let player_ops = Arc::clone(&playback_probe.player_ops);
let activity_frames = Arc::new(Mutex::new(VecDeque::::new()));
let monitor_stop = Arc::new(AtomicBool::new(false));
let monitor = spawn_tts_monitor(TtsMonitorState {
- player: Arc::clone(&player),
+ playback: Arc::clone(&playback),
cancel: Arc::clone(&cancel),
voice_cancel: Arc::clone(&voice_cancel),
tts_active: Arc::clone(&tts_active),
stop: Arc::clone(&monitor_stop),
- player_ops: Arc::clone(&player_ops),
activity_frames: Arc::clone(&activity_frames),
active_speaker: Arc::clone(&active_speaker),
speaker_cancel: Arc::clone(&speaker_cancel),
@@ -429,75 +440,77 @@ fn tts_worker(
// EXPERIMENTAL (latency bench): `Some(emit_frames)` = stream PCM deltas
// out of Pocket as they are generated (see tts_streaming.rs).
let tts_streaming = streaming_emit_frames();
- // `first_append` = "no audio queued since the player last went idle".
- // Flipped by `build_sentence_append_buffer` on the first real append; the
- // idle branch below uses it to decide when to drop `tts_active` and to
- // arm a fresh lead-in cushion for the next utterance.
- let mut first_append = true;
let mut last_route_id = 0;
let mut deferred_text = VecDeque::new();
let append_audio = |prepared: PreparedModelAudio,
route_id: u64,
speaker_pubkey: Option<&str>,
speaker_generation: u64| {
- let _ops = lock_player_ops(&player_ops);
- if cancel.load(Ordering::Acquire)
- || voice_cancel.load(Ordering::Acquire)
- || shutdown.load(Ordering::Acquire)
- {
- let reason = if shutdown.load(Ordering::Acquire) {
- "shutdown"
- } else if cancel.load(Ordering::Acquire) {
- "barge_in"
- } else {
- "voice_switch"
- };
- eprintln!(
- "buzz-desktop: tts stage=synthesis status=cancelled reason={reason} route_id={route_id}"
- );
- return false;
- }
- let speaker_is_current = speaker_pubkey.is_none_or(|pubkey| {
- current_speaker_generation(&speaker_generations, pubkey) == speaker_generation
+ let sample_count = prepared.sample_count;
+ let chunk_index = prepared.chunk_index;
+ let activity = speaker_pubkey.map(|pubkey| {
+ build_tts_speaker_activity_frames(&prepared.buffer, pubkey, SAMPLE_RATE as usize)
});
- if !speaker_is_current {
- eprintln!(
- "buzz-desktop: tts stage=synthesis status=cancelled reason=speaker_removed route_id={route_id}"
- );
+ let accepted = playback.append_if(
+ SamplesBuffer::new(channels, rate, prepared.buffer),
+ |player_empty| {
+ if cancel.load(Ordering::Acquire)
+ || voice_cancel.load(Ordering::Acquire)
+ || shutdown.load(Ordering::Acquire)
+ {
+ let reason = if shutdown.load(Ordering::Acquire) {
+ "shutdown"
+ } else if cancel.load(Ordering::Acquire) {
+ "barge_in"
+ } else {
+ "voice_switch"
+ };
+ eprintln!(
+ "buzz-desktop: tts stage=synthesis status=cancelled reason={reason} route_id={route_id}"
+ );
+ return false;
+ }
+ if speaker_pubkey.is_some_and(|pubkey| {
+ current_speaker_generation(&speaker_generations, pubkey) != speaker_generation
+ }) {
+ eprintln!(
+ "buzz-desktop: tts stage=synthesis status=cancelled reason=speaker_removed route_id={route_id}"
+ );
+ return false;
+ }
+ if let Some(pubkey) = speaker_pubkey {
+ let mut active = active_speaker
+ .lock()
+ .unwrap_or_else(|error| error.into_inner());
+ if player_empty {
+ active.take();
+ }
+ if active
+ .as_deref()
+ .is_some_and(|current| !current.eq_ignore_ascii_case(pubkey))
+ {
+ return false;
+ }
+ active.get_or_insert_with(|| pubkey.to_ascii_lowercase());
+ activity_frames
+ .lock()
+ .unwrap_or_else(|error| error.into_inner())
+ .extend(activity.unwrap_or_default());
+ }
+ true
+ },
+ // Publish activity under the coordinator: an append and the mic
+ // gate it implies are one transition, so a cancellation that
+ // replaces this player cannot have its release overwritten by a
+ // `true` landing after the fact.
+ || tts_active.store(true, Ordering::Release),
+ );
+ if !accepted {
return false;
}
- if let Some(pubkey) = speaker_pubkey {
- let mut active = active_speaker
- .lock()
- .unwrap_or_else(|error| error.into_inner());
- if player.empty() {
- active.take();
- }
- if active
- .as_deref()
- .is_some_and(|current| !current.eq_ignore_ascii_case(pubkey))
- {
- return false;
- }
- active.get_or_insert_with(|| pubkey.to_ascii_lowercase());
- }
- if let Some(pubkey) = speaker_pubkey {
- activity_frames
- .lock()
- .unwrap_or_else(|error| error.into_inner())
- .extend(build_tts_speaker_activity_frames(
- &prepared.buffer,
- pubkey,
- SAMPLE_RATE as usize,
- ));
- }
- player.append(SamplesBuffer::new(channels, rate, prepared.buffer));
eprintln!(
- "buzz-desktop: tts stage=player status=append_accepted route_id={route_id} chunk_index={} sample_count={}",
- prepared.chunk_index, prepared.sample_count
+ "buzz-desktop: tts stage=player status=append_accepted route_id={route_id} chunk_index={chunk_index} sample_count={sample_count}"
);
- // Set this only after append so STT remains open during synthesis.
- tts_active.store(true, Ordering::Release);
true
};
@@ -509,9 +522,8 @@ fn tts_worker(
&speaker_generations,
&tts_active,
(&text_rx, &mut deferred_text, &mut no_current_text),
- Some((&player, &player_ops)),
+ Some(&playback),
) {
- first_append = true;
continue;
}
if handle_cancel_or_shutdown(
@@ -521,14 +533,13 @@ fn tts_worker(
(&text_rx, &mut deferred_text, &mut no_current_text),
&voice_change_ack,
None,
- Some((&player, &player_ops)),
+ Some(&playback),
) {
if shutdown.load(Ordering::Acquire) {
break;
}
// Cancel consumed: queued audio cleared, queue drained. The next
// append starts a new utterance and needs its own lead-in cushion.
- first_append = true;
continue;
}
@@ -555,7 +566,7 @@ fn tts_worker(
// Nothing queued. If playback has also finished, the agent
// has gone quiet — release the mic gate and reset the
// lead-in so the next utterance gets a fresh cushion.
- if player.empty() && !first_append {
+ playback.release_if_drained(|| {
tts_active.store(false, Ordering::Release);
active_speaker
.lock()
@@ -564,8 +575,7 @@ fn tts_worker(
eprintln!(
"buzz-desktop: tts stage=player status=drained route_id={last_route_id}"
);
- first_append = true;
- }
+ });
continue;
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
@@ -582,12 +592,11 @@ fn tts_worker(
(&text_rx, &mut deferred_text, &mut queued_text),
&voice_change_ack,
pending_route_id,
- Some((&player, &player_ops)),
+ Some(&playback),
) {
if shutdown.load(Ordering::Acquire) {
break;
}
- first_append = true;
continue;
}
let Some(queued_text) = queued_text else {
@@ -607,7 +616,7 @@ fn tts_worker(
);
continue;
}
- if !player.empty()
+ if !playback.empty()
&& queued_text
.speaker_pubkey
.as_deref()
@@ -639,18 +648,14 @@ fn tts_worker(
// release stale ownership before doing any potentially slow voice or
// synthesis work. Serialize the drain decision with Stop and append so
// those paths observe one coherent utterance boundary.
- {
- let _ops = lock_player_ops(&player_ops);
- if player.empty() && !first_append {
- tts_active.store(false, Ordering::Release);
- active_speaker
- .lock()
- .unwrap_or_else(|error| error.into_inner())
- .take();
- eprintln!("buzz-desktop: tts stage=player status=drained route_id={last_route_id}");
- first_append = true;
- }
- }
+ playback.release_if_drained(|| {
+ tts_active.store(false, Ordering::Release);
+ active_speaker
+ .lock()
+ .unwrap_or_else(|error| error.into_inner())
+ .take();
+ eprintln!("buzz-desktop: tts stage=player status=drained route_id={last_route_id}");
+ });
// From this point until the item finishes, an empty player can mean a
// voice-preparation or synthesis gap rather than a drained utterance.
@@ -716,9 +721,8 @@ fn tts_worker(
(&text_rx, &mut deferred_text, &mut no_current_text),
&voice_change_ack,
Some(route_id),
- Some((&player, &player_ops)),
+ Some(&playback),
) {
- first_append = true;
synthesis_outcome = "cancelled";
break;
}
@@ -738,8 +742,7 @@ fn tts_worker(
emit_frames,
(&cancel, &voice_cancel, &shutdown),
StreamingPlayback {
- player: &player,
- first_append: &mut first_append,
+ playback: &playback,
route_id,
},
&mut |prepared| {
@@ -791,9 +794,8 @@ fn tts_worker(
(&text_rx, &mut deferred_text, &mut no_current_text),
&voice_change_ack,
Some(route_id),
- Some((&player, &player_ops)),
+ Some(&playback),
) {
- first_append = true;
synthesis_outcome = "cancelled";
break 'playback_chunks;
}
@@ -817,25 +819,20 @@ fn tts_worker(
// synthesis that completed after cancellation so stale audio
// never reaches the player, while keeping buzz-voice's
// extracted April engine API unchanged.
- first_append = true;
synthesis_outcome = "cancelled";
break 'playback_chunks;
}
match synthesis {
Ok(samples) if !samples.is_empty() => {
- if let Some(prepared) = playback_audio.push(
- samples,
- chunk_index,
- &mut first_append,
- player.empty(),
- ) {
+ if let Some(prepared) = playback
+ .prepare_audio(|empty| playback_audio.push(samples, chunk_index, empty))
+ {
if !append_audio(
prepared,
route_id,
speaker_pubkey.as_deref(),
speaker_generation,
) {
- first_append = true;
synthesis_outcome = "cancelled";
break 'playback_chunks;
}
@@ -857,14 +854,13 @@ fn tts_worker(
}
}
}
- if let Some(prepared) = playback_audio.finish(&mut first_append, player.empty()) {
+ if let Some(prepared) = playback.prepare_audio(|empty| playback_audio.finish(empty)) {
if !append_audio(
prepared,
route_id,
speaker_pubkey.as_deref(),
speaker_generation,
) {
- first_append = true;
synthesis_outcome = "cancelled";
break 'playback_chunks;
}
@@ -884,8 +880,8 @@ fn tts_worker(
}
}
- // Stop the barge-in monitor before exiting — it holds a Player clone,
- // and an orphaned monitor would keep ticking against a dead pipeline.
+ // Stop the barge-in monitor before exiting so an orphaned monitor cannot
+ // keep ticking against a dead pipeline.
monitor_stop.store(true, Ordering::Release);
if let Ok(handle) = monitor {
let _ = handle.join();
diff --git a/desktop/src-tauri/src/huddle/tts_audio.rs b/desktop/src-tauri/src/huddle/tts_audio.rs
index 80bf0c4661c..993ce11298f 100644
--- a/desktop/src-tauri/src/huddle/tts_audio.rs
+++ b/desktop/src-tauri/src/huddle/tts_audio.rs
@@ -21,35 +21,24 @@ impl PlaybackChunkAudio {
&mut self,
samples: Vec,
chunk_index: usize,
- first_append: &mut bool,
playback_idle: bool,
) -> Option {
if samples.is_empty() {
return None;
}
let previous = self.pending.replace((samples, chunk_index))?;
- let prepared = prepare_model_audio(previous, first_append, playback_idle, false);
+ let prepared = prepare_model_audio(previous, playback_idle, false);
Some(prepared)
}
- pub(super) fn finish(
- &mut self,
- first_append: &mut bool,
- playback_idle: bool,
- ) -> Option {
+ pub(super) fn finish(&mut self, playback_idle: bool) -> Option {
let pending = self.pending.take()?;
- Some(prepare_model_audio(
- pending,
- first_append,
- playback_idle,
- true,
- ))
+ Some(prepare_model_audio(pending, playback_idle, true))
}
}
fn prepare_model_audio(
(samples, chunk_index): (Vec, usize),
- first_append: &mut bool,
starts_playback_chunk: bool,
ends_playback_chunk: bool,
) -> PreparedModelAudio {
@@ -59,7 +48,7 @@ fn prepare_model_audio(
apply_fade_out(&mut audio);
}
PreparedModelAudio {
- buffer: build_sentence_append_buffer(first_append, audio, starts_playback_chunk),
+ buffer: build_sentence_append_buffer(audio, starts_playback_chunk),
sample_count,
chunk_index,
}
@@ -80,14 +69,9 @@ pub(super) fn apply_fade_out(samples: &mut [f32]) {
}
pub(super) fn build_sentence_append_buffer(
- first_append: &mut bool,
audio: Vec,
starts_playback_chunk: bool,
) -> Vec {
- if *first_append {
- *first_append = false;
- }
-
let lead_in_len = if starts_playback_chunk {
SENTENCE_LEAD_IN_SAMPLES
} else {
@@ -106,19 +90,14 @@ mod tests {
#[test]
fn model_units_are_queued_contiguously_without_injected_silence() {
let mut chunk = PlaybackChunkAudio::new();
- let mut first_append = true;
- assert!(chunk
- .push(vec![0.4; 16], 0, &mut first_append, false)
- .is_none());
+ assert!(chunk.push(vec![0.4; 16], 0, false).is_none());
let first = chunk
- .push(vec![0.5; 16], 1, &mut first_append, false)
+ .push(vec![0.5; 16], 1, false)
.expect("first ready model unit");
assert_eq!(first.buffer, vec![0.4; 16]);
- let last = chunk
- .finish(&mut first_append, false)
- .expect("last ready model unit");
+ let last = chunk.finish(false).expect("last ready model unit");
assert_eq!(last.buffer.len(), 16);
assert_eq!(last.sample_count, 16);
}
@@ -126,39 +105,27 @@ mod tests {
#[test]
fn empty_edge_units_do_not_steal_audio_boundaries() {
let mut chunk = PlaybackChunkAudio::new();
- let mut first_append = true;
-
- assert!(chunk
- .push(Vec::new(), 0, &mut first_append, false)
- .is_none());
- assert!(chunk
- .push(vec![0.5; 16], 1, &mut first_append, false)
- .is_none());
- assert!(chunk
- .push(Vec::new(), 2, &mut first_append, false)
- .is_none());
-
- let only = chunk
- .finish(&mut first_append, false)
- .expect("only audible model unit");
+
+ assert!(chunk.push(Vec::new(), 0, false).is_none());
+ assert!(chunk.push(vec![0.5; 16], 1, false).is_none());
+ assert!(chunk.push(Vec::new(), 2, false).is_none());
+
+ let only = chunk.finish(false).expect("only audible model unit");
assert_eq!(only.buffer.len(), 16);
}
#[test]
fn playback_underrun_rearms_the_onset_cushion() {
let mut chunk = PlaybackChunkAudio::new();
- let mut first_append = true;
- assert!(chunk
- .push(vec![0.4; 16], 0, &mut first_append, false)
- .is_none());
+ assert!(chunk.push(vec![0.4; 16], 0, false).is_none());
let first = chunk
- .push(vec![0.5; 16], 1, &mut first_append, false)
+ .push(vec![0.5; 16], 1, false)
.expect("first ready model unit");
assert_eq!(first.buffer.len(), 16);
let after_underrun = chunk
- .push(vec![0.6; 16], 2, &mut first_append, true)
+ .push(vec![0.6; 16], 2, true)
.expect("second ready model unit");
assert_eq!(after_underrun.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16);
assert!(after_underrun.buffer[..SENTENCE_LEAD_IN_SAMPLES]
diff --git a/desktop/src-tauri/src/huddle/tts_playback.rs b/desktop/src-tauri/src/huddle/tts_playback.rs
new file mode 100644
index 00000000000..93a5b16b8c8
--- /dev/null
+++ b/desktop/src-tauri/src/huddle/tts_playback.rs
@@ -0,0 +1,485 @@
+use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
+
+use rodio::{mixer::Mixer, Player, Source};
+
+/// Serializes every operation on the TTS player and owns the utterance-boundary
+/// bookkeeping that must change atomically when playback is replaced.
+///
+/// Poison recovery is sound because `PlaybackState` has no partially-valid
+/// representation: `Player` replacement is a single assignment, booleans are
+/// independently valid at either value, and no mutable reference to the state
+/// leaves the locked operation that created it.
+pub(super) struct PlaybackCoordinator {
+ mixer: Mixer,
+ state: Mutex,
+}
+
+struct PlaybackState {
+ player: Player,
+ /// `true` while no append has been committed since the last utterance
+ /// boundary. Only `append_if` clears it, so it records appends that were
+ /// actually queued — never one the authorization refused.
+ first_append: bool,
+ synthesis_in_flight: bool,
+ synthesis_generation: u64,
+}
+
+pub(super) struct SynthesisFlightGuard {
+ playback: Arc,
+ generation: u64,
+}
+
+impl Drop for SynthesisFlightGuard {
+ fn drop(&mut self) {
+ let mut state = self.playback.lock();
+ if state.synthesis_generation == self.generation {
+ state.synthesis_in_flight = false;
+ }
+ }
+}
+
+impl PlaybackCoordinator {
+ pub(super) fn new(mixer: &Mixer) -> Self {
+ Self {
+ mixer: mixer.clone(),
+ state: Mutex::new(PlaybackState {
+ player: Player::connect_new(mixer),
+ first_append: true,
+ synthesis_in_flight: false,
+ synthesis_generation: 0,
+ }),
+ }
+ }
+
+ fn lock(&self) -> MutexGuard<'_, PlaybackState> {
+ self.state.lock().unwrap_or_else(PoisonError::into_inner)
+ }
+
+ /// Queue `source` when `authorize` accepts, then publish the append with
+ /// `commit` before releasing the coordinator. `commit` runs under the lock
+ /// so an append and the activity state it implies are one transition: a
+ /// concurrent cancellation either replaces the queue before this append is
+ /// authorized, or observes the committed state after it — never lands its
+ /// own release between the two and gets overwritten.
+ pub(super) fn append_if(
+ &self,
+ source: S,
+ authorize: impl FnOnce(bool) -> bool,
+ commit: impl FnOnce(),
+ ) -> bool
+ where
+ S: Source- + Send + 'static,
+ {
+ let mut state = self.lock();
+ if !authorize(state.player.empty()) {
+ return false;
+ }
+ state.player.append(source);
+ state.first_append = false;
+ commit();
+ true
+ }
+
+ pub(super) fn append_untracked
(&self, source: S)
+ where
+ S: Source- + Send + 'static,
+ {
+ self.lock().player.append(source);
+ }
+
+ pub(super) fn empty(&self) -> bool {
+ self.lock().player.empty()
+ }
+
+ /// Observe playback emptiness under the coordinator so the onset decision
+ /// for the audio being built is serialized with append and cancellation.
+ pub(super) fn prepare_audio
(&self, prepare: impl FnOnce(bool) -> R) -> R {
+ let state = self.lock();
+ let empty = state.player.empty();
+ prepare(empty)
+ }
+
+ pub(super) fn release_if_drained(&self, release: impl FnOnce()) -> bool {
+ let mut state = self.lock();
+ if !state.player.empty() || state.first_append {
+ return false;
+ }
+ release();
+ state.first_append = true;
+ true
+ }
+
+ pub(super) fn begin_synthesis(self: &Arc) -> SynthesisFlightGuard {
+ let generation = {
+ let mut state = self.lock();
+ state.synthesis_generation = state.synthesis_generation.wrapping_add(1);
+ state.synthesis_in_flight = true;
+ state.synthesis_generation
+ };
+ SynthesisFlightGuard {
+ playback: Arc::clone(self),
+ generation,
+ }
+ }
+
+ pub(super) fn with_playback_live(&self, observe: impl FnOnce(bool) -> R) -> R {
+ let state = self.lock();
+ observe(!state.player.empty() || state.synthesis_in_flight)
+ }
+
+ /// Replace live playback with a fresh queue, publishing the replacement
+ /// with `commit` before releasing the coordinator. The old player is
+ /// dropped after releasing, so rodio's teardown cannot extend the critical
+ /// section. Concurrent cancel observers elect exactly one replacement
+ /// because replacement resets both liveness signals.
+ ///
+ /// `commit` is the mirror of `append_if`'s: a replacement and the activity
+ /// state it implies are one transition, so an append that wins the lock
+ /// handoff after this cancellation cannot have its own publication
+ /// overwritten by a `false` landing late.
+ pub(super) fn cancel_if_live(
+ &self,
+ authorize: impl FnOnce() -> bool,
+ commit: impl FnOnce(),
+ ) -> bool {
+ let old_player = {
+ let mut state = self.lock();
+ if (state.player.empty() && !state.synthesis_in_flight) || !authorize() {
+ return false;
+ }
+ state.first_append = true;
+ state.synthesis_in_flight = false;
+ state.synthesis_generation = state.synthesis_generation.wrapping_add(1);
+ let old_player = std::mem::replace(&mut state.player, Player::connect_new(&self.mixer));
+ commit();
+ old_player
+ };
+ drop(old_player);
+ true
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{
+ num::NonZero,
+ sync::{
+ atomic::{AtomicBool, AtomicUsize, Ordering},
+ Arc, Barrier,
+ },
+ thread,
+ time::{Duration, Instant},
+ };
+
+ use rodio::buffer::SamplesBuffer;
+
+ use super::*;
+
+ fn coordinator() -> (Arc, rodio::mixer::MixerSource) {
+ let channels = NonZero::new(1).expect("nonzero channels");
+ let rate = NonZero::new(24_000).expect("nonzero rate");
+ let (mixer, source) = rodio::mixer::mixer(channels, rate);
+ (Arc::new(PlaybackCoordinator::new(&mixer)), source)
+ }
+
+ fn append_second(playback: &PlaybackCoordinator) {
+ playback.append_if(
+ SamplesBuffer::new(
+ NonZero::new(1).expect("nonzero channels"),
+ NonZero::new(24_000).expect("nonzero rate"),
+ vec![0.25; 24_000],
+ ),
+ |_| true,
+ || {},
+ );
+ }
+
+ #[test]
+ fn cancel_replaces_playback_without_waiting_for_the_mixer() {
+ let (playback, _unpulled_source) = coordinator();
+ append_second(&playback);
+
+ let started = Instant::now();
+ assert!(playback.cancel_if_live(|| true, || {}));
+
+ assert!(started.elapsed() < Duration::from_millis(50));
+ assert!(playback.empty());
+ }
+
+ #[test]
+ fn concurrent_cancel_observers_elect_exactly_one_replacement() {
+ let (playback, _unpulled_source) = coordinator();
+ append_second(&playback);
+ let barrier = Arc::new(Barrier::new(3));
+ let replacements = Arc::new(AtomicUsize::new(0));
+ let mut threads = Vec::new();
+ for _ in 0..2 {
+ let playback = Arc::clone(&playback);
+ let barrier = Arc::clone(&barrier);
+ let replacements = Arc::clone(&replacements);
+ threads.push(thread::spawn(move || {
+ barrier.wait();
+ if playback.cancel_if_live(|| true, || {}) {
+ replacements.fetch_add(1, Ordering::Relaxed);
+ }
+ }));
+ }
+ barrier.wait();
+ for thread in threads {
+ thread.join().expect("cancel observer");
+ }
+
+ assert_eq!(replacements.load(Ordering::Relaxed), 1);
+ }
+
+ #[test]
+ fn append_and_cancel_are_one_serialized_public_operation() {
+ let (playback, _unpulled_source) = coordinator();
+ append_second(&playback);
+ let append_authorized = Arc::new(Barrier::new(2));
+ let release_append = Arc::new(Barrier::new(2));
+ let append_thread = {
+ let playback = Arc::clone(&playback);
+ let append_authorized = Arc::clone(&append_authorized);
+ let release_append = Arc::clone(&release_append);
+ thread::spawn(move || {
+ playback.append_if(
+ SamplesBuffer::new(
+ NonZero::new(1).expect("nonzero channels"),
+ NonZero::new(24_000).expect("nonzero rate"),
+ vec![0.5; 24_000],
+ ),
+ |_| {
+ append_authorized.wait();
+ release_append.wait();
+ true
+ },
+ || {},
+ )
+ })
+ };
+ append_authorized.wait();
+ let cancel_thread = {
+ let playback = Arc::clone(&playback);
+ thread::spawn(move || playback.cancel_if_live(|| true, || {}))
+ };
+ release_append.wait();
+
+ assert!(append_thread.join().expect("append"));
+ assert!(cancel_thread.join().expect("cancel"));
+ assert!(
+ playback.empty(),
+ "cancel must replace the queue after append"
+ );
+ }
+
+ #[test]
+ fn an_accepted_append_publishes_its_activity_inside_the_append_transition() {
+ let (playback, _unpulled_source) = coordinator();
+ let committed = Arc::new(AtomicBool::new(false));
+
+ let appended = playback.append_if(
+ SamplesBuffer::new(
+ NonZero::new(1).expect("nonzero channels"),
+ NonZero::new(24_000).expect("nonzero rate"),
+ vec![0.25; 24_000],
+ ),
+ |_| true,
+ || {
+ // The coordinator is still held, so no cancellation can land a
+ // release between queueing this audio and publishing it.
+ assert!(
+ playback.state.try_lock().is_err(),
+ "commit must run inside the append's critical section"
+ );
+ committed.store(true, Ordering::Release);
+ },
+ );
+
+ assert!(appended);
+ assert!(
+ committed.load(Ordering::Acquire),
+ "an accepted append must publish"
+ );
+ assert!(
+ playback.state.try_lock().is_ok(),
+ "the coordinator is released once the append returns"
+ );
+ }
+
+ #[test]
+ fn a_refused_append_publishes_nothing_and_leaves_the_onset_armed() {
+ let (playback, _unpulled_source) = coordinator();
+
+ // The worker builds its buffer under the coordinator, then the append
+ // is refused — cancelled, or owned by another speaker.
+ playback.prepare_audio(|starts_playback_chunk| {
+ assert!(starts_playback_chunk, "a fresh coordinator is idle");
+ });
+ let appended = playback.append_if(
+ SamplesBuffer::new(
+ NonZero::new(1).expect("nonzero channels"),
+ NonZero::new(24_000).expect("nonzero rate"),
+ vec![0.25; 24_000],
+ ),
+ |_| false,
+ || panic!("a refused append must publish nothing"),
+ );
+
+ assert!(!appended);
+ // Nothing was queued, so this is not a drained utterance: releasing
+ // here would drop the mic gate and log a drain for audio that never
+ // played, and cost the next append its onset cushion.
+ assert!(
+ !playback.release_if_drained(|| panic!("a refused append is not a drain")),
+ "a refused append must not present as a drained utterance"
+ );
+ }
+
+ #[test]
+ fn an_appended_utterance_still_releases_exactly_once_when_it_drains() {
+ let (playback, mut source) = coordinator();
+ append_second(&playback);
+
+ assert!(
+ !playback.release_if_drained(|| panic!("queued audio is not drained")),
+ "queued audio must not release"
+ );
+ while !playback.empty() {
+ assert!(
+ source.next().is_some(),
+ "the mixer source outlives the queue"
+ );
+ }
+
+ let releases = Arc::new(AtomicUsize::new(0));
+ for _ in 0..2 {
+ let releases = Arc::clone(&releases);
+ playback.release_if_drained(move || {
+ releases.fetch_add(1, Ordering::Relaxed);
+ });
+ }
+
+ assert_eq!(
+ releases.load(Ordering::Relaxed),
+ 1,
+ "a drained utterance releases once and rearms the onset"
+ );
+ }
+
+ /// The reverse direction of the same barrier: a cancellation must publish
+ /// its release *inside* the replacement. The cancelling thread is
+ /// otherwise past its replacement and about to release the mic gate, while
+ /// an append that wins the coordinator handoff has already published its
+ /// own `true` — a `false` landing outside the replacement would ungate the
+ /// mic for audio that is actually playing, and VAD would hear our own TTS
+ /// and barge in on it.
+ #[test]
+ fn a_replacement_publishes_its_release_inside_the_cancel_transition() {
+ let (playback, _unpulled_source) = coordinator();
+ append_second(&playback);
+ let committed = Arc::new(AtomicBool::new(false));
+
+ let replaced = playback.cancel_if_live(
+ || true,
+ || {
+ // Still held: no append can commit its own activity between
+ // this replacement and the release it implies.
+ assert!(
+ playback.state.try_lock().is_err(),
+ "commit must run inside the cancellation's critical section"
+ );
+ committed.store(true, Ordering::Release);
+ },
+ );
+
+ assert!(replaced, "live playback must be replaced");
+ assert!(
+ committed.load(Ordering::Acquire),
+ "a replacement must publish"
+ );
+ assert!(
+ playback.state.try_lock().is_ok(),
+ "the coordinator is released once the cancellation returns"
+ );
+ }
+
+ /// A cancellation that replaces nothing publishes nothing: the caller
+ /// still owns releasing the gate, and a replacement that never happened
+ /// must not present as one.
+ #[test]
+ fn a_cancellation_with_nothing_live_publishes_nothing() {
+ let (playback, _unpulled_source) = coordinator();
+
+ assert!(!playback.cancel_if_live(|| true, || panic!("nothing was replaced")));
+ assert!(!playback.cancel_if_live(|| false, || panic!("cancellation was refused")));
+ }
+
+ /// Both publication directions under real contention: whichever
+ /// transition takes the coordinator last decides, and the activity flag
+ /// must describe the player that survived.
+ #[test]
+ fn a_cancellation_and_an_append_never_disagree_about_the_mic_gate() {
+ for _ in 0..256 {
+ let (playback, _unpulled_source) = coordinator();
+ let tts_active = Arc::new(AtomicBool::new(true));
+ append_second(&playback);
+ let barrier = Arc::new(Barrier::new(2));
+
+ let canceller = {
+ let playback = Arc::clone(&playback);
+ let tts_active = Arc::clone(&tts_active);
+ let barrier = Arc::clone(&barrier);
+ thread::spawn(move || {
+ barrier.wait();
+ playback.cancel_if_live(|| true, || tts_active.store(false, Ordering::Release))
+ })
+ };
+ let appender = {
+ let playback = Arc::clone(&playback);
+ let tts_active = Arc::clone(&tts_active);
+ let barrier = Arc::clone(&barrier);
+ thread::spawn(move || {
+ barrier.wait();
+ playback.append_if(
+ SamplesBuffer::new(
+ NonZero::new(1).expect("nonzero channels"),
+ NonZero::new(24_000).expect("nonzero rate"),
+ vec![0.5; 24_000],
+ ),
+ |_| true,
+ || tts_active.store(true, Ordering::Release),
+ )
+ })
+ };
+
+ let replaced = canceller.join().expect("canceller");
+ let appended = appender.join().expect("appender");
+ assert!(replaced, "live playback must be replaced");
+ assert!(appended, "the append is authorized either way");
+
+ assert_eq!(
+ tts_active.load(Ordering::Acquire),
+ !playback.empty(),
+ "the mic gate must agree with the player that survived"
+ );
+ }
+ }
+
+ #[test]
+ fn cancellation_rearms_first_append_and_releases_activity_once() {
+ let (playback, _unpulled_source) = coordinator();
+ append_second(&playback);
+ assert!(playback.cancel_if_live(|| true, || {}));
+ assert!(!playback.release_if_drained(|| panic!("fresh replacement is not a drain")));
+ playback.prepare_audio(|starts_playback_chunk| {
+ assert!(
+ starts_playback_chunk,
+ "the first append after replacement must carry the onset cushion"
+ );
+ });
+
+ append_second(&playback);
+ assert!(!playback.release_if_drained(|| panic!("queued audio is not drained")));
+ }
+}
diff --git a/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs b/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs
index 4b9c2824f73..185ca56d08c 100644
--- a/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs
+++ b/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs
@@ -1,12 +1,11 @@
use super::*;
pub(super) struct TtsMonitorState {
- pub(super) player: Arc,
+ pub(super) playback: Arc,
pub(super) cancel: Arc,
pub(super) voice_cancel: Arc,
pub(super) tts_active: Arc,
pub(super) stop: Arc,
- pub(super) player_ops: Arc>,
pub(super) activity_frames: Arc>>,
pub(super) active_speaker: ActiveSpeaker,
pub(super) speaker_cancel: SpeakerCancellation,
@@ -23,25 +22,28 @@ pub(super) fn spawn_tts_monitor(state: TtsMonitorState) -> std::io::Result std::io::Result,
+ playback: &PlaybackCoordinator,
tts_active: &AtomicBool,
) {
let Some(cancelled) = cancellation
@@ -105,12 +106,10 @@ pub(super) fn silence_cancelled_speaker(
else {
return;
};
- let _ops = lock_player_ops(player_ops);
- if take_cancelled_active_speaker(&cancelled, active_speaker) {
- player.clear();
- player.play();
- tts_active.store(false, Ordering::Release);
- }
+ playback.cancel_if_live(
+ || take_cancelled_active_speaker(&cancelled, active_speaker),
+ || tts_active.store(false, Ordering::Release),
+ );
}
fn take_cancelled_active_speaker(cancelled: &str, active_speaker: &ActiveSpeaker) -> bool {
@@ -133,7 +132,7 @@ pub(super) fn consume_speaker_cancel(
generations: &SpeakerGenerations,
tts_active: &AtomicBool,
text_state: CancelTextState<'_>,
- player: Option<(&rodio::Player, &Mutex<()>)>,
+ playback: Option<&PlaybackCoordinator>,
) -> bool {
let Some(cancelled) = cancellation
.lock()
@@ -145,12 +144,11 @@ pub(super) fn consume_speaker_cancel(
let (text_rx, deferred_text, current_text) = text_state;
retain_current_speaker_text(generations, deferred_text, current_text, text_rx);
let mut cleared_player = false;
- if let Some((player, player_ops)) = player {
- let _ops = lock_player_ops(player_ops);
- if take_cancelled_active_speaker(&cancelled, active_speaker) {
- player.clear();
- player.play();
- tts_active.store(false, Ordering::Release);
+ if let Some(playback) = playback {
+ if playback.cancel_if_live(
+ || take_cancelled_active_speaker(&cancelled, active_speaker),
+ || tts_active.store(false, Ordering::Release),
+ ) {
cleared_player = true;
}
}
@@ -164,6 +162,31 @@ pub(super) fn consume_speaker_cancel(
mod tests {
use super::*;
+ use std::sync::Barrier;
+
+ use rodio::buffer::SamplesBuffer;
+
+ /// Headless coordinator: a real mixer with its source held unpulled, so
+ /// the queue never drains and no output device is opened.
+ fn coordinator() -> (Arc, rodio::mixer::MixerSource) {
+ let channels = std::num::NonZero::new(1).expect("nonzero channels");
+ let rate = std::num::NonZero::new(24_000).expect("nonzero rate");
+ let (mixer, source) = rodio::mixer::mixer(channels, rate);
+ (Arc::new(PlaybackCoordinator::new(&mixer)), source)
+ }
+
+ /// Append as `speaker` the way the worker does: activity is published
+ /// inside the append transition.
+ fn speak(playback: &PlaybackCoordinator, tts_active: &AtomicBool) {
+ let channels = std::num::NonZero::new(1).expect("nonzero channels");
+ let rate = std::num::NonZero::new(24_000).expect("nonzero rate");
+ assert!(playback.append_if(
+ SamplesBuffer::new(channels, rate, vec![0.25; 24_000]),
+ |_| true,
+ || tts_active.store(true, Ordering::Release),
+ ));
+ }
+
#[test]
fn stale_targeted_cancel_does_not_release_the_next_speaker() {
let active_speaker = Arc::new(Mutex::new(Some("bob".to_string())));
@@ -174,4 +197,129 @@ mod tests {
Some("bob")
);
}
+
+ /// The wedge Mari found: the monitor silences the cancelled speaker while
+ /// the worker is mid-append. The monitor takes `active_speaker`, so the
+ /// worker's later `consume_speaker_cancel` fails authorization and never
+ /// clears `tts_active` — if the worker's `true` could land after the
+ /// monitor's `false`, mic gating stays active with nothing playing.
+ #[test]
+ fn a_targeted_cancel_racing_an_append_leaves_the_mic_gate_released() {
+ for _ in 0..64 {
+ let (playback, _unpulled_source) = coordinator();
+ let tts_active = Arc::new(AtomicBool::new(false));
+ let active_speaker: ActiveSpeaker = Arc::new(Mutex::new(None));
+ let speaker_cancel: SpeakerCancellation = Arc::new(Mutex::new(None));
+
+ speak(&playback, &tts_active);
+ active_speaker
+ .lock()
+ .expect("active speaker")
+ .replace("alice".to_string());
+ speaker_cancel
+ .lock()
+ .expect("speaker cancel")
+ .replace("alice".to_string());
+
+ let barrier = Arc::new(Barrier::new(2));
+ let monitor = {
+ let playback = Arc::clone(&playback);
+ let tts_active = Arc::clone(&tts_active);
+ let active_speaker = Arc::clone(&active_speaker);
+ let speaker_cancel = Arc::clone(&speaker_cancel);
+ let barrier = Arc::clone(&barrier);
+ thread::spawn(move || {
+ barrier.wait();
+ silence_cancelled_speaker(
+ &speaker_cancel,
+ &active_speaker,
+ &playback,
+ &tts_active,
+ );
+ })
+ };
+ let worker = {
+ let playback = Arc::clone(&playback);
+ let tts_active = Arc::clone(&tts_active);
+ let barrier = Arc::clone(&barrier);
+ thread::spawn(move || {
+ barrier.wait();
+ // The worker appends the next chunk of the utterance the
+ // monitor is cancelling.
+ playback.append_if(
+ SamplesBuffer::new(
+ std::num::NonZero::new(1).expect("nonzero channels"),
+ std::num::NonZero::new(24_000).expect("nonzero rate"),
+ vec![0.25; 24_000],
+ ),
+ |_| true,
+ || tts_active.store(true, Ordering::Release),
+ )
+ })
+ };
+
+ let appended = worker.join().expect("worker");
+ monitor.join().expect("monitor");
+
+ // Whichever order the two took the coordinator, the surviving
+ // activity flag must describe the surviving player.
+ assert_eq!(
+ tts_active.load(Ordering::Acquire),
+ !playback.empty(),
+ "the mic gate must agree with the player that survived \
+ (appended={appended})"
+ );
+ }
+ }
+
+ /// The worker arm of the same race: the monitor already took the speaker,
+ /// so `consume_speaker_cancel` is not authorized to clear anything. It
+ /// must not report a clear it did not perform, and it must not disturb the
+ /// activity flag the monitor already published.
+ #[test]
+ fn consuming_a_cancel_the_monitor_already_handled_preserves_the_released_gate() {
+ let (playback, _unpulled_source) = coordinator();
+ let tts_active = Arc::new(AtomicBool::new(false));
+ let active_speaker: ActiveSpeaker = Arc::new(Mutex::new(None));
+ let speaker_cancel: SpeakerCancellation = Arc::new(Mutex::new(None));
+ let generations: SpeakerGenerations = Arc::new(Mutex::new(HashMap::new()));
+ let (_text_tx, text_rx) = mpsc::channel::();
+ let mut deferred_text = VecDeque::new();
+ let mut current_text = None;
+
+ speak(&playback, &tts_active);
+ active_speaker
+ .lock()
+ .expect("active speaker")
+ .replace("alice".to_string());
+ speaker_cancel
+ .lock()
+ .expect("speaker cancel")
+ .replace("alice".to_string());
+
+ silence_cancelled_speaker(&speaker_cancel, &active_speaker, &playback, &tts_active);
+ assert!(
+ !tts_active.load(Ordering::Acquire),
+ "the monitor releases the mic gate it cancelled"
+ );
+
+ let cleared = consume_speaker_cancel(
+ &speaker_cancel,
+ &active_speaker,
+ &generations,
+ &tts_active,
+ (&text_rx, &mut deferred_text, &mut current_text),
+ Some(&playback),
+ );
+
+ assert!(
+ !cleared,
+ "the worker must not claim a clear the monitor already performed"
+ );
+ assert!(
+ !tts_active.load(Ordering::Acquire),
+ "the released mic gate must survive the worker's pass"
+ );
+ assert!(playback.empty(), "the cancelled utterance stays silenced");
+ }
}
diff --git a/desktop/src-tauri/src/huddle/tts_streaming.rs b/desktop/src-tauri/src/huddle/tts_streaming.rs
index 2bb401c43f5..cf618f8be69 100644
--- a/desktop/src-tauri/src/huddle/tts_streaming.rs
+++ b/desktop/src-tauri/src/huddle/tts_streaming.rs
@@ -27,8 +27,7 @@ pub(super) fn streaming_emit_frames() -> Option {
/// Playback context threaded through one streamed chunk.
pub(super) struct StreamingPlayback<'a> {
- pub(super) player: &'a rodio::Player,
- pub(super) first_append: &'a mut bool,
+ pub(super) playback: &'a PlaybackCoordinator,
pub(super) route_id: u64,
}
@@ -52,11 +51,7 @@ pub(super) fn synthesize_streaming(
append_audio: &mut dyn FnMut(PreparedModelAudio) -> bool,
) -> Option<&'static str> {
let (cancel, voice_cancel, shutdown) = signals;
- let StreamingPlayback {
- player,
- first_append,
- route_id,
- } = playback;
+ let StreamingPlayback { playback, route_id } = playback;
let mut playback_audio = PlaybackChunkAudio::new();
let mut delta_index = 0usize;
let stream_result = engine.synth_chunk_streaming(text, style, emit_frames, &mut |samples| {
@@ -69,7 +64,7 @@ pub(super) fn synthesize_streaming(
let chunk_index = delta_index;
delta_index += 1;
if let Some(prepared) =
- playback_audio.push(samples, chunk_index, first_append, player.empty())
+ playback.prepare_audio(|empty| playback_audio.push(samples, chunk_index, empty))
{
if !append_audio(prepared) {
return false;
@@ -79,9 +74,8 @@ pub(super) fn synthesize_streaming(
});
match stream_result {
Ok(true) => {
- if let Some(prepared) = playback_audio.finish(first_append, player.empty()) {
+ if let Some(prepared) = playback.prepare_audio(|empty| playback_audio.finish(empty)) {
if !append_audio(prepared) {
- *first_append = true;
return Some("cancelled");
}
}
@@ -91,7 +85,6 @@ pub(super) fn synthesize_streaming(
eprintln!(
"buzz-desktop: tts stage=synthesis status=cancelled reason=stream_callback route_id={route_id}"
);
- *first_append = true;
Some("cancelled")
}
Err(_) => {
diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs
index 50e4d17ced5..3358d526446 100644
--- a/desktop/src-tauri/src/huddle/tts_tests.rs
+++ b/desktop/src-tauri/src/huddle/tts_tests.rs
@@ -785,22 +785,12 @@ fn apply_fade_out_single_sample() {
// ── build_sentence_append_buffer tests ───────────────────────────────────
-/// `first_append` still flips on the first append for `tts_active` gating.
-#[test]
-fn build_sentence_append_buffer_flips_first_append() {
- let mut first = true;
- let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], false);
- assert_eq!(buf, vec![0.5; 100]);
- assert!(!first, "first call must flip the flag");
-}
-
/// Playback chunks are contiguous: Pocket's generated pause is not extended
/// with a fixed inter-sentence silence budget.
#[test]
fn sentence_append_buffer_does_not_inject_silence() {
- let mut first = true;
- let first_buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], false);
- let second_buf = build_sentence_append_buffer(&mut first, vec![0.25; 100], false);
+ let first_buf = build_sentence_append_buffer(vec![0.5; 100], false);
+ let second_buf = build_sentence_append_buffer(vec![0.25; 100], false);
assert_eq!(first_buf, vec![0.5; 100]);
assert_eq!(second_buf, vec![0.25; 100]);
@@ -810,8 +800,7 @@ fn sentence_append_buffer_does_not_inject_silence() {
/// the first phoneme while the output path wakes back up.
#[test]
fn idle_playback_gets_an_onset_cushion() {
- let mut first = true;
- let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], true);
+ let buf = build_sentence_append_buffer(vec![0.5; 100], true);
assert_eq!(buf.len(), SENTENCE_LEAD_IN_SAMPLES + 100);
assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0));
diff --git a/desktop/src-tauri/src/huddle/tts_tests/token_split.rs b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs
index 404f8a8153f..bd9d85215ef 100644
--- a/desktop/src-tauri/src/huddle/tts_tests/token_split.rs
+++ b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs
@@ -1,18 +1,19 @@
use super::*;
-/// The onset cushion covers 20 ms at the production sample rate.
+/// The onset cushion rounds rodio's 512-sample bootstrap span up to 22 ms at
+/// the 24 kHz production sample rate (528 samples).
#[test]
fn chunk_lead_in_is_sane() {
- assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 480, "20 ms × 24 kHz");
+ assert_eq!(RODIO_ADD_BOOTSTRAP_SPAN_SAMPLES, 512);
+ assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 528, "22 ms × 24 kHz");
}
/// Model-token splits remain contiguous: only the playback chunk as a whole
/// receives its onset cushion and trailing sentence gap.
#[test]
fn token_split_units_do_not_add_sentence_boundary_padding() {
- let mut first = true;
- let first_unit = build_sentence_append_buffer(&mut first, vec![0.5; 100], false);
- let last_unit = build_sentence_append_buffer(&mut first, vec![0.25; 100], false);
+ let first_unit = build_sentence_append_buffer(vec![0.5; 100], false);
+ let last_unit = build_sentence_append_buffer(vec![0.25; 100], false);
assert_eq!(first_unit.len(), 100);
assert_eq!(first_unit.last(), Some(&0.5));
diff --git a/desktop/src-tauri/src/huddle/tts_voice_transition.rs b/desktop/src-tauri/src/huddle/tts_voice_transition.rs
index 99b165bfe81..78c9c3cc4d1 100644
--- a/desktop/src-tauri/src/huddle/tts_voice_transition.rs
+++ b/desktop/src-tauri/src/huddle/tts_voice_transition.rs
@@ -5,10 +5,12 @@ use std::{
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc::{self, SyncSender},
- Arc, Mutex, MutexGuard, PoisonError,
+ Arc, Mutex,
},
};
+use super::{PlaybackCoordinator, SynthesisFlightGuard};
+
use crate::huddle::pocket::{load_voice_style, VoiceStyle, DEFAULT_VOICE, VOICE_FILE_EXT};
#[derive(Debug)]
@@ -32,51 +34,29 @@ pub(super) type CancelSignals<'a> = (&'a AtomicBool, &'a AtomicBool);
#[derive(Clone)]
pub(super) struct PlaybackProbe {
- player: Arc>>>,
- pub(super) player_ops: Arc>,
- synthesis_in_flight: Arc,
-}
-
-pub(super) struct SynthesisFlightGuard {
- playback_probe: PlaybackProbe,
-}
-
-impl Drop for SynthesisFlightGuard {
- fn drop(&mut self) {
- self.playback_probe.set_synthesis_in_flight(false);
- }
+ playback: Arc>>>,
}
impl PlaybackProbe {
pub(super) fn new() -> Self {
Self {
- player: Arc::new(Mutex::new(None)),
- player_ops: Arc::new(Mutex::new(())),
- synthesis_in_flight: Arc::new(AtomicBool::new(false)),
+ playback: Arc::new(Mutex::new(None)),
}
}
- pub(super) fn install(&self, player: Arc) {
- self.player
+ pub(super) fn install(&self, playback: Arc) {
+ self.playback
.lock()
.unwrap_or_else(|error| error.into_inner())
- .replace(player);
+ .replace(playback);
}
- pub(super) fn set_synthesis_in_flight(&self, in_flight: bool) {
- let _ops = lock_player_ops(&self.player_ops);
- self.synthesis_in_flight.store(in_flight, Ordering::Release);
+ pub(super) fn begin_synthesis(&self) -> Option {
+ self.playback().map(|playback| playback.begin_synthesis())
}
- pub(super) fn begin_synthesis(&self) -> SynthesisFlightGuard {
- self.set_synthesis_in_flight(true);
- SynthesisFlightGuard {
- playback_probe: self.clone(),
- }
- }
-
- fn player(&self) -> Option> {
- self.player
+ pub(super) fn playback(&self) -> Option> {
+ self.playback
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone()
@@ -199,19 +179,18 @@ pub(super) fn request_active_speaker_cancel(
playback_probe: &PlaybackProbe,
expected_speaker_pubkey: &str,
) -> bool {
- let Some(player) = playback_probe.player() else {
+ let Some(playback) = playback_probe.playback() else {
return false;
};
- let _ops = lock_player_ops(&playback_probe.player_ops);
- let playback_live =
- !player.empty() || playback_probe.synthesis_in_flight.load(Ordering::Acquire);
- request_active_speaker_cancel_while_locked(
- generations,
- active_speaker,
- cancellation,
- playback_live,
- expected_speaker_pubkey,
- )
+ playback.with_playback_live(|playback_live| {
+ request_active_speaker_cancel_while_locked(
+ generations,
+ active_speaker,
+ cancellation,
+ playback_live,
+ expected_speaker_pubkey,
+ )
+ })
}
fn request_active_speaker_cancel_while_locked(
@@ -475,10 +454,8 @@ fn log_cancelled_route(route_id: u64, reason: &str) {
/// Check for cancel or shutdown. Returns `true` if the caller should break/continue.
/// On cancel: drains the text queue and clears the cancel flag.
///
-/// `player` pairs the Player with the `player_ops` mutex shared with the
-/// barge-in monitor thread; the cancel/shutdown clear runs under that lock so
-/// it is serialized with the monitor's stale-branch re-check (see the monitor
-/// block in `tts_worker`).
+/// `playback` is the coordinator shared with the barge-in monitor; replacing
+/// playback is serialized with append and with the monitor's stale observation.
pub(super) fn handle_cancel_or_shutdown(
cancel_signals: CancelSignals<'_>,
shutdown: &AtomicBool,
@@ -486,7 +463,7 @@ pub(super) fn handle_cancel_or_shutdown(
text_state: CancelTextState<'_>,
voice_change_ack: &VoiceChangeAck,
active_route_id: Option,
- player: Option<(&rodio::Player, &Mutex<()>)>,
+ playback: Option<&PlaybackCoordinator>,
) -> bool {
let (cancel, voice_cancel) = cancel_signals;
let (text_rx, deferred_text, current_text) = text_state;
@@ -495,11 +472,7 @@ pub(super) fn handle_cancel_or_shutdown(
"buzz-desktop: tts stage=cancellation reason=shutdown route_id={}",
active_route_id.unwrap_or(0)
);
- if let Some((p, ops)) = player {
- let _ops = lock_player_ops(ops);
- p.clear();
- }
- tts_active.store(false, Ordering::Release);
+ release_playback(playback, tts_active);
return true;
}
if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) {
@@ -525,33 +498,27 @@ pub(super) fn handle_cancel_or_shutdown(
})
.flatten();
retain_cancelled_text(deferred_text, current_text, text_rx, preserve_generation);
- if let Some((p, ops)) = player {
- let _ops = lock_player_ops(ops);
- // `Player::clear()` removes queued sources AND pauses the player
- // (rodio 0.22 `clear()` ends with `self.pause()`). With one
- // persistent Player for the worker's lifetime, the un-pause is
- // mandatory: without `play()`, every append after a barge-in
- // would queue silently forever.
- p.clear();
- p.play();
- // Consume the flag under the lock: once released with
- // `cancel == false`, the monitor's stale branch no-ops instead
- // of clearing the fresh post-cancel utterance.
- }
- tts_active.store(false, Ordering::Release);
+ // Consume the flag at the coordinator serialization point: once
+ // released with `cancel == false`, a stale monitor observation cannot
+ // replace fresh post-cancel playback.
+ release_playback(playback, tts_active);
return true;
}
false
}
-/// Acquire the `player_ops` lock, recovering from poison.
-///
-/// The data under the mutex is `()` — it only serializes Player mutations —
-/// so a panicked holder leaves nothing inconsistent to observe and recovery
-/// is always safe. Without this, a worker panic would wedge the monitor (or
-/// vice versa) on `unwrap()`.
-pub(super) fn lock_player_ops(ops: &Mutex<()>) -> MutexGuard<'_, ()> {
- ops.lock().unwrap_or_else(PoisonError::into_inner)
+/// Silence playback and release the mic gate as one transition. When a player
+/// is live the release is published inside the replacement, so an append that
+/// wins the coordinator handoff cannot have its own activity publication
+/// overwritten by this `false`. With nothing live there is no transition to
+/// join and the gate is released directly.
+fn release_playback(playback: Option<&PlaybackCoordinator>, tts_active: &AtomicBool) {
+ let released = playback.is_some_and(|playback| {
+ playback.cancel_if_live(|| true, || tts_active.store(false, Ordering::Release))
+ });
+ if !released {
+ tts_active.store(false, Ordering::Release);
+ }
}
#[cfg(test)]
@@ -562,16 +529,16 @@ mod speaker_generation_tests {
let channels = std::num::NonZero::new(1).expect("non-zero channels");
let sample_rate = std::num::NonZero::new(24_000).expect("non-zero sample rate");
let (mixer, _mixer_source) = rodio::mixer::mixer(channels, sample_rate);
- let player = Arc::new(rodio::Player::connect_new(&mixer));
+ let playback = Arc::new(PlaybackCoordinator::new(&mixer));
if playback_live {
- player.append(rodio::buffer::SamplesBuffer::new(
- channels,
- sample_rate,
- vec![0.0; 24_000],
- ));
+ playback.append_if(
+ rodio::buffer::SamplesBuffer::new(channels, sample_rate, vec![0.0; 24_000]),
+ |_| true,
+ || {},
+ );
}
let probe = PlaybackProbe::new();
- probe.install(player);
+ probe.install(playback);
probe
}
@@ -586,6 +553,43 @@ mod speaker_generation_tests {
}
}
+ /// A cancellation releases the mic gate whether or not there was audio to
+ /// silence. With a live player the release rides inside the replacement;
+ /// with nothing live there is no transition to join, and skipping the
+ /// release would strand the gate open with the worker already past the
+ /// utterance.
+ #[test]
+ fn cancellation_releases_the_mic_gate_with_or_without_live_playback() {
+ for playback_live in [false, true] {
+ let probe = playback_probe(playback_live);
+ let playback = probe.playback().expect("installed coordinator");
+ let cancel = AtomicBool::new(true);
+ let voice_cancel = AtomicBool::new(false);
+ let shutdown = AtomicBool::new(false);
+ let tts_active = AtomicBool::new(true);
+ let voice_change_ack = Arc::new(Mutex::new(None));
+ let (_text_tx, text_rx) = mpsc::channel();
+ let mut deferred_text = VecDeque::new();
+ let mut current_text = None;
+
+ assert!(handle_cancel_or_shutdown(
+ (&cancel, &voice_cancel),
+ &shutdown,
+ &tts_active,
+ (&text_rx, &mut deferred_text, &mut current_text),
+ &voice_change_ack,
+ None,
+ Some(&playback),
+ ));
+
+ assert!(
+ !tts_active.load(Ordering::Acquire),
+ "cancellation must release the mic gate (playback_live={playback_live})"
+ );
+ assert!(playback.empty(), "cancellation silences any queued audio");
+ }
+ }
+
#[test]
fn removing_a_speaker_invalidates_only_that_speakers_queued_text() {
let generations = Arc::new(Mutex::new(HashMap::new()));
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index ec1d0498524..e254befe466 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -26,9 +26,13 @@ mod migration;
#[cfg(test)]
mod model_tests;
mod models;
+mod native_relay_client;
mod native_websocket;
+mod native_websocket_batch;
mod nostr_bind;
pub mod nostr_convert;
+mod observed_unread;
+mod persona_catalog;
mod prevent_sleep;
mod ptt_shortcut;
mod relay;
@@ -42,6 +46,7 @@ mod terminal_runtime;
mod terminal_transport;
#[cfg(target_os = "macos")]
mod tray_menu;
+mod unread_catch_up;
mod util;
#[cfg(target_os = "linux")]
pub mod webkit_rendering;
@@ -197,10 +202,10 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init());
- // The global-shortcut plugin is omitted from test builds: linking it into
- // the lib-test binary makes it fail to load on Windows (STATUS_ENTRYPOINT_NOT_FOUND) before any test runs.
- #[cfg(not(test))]
- let builder = builder.plugin(ptt_shortcut::global_shortcut_plugin());
+ // The push-to-talk global-shortcut plugin lives in `ptt_shortcut`, next to
+ // the registration lifecycle it drives. Installing it is a no-op in test
+ // builds; see that module for why.
+ let builder = ptt_shortcut::install(builder);
// Register the updater only in configured release builds; omit it locally.
#[cfg(buzz_updater_enabled)]
@@ -226,6 +231,9 @@ pub fn run() {
.manage(BuilderlabLogin::default())
.manage(commands::pairing::PairingHandle::new())
.manage(terminal_runtime::TerminalSessions::default())
+ .manage(archive::sync::ArchiveSyncState::default())
+ .manage(native_relay_client::NativeRelayClient::default())
+ .manage(observed_unread::ObservedUnreadStore::default())
.setup(move |app| {
let app_handle = app.handle().clone();
#[cfg(target_os = "macos")]
@@ -566,9 +574,11 @@ pub fn run() {
get_user_notes,
get_git_identity,
get_project_repo_snapshot,
+ get_project_repo_file_content,
get_project_repo_diff,
get_project_local_repo_diff,
get_project_local_repo_snapshot,
+ get_project_local_repo_file_content,
get_project_repo_sync_status,
list_project_local_repositories,
clone_project_repository,
@@ -717,6 +727,10 @@ pub fn run() {
update_managed_agent,
discover_backend_providers,
probe_backend_provider,
+ persona_catalog::fetch_persona_catalog,
+ unread_catch_up::unread_catch_up,
+ observed_unread::observed_unread_open_scope,
+ observed_unread::observed_unread_ingest,
list_personas,
create_persona,
update_persona,
@@ -831,6 +845,9 @@ pub fn run() {
archive::index_observer_channel_id,
archive::read_unindexed_observer_rows,
archive::get_agent_usage_series,
+ archive::sync::announce_archive_sync_epoch,
+ archive::sync::start_archive_sync,
+ archive::sync::stop_archive_sync,
is_auto_update_supported,
set_window_vibrancy,
#[cfg(target_os = "macos")]
diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs
index 895aad712a8..a225f492d33 100644
--- a/desktop/src-tauri/src/managed_agents/restore.rs
+++ b/desktop/src-tauri/src/managed_agents/restore.rs
@@ -373,7 +373,7 @@ pub async fn restore_managed_agents_on_launch(
.lock()
.map_err(|error| error.to_string())?;
- let mut successfully_spawned: Vec = Vec::new();
+ let mut successfully_spawned: Vec<(String, String)> = Vec::new();
for (pubkey, outcome) in spawn_results {
match outcome {
@@ -404,8 +404,15 @@ pub async fn restore_managed_agents_on_launch(
record.last_stopped_at = None;
record.last_exit_code = None;
record.last_error = None;
- runtimes.insert(key, super::ManagedAgentPairRuntime::starting(*process));
- successfully_spawned.push(pubkey);
+ runtimes.insert(
+ key.clone(),
+ super::ManagedAgentPairRuntime::starting(*process),
+ );
+ // Carry the spawn key's relay into profile reconciliation so
+ // the background task queries/publishes on the relay this
+ // spawn was actually keyed to — not whatever workspace is
+ // active when the task eventually executes.
+ successfully_spawned.push((pubkey, key.relay_url.clone()));
}
SpawnOutcome::Failed(error) => {
let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else {
@@ -425,7 +432,7 @@ pub async fn restore_managed_agents_on_launch(
let reconcile_items: Vec<(String, crate::commands::ProfileReconcileData)> =
successfully_spawned
.iter()
- .filter_map(|pubkey| {
+ .filter_map(|(pubkey, spawn_relay)| {
let record = records.iter().find(|r| r.pubkey == *pubkey)?;
// Resolve the effective harness for the avatar-fallback
// derivation (the snapshot may be empty/stale for an inherited
@@ -438,7 +445,10 @@ pub async fn restore_managed_agents_on_launch(
private_key_nsec: record.private_key_nsec.clone(),
name: record.name.clone(),
relay_url: record.relay_url.clone(),
- target_relay_url: None,
+ // Pin the relay this spawn was keyed to (see the
+ // successfully_spawned push above) so the deferred
+ // task cannot resolve a post-switch workspace.
+ target_relay_url: Some(spawn_relay.clone()),
avatar_url: record.avatar_url.clone(),
auth_tag: record.auth_tag.clone(),
pubkey: record.pubkey.clone(),
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 923530d34b9..0ce5ca7b219 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -68,6 +68,8 @@ mod lifecycle;
#[cfg(test)]
use lifecycle::kill_stale_tracked_processes_with;
pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes};
+mod spawn_key; // production spawn-key derivation + its regressions
+pub(crate) use spawn_key::bound_runtime_key;
/// Classify an agent's persona against the live catalog for the Agents-menu
/// drift indicator. Returns `(out_of_date, orphaned)`.
@@ -934,21 +936,20 @@ fn child_rust_log_filter() -> String {
}
}
+/// Spawn (or adopt) the runtime pair for `record` on the caller's bound
+/// workspace relay. `workspace_relay` can only be produced by
+/// `bind_expected_relay_scope`, so this spawn consumes — by construction — the
+/// exact workspace-relay read the caller's scope assertion passed on; it never
+/// re-reads the mutable override (see `relay::scope`). The key comes from
+/// [`bound_runtime_key`] — the seam the spawn-key regressions exercise.
pub fn start_managed_agent_process(
app: &AppHandle,
record: &mut ManagedAgentRecord,
runtimes: &mut HashMap,
owner_hex: Option<&str>,
+ workspace_relay: &crate::relay::ScopedWorkspaceRelay,
) -> Result<(), String> {
- let relay_url = {
- use tauri::Manager;
- let state = app.state::();
- crate::relay::effective_agent_relay_url(
- &record.relay_url,
- &crate::relay::relay_ws_url_with_override(&state),
- )
- };
- let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &relay_url)?;
+ let key = bound_runtime_key(record, workspace_relay)?;
if let Some(runtime) = runtimes.get_mut(&key) {
if runtime
.child
diff --git a/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs b/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs
new file mode 100644
index 00000000000..fe302ffc67e
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs
@@ -0,0 +1,84 @@
+//! Production spawn-key derivation — split from `runtime.rs` (file-size
+//! guard). The regression tests live beside the function so they exercise
+//! the exact seam production spawn keys on.
+
+use crate::managed_agents::types::ManagedAgentRecord;
+use crate::managed_agents::ManagedAgentRuntimeKey;
+
+/// The one production derivation from a caller-bound workspace relay to the
+/// runtime-pair key `start_managed_agent_process` spawns and persists under.
+/// Extracted so the regression suite exercises the exact seam production
+/// uses: a mutation that keys the spawn to anything but the bound value now
+/// fails the tests below, instead of leaving them green while a painted
+/// guard watches the door.
+pub(crate) fn bound_runtime_key(
+ record: &ManagedAgentRecord,
+ workspace_relay: &crate::relay::ScopedWorkspaceRelay,
+) -> Result {
+ let relay_url =
+ crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay.as_str());
+ ManagedAgentRuntimeKey::new(record.pubkey.clone(), &relay_url)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::bound_runtime_key;
+ use crate::managed_agents::types::ManagedAgentRecord;
+
+ fn record(pubkey: &str, relay_url: &str) -> ManagedAgentRecord {
+ serde_json::from_value(serde_json::json!({
+ "pubkey": pubkey,
+ "name": "test",
+ "private_key_nsec": "nsec1fake",
+ "relay_url": relay_url,
+ "acp_command": "buzz-acp",
+ "agent_command": "buzz-agent",
+ "agent_args": [],
+ "mcp_command": "",
+ "turn_timeout_seconds": 320,
+ "created_at": "",
+ "updated_at": ""
+ }))
+ .expect("record fixture")
+ }
+
+ #[test]
+ fn production_spawn_key_derives_from_the_bound_relay_not_the_post_switch_workspace() {
+ // Round-8 regression: the previous test reconstructed the key
+ // derivation by hand, so hard-coding a wrong tenant inside production
+ // spawn stayed green. This calls `bound_runtime_key` — the exact
+ // function `start_managed_agent_process` keys its spawn, receipt, and
+ // runtimes-map insert on — so that mutation now fails here.
+ let record = record(&"aa".repeat(32), ""); // never-pinned record
+ let mut workspace = "wss://tenant-a.example".to_string();
+ let bound = crate::relay::bind_expected_relay_scope(
+ Some("wss://tenant-a.example"),
+ workspace.clone(),
+ )
+ .expect("scope matches at bind time");
+ workspace = "wss://tenant-b.example".to_string(); // the switch lands post-check
+
+ let key = bound_runtime_key(&record, &bound).expect("keyable record and relay");
+ assert_eq!(key.relay_url, "wss://tenant-a.example");
+ assert_eq!(key.pubkey, "aa".repeat(32));
+ assert_ne!(
+ key.relay_url, workspace,
+ "the production spawn key must be unrepresentable for the post-switch tenant"
+ );
+ }
+
+ #[test]
+ fn production_spawn_key_ignores_a_legacy_record_pin() {
+ // agents-everywhere (#2122): the stored per-record pin never
+ // contributes; the bound workspace relay is the only input. Pins the
+ // same contract at the production seam so a regression re-honoring
+ // the pin fails loudly.
+ let record = record(&"bb".repeat(32), "wss://stale-pin.example");
+ let bound =
+ crate::relay::bind_expected_relay_scope(None, "wss://tenant-a.example".to_string())
+ .expect("unscoped bind");
+
+ let key = bound_runtime_key(&record, &bound).expect("keyable record and relay");
+ assert_eq!(key.relay_url, "wss://tenant-a.example");
+ }
+}
diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs
index edb4fad422e..b54c0e7a050 100644
--- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs
@@ -1206,7 +1206,7 @@ fn receipt_invalid_when_process_not_running() {
);
}
-// ── Test helpers ────────────────────────────────────────────────────────────
+// ── Test helpers (spawn-key regressions: see `runtime/spawn_key.rs`) ───────
fn minimal_record(pubkey: &str) -> crate::managed_agents::ManagedAgentRecord {
serde_json::from_str(&format!(
diff --git a/desktop/src-tauri/src/native_relay_client.rs b/desktop/src-tauri/src/native_relay_client.rs
new file mode 100644
index 00000000000..2237076a926
--- /dev/null
+++ b/desktop/src-tauri/src/native_relay_client.rs
@@ -0,0 +1,962 @@
+//! Shared native relay session.
+//!
+//! Owns the authenticated relay socket for backend features that need live
+//! subscriptions (archive sync today; persona catalog and catch-up next). One
+//! session per (relay, pubkey) scope, multiplexing every subscription over a
+//! single socket — a second socket per feature would multiply relay connection
+//! slots and duplicate the NIP-42 handshake for no benefit.
+//!
+//! Built on `buzz-ws-client`, which owns the wire format and the NIP-42
+//! handshake. That crate is request/response shaped (one caller, `next_event`
+//! off a buffer); the session lifecycle lives here instead of being pushed down
+//! into it, because `buzz-cli` and `buzz-test-client` consume that crate and do
+//! not want subscription bookkeeping.
+//!
+//! # Caller contract
+//!
+//! A subscription id's filter is immutable for the life of a session: to change
+//! a filter, use a new id. See [`Subscription::id`] for why this cannot be
+//! relaxed from inside this module.
+
+use std::{
+ collections::{HashMap, HashSet},
+ sync::Arc,
+ time::Duration,
+};
+
+use buzz_ws_client_pkg::{NostrWsConnection, RelayMessage};
+use nostr::{Event, Keys};
+use tokio::{
+ sync::{mpsc, oneshot, Mutex},
+ time::Instant,
+};
+use tokio_util::sync::CancellationToken;
+
+/// Backoff floor for reconnect attempts.
+const RECONNECT_BASE_DELAY: Duration = Duration::from_millis(500);
+/// Backoff ceiling. Matches the renderer session's ceiling so a relay outage
+/// produces one retry cadence across the app rather than two competing ones.
+const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(30);
+/// How long a read may block before the loop re-checks cancellation. Not a
+/// connection timeout: an idle relay is normal, so a lapsed read just loops.
+const READ_TIMEOUT: Duration = Duration::from_secs(30);
+/// Backoff floor for reopening a subscription the relay CLOSED. Matches
+/// `RETRY_BASE_DELAY_MS` in `relayClosedRecovery.ts`.
+const CLOSED_RETRY_BASE_DELAY: Duration = Duration::from_secs(1);
+/// Backoff ceiling for reopening a CLOSED subscription. Matches
+/// `RETRY_MAX_DELAY_MS` in `relayClosedRecovery.ts`.
+const CLOSED_RETRY_MAX_DELAY: Duration = Duration::from_secs(30);
+/// Delay for a `rate-limited:` CLOSED that carries no `retry in Ns` hint.
+/// Matches `DEFAULT_RATE_LIMIT_SECONDS` on both sides of the client.
+const CLOSED_RATE_LIMIT_DEFAULT: Duration = Duration::from_secs(10);
+
+/// A live subscription request: a filter plus where its events go.
+#[derive(Clone)]
+pub(crate) struct Subscription {
+ /// Caller-stable key. Reused verbatim as the relay subscription id so a
+ /// resubscribe after reconnect replaces rather than duplicates.
+ ///
+ /// **An id's filter is immutable for the life of a session.** To change a
+ /// filter, use a new id — as `archive::sync` does by hashing scope and
+ /// kinds into the id. Reusing an id for a different filter is unsound and
+ /// cannot be made sound here: a CLOSED frame carries only the id, so a
+ /// rejection caused by the old filter is indistinguishable from one caused
+ /// by the new one, and would latch backoff (or a terminal stop) onto a
+ /// subscription that never failed.
+ pub(crate) id: String,
+ pub(crate) filter: serde_json::Value,
+}
+
+/// An event delivered to the session owner, tagged with the subscription that
+/// matched it. Callers demultiplex on `subscription_id`.
+#[derive(Clone)]
+pub(crate) struct MatchedEvent {
+ pub(crate) subscription_id: String,
+ pub(crate) event: Box,
+}
+
+/// App-wide owner of the one native socket for the active `(relay, pubkey)`
+/// scope. Features subscribe independently.
+///
+/// Only the archive lifecycle may replace the installed scope, and only while
+/// holding [`crate::archive::sync::ArchiveOwnership`]; see [`Self::session`]
+/// for why finite callers get a non-destructive lease instead.
+#[derive(Default)]
+pub(crate) struct NativeRelayClient {
+ current: Mutex>,
+}
+
+struct ManagedSession {
+ scope: (String, String),
+ session: Arc,
+}
+
+/// A session borrowed by a finite-request caller, plus whether that caller owns
+/// it. Dropping the lease shuts down a private session and leaves a shared one
+/// running for the feature that installed it.
+///
+/// Exists because a finite caller cannot be trusted to shut the session down by
+/// hand: it must not call [`RelaySession::shutdown`] on the shared session, and
+/// it must call it on a private one or the socket outlives the request. Tying
+/// both to the drop makes the correct behavior the only reachable one.
+pub(crate) struct SessionLease {
+ session: Arc,
+ /// Set only for a session this lease alone can see, which is therefore the
+ /// lease's to cancel.
+ private: bool,
+}
+
+impl std::ops::Deref for SessionLease {
+ type Target = RelaySession;
+
+ fn deref(&self) -> &Self::Target {
+ &self.session
+ }
+}
+
+impl SessionLease {
+ /// Clones the underlying handle for a task that outlives this binding, as
+ /// the catch-up fan-out does. Only the lease cancels the session, so the
+ /// clone must not outlive it.
+ pub(crate) fn handle(&self) -> Arc {
+ Arc::clone(&self.session)
+ }
+}
+
+impl Drop for SessionLease {
+ fn drop(&mut self) {
+ if self.private {
+ self.session.shutdown();
+ }
+ }
+}
+
+impl NativeRelayClient {
+ /// Installs the session for `scope`, shutting down whatever scope held the
+ /// slot. Destructive on entry, so every caller must already hold proof it
+ /// is the current owner — today that is
+ /// [`crate::archive::sync::ArchiveOwnership`].
+ async fn ensure_session(&self, relay_url: String, keys: Keys) -> Arc {
+ let scope = (relay_url.clone(), keys.public_key().to_hex());
+ let mut current = self.current.lock().await;
+ if let Some(managed) = current.as_ref().filter(|managed| managed.scope == scope) {
+ return Arc::clone(&managed.session);
+ }
+ if let Some(previous) = current.take() {
+ previous.session.shutdown();
+ }
+ let session = start_managed(relay_url, keys, None);
+ *current = Some(ManagedSession {
+ scope,
+ session: Arc::clone(&session),
+ });
+ session
+ }
+
+ /// Leases a session for a finite request, never displacing another scope.
+ ///
+ /// Finite callers (persona catalog, unread catch-up) hold no ownership
+ /// proof and cannot obtain one: they are not part of the archive lifecycle.
+ /// So this is the non-destructive half of the split — it shares the
+ /// installed session when the scope matches, and otherwise runs the request
+ /// on a private session that the lease shuts down on drop.
+ ///
+ /// A mismatch is deliberately NOT treated as "the caller is stale". These
+ /// commands are not ordered against archive lifecycle in either direction:
+ /// a catalog fetch for the community the user just opened routinely arrives
+ /// *before* that community's `start_archive_sync`, while the previous
+ /// scope's session is still installed. From inside this lock an early
+ /// caller and a late one are indistinguishable — both differ from the
+ /// installed scope — so refusing (or fencing on a generation counter, which
+ /// answers the same question) would fail the current caller as often as the
+ /// stale one. Serving both on their own socket is correct for either, and
+ /// whichever is genuinely stale has its result discarded by the scope
+ /// re-check each command performs before returning.
+ ///
+ /// Filling an empty slot is deliberate: at startup the catalog fetch
+ /// commonly precedes archive sync, and installing here means the archive
+ /// start that follows reuses this socket instead of opening a second one.
+ pub(crate) async fn session(&self, relay_url: String, keys: Keys) -> SessionLease {
+ let scope = (relay_url.clone(), keys.public_key().to_hex());
+ let mut current = self.current.lock().await;
+ if let Some(managed) = current.as_ref() {
+ return if managed.scope == scope {
+ SessionLease {
+ session: Arc::clone(&managed.session),
+ private: false,
+ }
+ } else {
+ SessionLease {
+ session: start_managed(relay_url, keys, None),
+ private: true,
+ }
+ };
+ }
+ let session = start_managed(relay_url, keys, None);
+ *current = Some(ManagedSession {
+ scope,
+ session: Arc::clone(&session),
+ });
+ SessionLease {
+ session,
+ private: false,
+ }
+ }
+
+ /// Returns the shared session for `(relay_url, keys)` plus the archive
+ /// event stream, replacing any session for a different scope.
+ ///
+ /// Requires proof of archive-sync ownership because both halves are
+ /// destructive on entry: `ensure_session` shuts down a different scope's
+ /// socket, and `attach_archive` replaces the session's archive sender, so a
+ /// superseded caller would steal the live stream from the current owner.
+ /// The token is un-constructible outside `archive::sync` and holds the
+ /// ownership locks for its lifetime, so a stale start cannot reach this
+ /// call. See [`crate::archive::sync::ArchiveOwnership`].
+ pub(crate) async fn archive_session(
+ &self,
+ relay_url: String,
+ keys: Keys,
+ _ownership: &crate::archive::sync::ArchiveOwnership<'_>,
+ ) -> (Arc, mpsc::Receiver) {
+ let session = self.ensure_session(relay_url, keys).await;
+ let event_rx = session.attach_archive().await;
+ (session, event_rx)
+ }
+}
+
+pub(crate) struct RelaySession {
+ state: Arc>,
+ requests: Arc>>,
+ /// The archive is the sole persistent-event consumer. Sending through its
+ /// bounded channel is awaited by the socket loop, preserving the
+ /// backpressure required by live-only (`limit: 0`) subscriptions: dropping
+ /// an event here cannot be repaired by replaying it later.
+ archive_events: Arc>>>,
+ wake: mpsc::Sender<()>,
+ cancel: CancellationToken,
+}
+
+struct PendingRequest {
+ events: Vec,
+ complete: oneshot::Sender, String>>,
+}
+
+/// Desired set plus the write-time record of what has left it.
+///
+/// One lock covers both because reconcile must read them together: snapshotting
+/// the desired set and draining `removed` in separate acquisitions lets a
+/// `set_subscriptions` land in the gap, so the drain would be consumed against
+/// a stale snapshot and could reopen a subscription the caller just dropped.
+#[derive(Default)]
+struct SessionState {
+ desired: Vec,
+ transient: Vec,
+ /// Ids whose exact subscription has left `desired` since the last
+ /// reconcile drained this. Written here rather than derived at reconcile
+ /// time because reconcile cannot derive it: wakes coalesce, so a remove
+ /// followed by a re-add is observed as a single pass whose desired set
+ /// never lost the id. See the eviction table on `retries`.
+ removed: HashSet,
+}
+
+impl SessionState {
+ /// Installs a new desired set, recording every departure.
+ ///
+ /// Returns the ids whose filter changed under a reused id — a violation of
+ /// the immutable-filter-per-id contract on [`Subscription::id`]. This is
+ /// the only place that can detect one: the write side alone holds the old
+ /// and new filter for an id. Behavior after a violation is deliberately
+ /// unspecified; detection is all this offers.
+ fn replace_desired(&mut self, subscriptions: Vec) -> Vec {
+ let mut violations = Vec::new();
+ for previous in std::mem::replace(&mut self.desired, subscriptions) {
+ // Departure is keyed on the exact subscription, not the id alone:
+ // the relay replaces by id, so a changed filter retires the old
+ // subscription just as surely as dropping the id would, and its
+ // backoff must not be inherited.
+ let survivor = self.desired.iter().find(|next| next.id == previous.id);
+ if survivor.is_some_and(|next| next.filter == previous.filter) {
+ continue;
+ }
+ if survivor.is_some() {
+ violations.push(previous.id.clone());
+ }
+ self.removed.insert(previous.id);
+ }
+ violations
+ }
+}
+
+impl RelaySession {
+ async fn attach_archive(&self) -> mpsc::Receiver {
+ let (events, receiver) = mpsc::channel(256);
+ *self.archive_events.lock().await = Some(events);
+ receiver
+ }
+
+ /// Fetches one finite page over this session without disturbing persistent
+ /// feature subscriptions. Request ids are fresh, so CLOSED/backoff history
+ /// can never leak between pages or into a long-lived subscription.
+ pub(crate) async fn fetch_events(
+ &self,
+ filter: serde_json::Value,
+ timeout: Duration,
+ ) -> Result, String> {
+ let id = format!("native-fetch-{}", uuid::Uuid::new_v4());
+ let (complete, result) = oneshot::channel();
+ self.requests.lock().await.insert(
+ id.clone(),
+ PendingRequest {
+ events: Vec::new(),
+ complete,
+ },
+ );
+ {
+ let mut state = self.state.lock().await;
+ state.transient.push(Subscription {
+ id: id.clone(),
+ filter,
+ });
+ }
+ let _ = self.wake.try_send(());
+
+ let outcome = tokio::select! {
+ _ = self.cancel.cancelled() => Err("relay session cancelled".to_string()),
+ value = tokio::time::timeout(timeout, result) => match value {
+ Ok(Ok(value)) => value,
+ Ok(Err(_)) => Err("relay request ended before EOSE".to_string()),
+ Err(_) => Err("relay request timed out".to_string()),
+ }
+ };
+ self.finish_request(&id).await;
+ outcome
+ }
+
+ async fn finish_request(&self, id: &str) {
+ self.requests.lock().await.remove(id);
+ let mut state = self.state.lock().await;
+ state.transient.retain(|subscription| subscription.id != id);
+ state.removed.insert(id.to_string());
+ drop(state);
+ let _ = self.wake.try_send(());
+ }
+
+ /// Replaces the desired subscription set and wakes the loop to reconcile.
+ ///
+ /// Reconciliation is declarative rather than incremental: callers state
+ /// what they want and the loop diffs. An incremental add/remove API would
+ /// have to be replayed in order across a reconnect, which is exactly the
+ /// bug class this avoids.
+ ///
+ /// It is also why `open` needs no revision/generation guard. Every
+ /// reconcile re-reads the current desired set, so a change that lands
+ /// mid-pass is picked up by the wake it queued rather than having to
+ /// invalidate work already in flight.
+ ///
+ /// That argument holds only for state that is a function of the final
+ /// desired set. It does not hold for `retries`, whose validity depends on
+ /// the id having been *continuously* desired — history that coalescing
+ /// erases. So departures are recorded here, at the only point that can see
+ /// them.
+ pub(crate) async fn set_subscriptions(&self, subscriptions: Vec) {
+ let violations = self.state.lock().await.replace_desired(subscriptions);
+ for id in violations {
+ eprintln!(
+ "buzz-desktop: native_relay_client: subscription {id} changed filter under a \
+ reused id; ids must be derived from their filter"
+ );
+ }
+ // A full channel already means "reconcile pending", so a failed send
+ // is success: the loop has not yet consumed the previous wake.
+ let _ = self.wake.try_send(());
+ }
+
+ pub(crate) fn shutdown(&self) {
+ self.cancel.cancel();
+ }
+}
+
+/// Starts a session against `relay_url` authenticated as `keys`.
+///
+/// Returns the handle plus the receiver for matched events. The session
+/// reconnects on drop with exponential backoff and resubscribes the current
+/// desired set — never a snapshot captured at connect time, so a subscription
+/// change during an outage is honored by the reconnect that follows.
+#[cfg(test)]
+pub(crate) async fn start(
+ relay_url: String,
+ keys: Keys,
+ auth_tag: Option,
+) -> (Arc, mpsc::Receiver) {
+ let session = start_managed(relay_url, keys, auth_tag);
+ let events = session.attach_archive().await;
+ (session, events)
+}
+
+fn start_managed(relay_url: String, keys: Keys, auth_tag: Option) -> Arc {
+ let (wake, wake_rx) = mpsc::channel(1);
+ let session = Arc::new(RelaySession {
+ state: Arc::new(Mutex::new(SessionState::default())),
+ requests: Arc::new(Mutex::new(HashMap::new())),
+ archive_events: Arc::new(Mutex::new(None)),
+ wake,
+ cancel: CancellationToken::new(),
+ });
+
+ tauri::async_runtime::spawn(run_session(
+ relay_url,
+ keys,
+ auth_tag,
+ Arc::clone(&session),
+ wake_rx,
+ ));
+
+ session
+}
+
+async fn run_session(
+ relay_url: String,
+ keys: Keys,
+ auth_tag: Option,
+ session: Arc,
+ mut wake_rx: mpsc::Receiver<()>,
+) {
+ let mut delay = RECONNECT_BASE_DELAY;
+ loop {
+ if session.cancel.is_cancelled() {
+ return;
+ }
+
+ match NostrWsConnection::connect_authenticated(&relay_url, &keys, auth_tag.as_ref()).await {
+ Ok(conn) => {
+ // A connection that authenticated is healthy regardless of how
+ // long it then lived, so backoff resets here rather than on
+ // clean exit — a socket that drops after one event must not
+ // inherit the previous failure's delay.
+ delay = RECONNECT_BASE_DELAY;
+ run_connection(conn, &session, &mut wake_rx).await;
+ }
+ Err(error) => {
+ eprintln!("buzz-desktop: native_relay_client: connect failed: {error}");
+ }
+ }
+
+ if session.cancel.is_cancelled() {
+ return;
+ }
+ tokio::select! {
+ _ = session.cancel.cancelled() => return,
+ _ = tokio::time::sleep(delay) => {}
+ }
+ delay = (delay * 2).min(RECONNECT_MAX_DELAY);
+ }
+}
+
+/// Drives one connected socket until it drops or the session is cancelled.
+async fn run_connection(
+ mut conn: NostrWsConnection,
+ session: &RelaySession,
+ wake_rx: &mut mpsc::Receiver<()>,
+) {
+ // Subscription ids currently open ON THIS SOCKET. Deliberately local: a new
+ // socket has none, so reconnect resubscribes the full desired set without
+ // any explicit "resubscribe" path that could drift from the normal one.
+ let mut open: HashMap = HashMap::new();
+ // Reopen schedule for ids the relay CLOSED, keyed the same way and equally
+ // local — for the same reason and one more. Backoff state cannot live in
+ // `desired`: that set is reloaded from SQLite by the archive task, so a
+ // subscription deleted there is re-added by the next reload. The JS port
+ // could delete from its subscription map because that map WAS the desired
+ // set; here the two are separate, and only this one is per-socket.
+ //
+ // An entry is valid only while its id has been continuously desired since
+ // the CLOSED that created it, which makes eviction the whole design:
+ //
+ // | Eviction trigger | Where | Why it is the right edge |
+ // |---|---|---|
+ // | event delivered | the EVENT arm below | the subscription is demonstrably healthy |
+ // | EOSE | the EOSE arm below | the relay served it, so the cause has cleared |
+ // | id leaves the desired set, including intermediate states the loop never observes | `SessionState::removed`, drained at the top of `reconcile` | validity depends on history, and coalesced wakes erase it — see `set_subscriptions` |
+ // | socket drops | this map is per-connection | relay policy and our own auth can change across a reconnect |
+ //
+ // Reconcile deliberately does NOT also prune ids merely absent from the
+ // desired snapshot. That clause is unreachable: entries are minted only for
+ // ids present in `open` (the CLOSED arm's guard below), ids enter `open`
+ // only from a desired snapshot, and every departure from desired is
+ // recorded at write time. It would kill no mutant these tests do not
+ // already kill, while masking the drain that does the work.
+ let mut retries: HashMap = HashMap::new();
+
+ if !reconcile(&mut conn, session, &mut open, &mut retries).await {
+ return;
+ }
+
+ loop {
+ // Earliest pending reopen, or `None` when nothing is scheduled. The arm
+ // below is disabled in that case rather than sleeping on a far-future
+ // instant, so an idle connection never wakes on this branch.
+ let retry_at = retries.values().filter_map(|retry| retry.due_at).min();
+
+ tokio::select! {
+ _ = session.cancel.cancelled() => {
+ let _ = conn.disconnect().await;
+ return;
+ }
+ Some(()) = wake_rx.recv() => {
+ if !reconcile(&mut conn, session, &mut open, &mut retries).await {
+ return;
+ }
+ }
+ // The edge that makes a CLOSED recoverable. Without it, nothing
+ // re-enters `reconcile` unless the desired set changes again, and
+ // for a stable set that means the subscription is dead for the life
+ // of the socket.
+ _ = tokio::time::sleep_until(retry_at.unwrap_or_else(Instant::now)),
+ if retry_at.is_some() =>
+ {
+ for retry in retries.values_mut() {
+ if retry.due_at.is_some_and(|due| due <= Instant::now()) {
+ retry.due_at = None;
+ }
+ }
+ if !reconcile(&mut conn, session, &mut open, &mut retries).await {
+ return;
+ }
+ }
+ message = conn.next_event(READ_TIMEOUT) => {
+ match message {
+ Ok(RelayMessage::Event { subscription_id, event }) => {
+ // Only forward events for a subscription we still want.
+ // A CLOSE races in flight with events already queued at
+ // the relay, so this is the last line of defense
+ // against delivering out-of-scope events after a change.
+ //
+ // This arm drops rather than heals: an event for an id
+ // we do not have open is generation-ambiguous — it may
+ // predate a deletion — so it cannot serve as the fence
+ // an EOSE does. The EOSE arm below is where an
+ // open-map mismatch is repaired.
+ if !open.contains_key(&subscription_id) {
+ continue;
+ }
+ let pending = session
+ .requests
+ .lock()
+ .await
+ .contains_key(&subscription_id);
+ if pending {
+ // Reject forged finite-request events before
+ // retaining them, bounding memory at the transport
+ // seam. The catalog re-verifies defensively before
+ // head selection.
+ if event.verify().is_err() {
+ continue;
+ }
+ if let Some(request) = session
+ .requests
+ .lock()
+ .await
+ .get_mut(&subscription_id)
+ {
+ request.events.push(*event);
+ }
+ continue;
+ }
+ // Delivery proves the subscription is healthy, so any
+ // accumulated backoff for it is stale. Mirrors the JS
+ // port's per-event `closedRetryAttempt = 0`.
+ retries.remove(&subscription_id);
+ // Persistent archive subscriptions are live-only, so
+ // losing an event cannot be repaired with a later REQ.
+ // Await the bounded archive channel to push back on the
+ // socket read loop instead. Finite catalog requests are
+ // fulfilled above and never enter this channel.
+ // Because this await is outside the session-cancel select,
+ // teardown depends on `run_sync` dropping its receiver; moving
+ // ownership or spawning that teardown can strand the socket loop.
+ let sender = session.archive_events.lock().await.clone();
+ if let Some(sender) = sender {
+ let _ = sender
+ .send(MatchedEvent {
+ subscription_id,
+ event,
+ })
+ .await;
+ }
+ }
+ Ok(RelayMessage::Closed { subscription_id, message }) => {
+ // The relay dropped it; forget it so a reopen re-sends
+ // REQ rather than assuming it is still live.
+ //
+ // A CLOSED for a subscription this socket is not
+ // running is stale — our own CLOSE raced it, exactly as
+ // the EVENT arm above guards. Minting retry state from
+ // it would resurrect the entry the drain just pruned,
+ // and nothing would evict it: the id is gone from
+ // `desired`, so no future removal can record it again.
+ if open.remove(&subscription_id).is_none() {
+ continue;
+ }
+ if let Some(request) = session.requests.lock().await.remove(&subscription_id) {
+ let _ = request.complete.send(Err(format!("relay closed request: {message}")));
+ let mut state = session.state.lock().await;
+ state.transient.retain(|subscription| subscription.id != subscription_id);
+ state.removed.insert(subscription_id.clone());
+ drop(state);
+ let _ = session.wake.try_send(());
+ continue;
+ }
+ let retry = retries.entry(subscription_id.clone()).or_default();
+ retry.schedule(&message);
+ eprintln!(
+ "buzz-desktop: native_relay_client: relay closed {subscription_id}: {message}"
+ );
+ }
+ Ok(RelayMessage::Eose { subscription_id }) => {
+ // The relay served this subscription, so whatever
+ // caused an earlier CLOSED has cleared. Same reset the
+ // JS port performs in `handleSubscriptionEose`, and it
+ // is what keeps an intermittent relay from ratcheting
+ // its way to the 30s ceiling and staying there.
+ let was_open = open.contains_key(&subscription_id);
+ if let Some(request) = session.requests.lock().await.remove(&subscription_id) {
+ let _ = request.complete.send(Ok(request.events));
+ let mut state = session.state.lock().await;
+ state.transient.retain(|subscription| subscription.id != subscription_id);
+ state.removed.insert(subscription_id.clone());
+ drop(state);
+ let _ = session.wake.try_send(());
+ continue;
+ }
+ retries.remove(&subscription_id);
+ // The relay is running a subscription this socket does
+ // not think is open, so the two disagree. EOSE is the
+ // fence that makes this recoverable: frames on one
+ // socket are ordered, so a stale CLOSED from a previous
+ // generation of this id necessarily precedes the
+ // recreated generation's EOSE. Without this wake a
+ // terminal stale CLOSED is a blackhole — it clears
+ // `open`, sets no `due_at`, and so leaves no edge back
+ // into reconcile while the relay delivers events the
+ // EVENT arm silently drops.
+ //
+ // Deliberately not on the EVENT arm: an event for an
+ // absent id may belong to the old generation, so it is
+ // not a fence. Converges rather than storms — the
+ // reconcile this triggers reopens the id, and the
+ // replacement EOSE then finds it open.
+ if !was_open {
+ let _ = session.wake.try_send(());
+ }
+ }
+ Ok(_) => {}
+ Err(error) => {
+ if !is_read_timeout(&error) {
+ eprintln!("buzz-desktop: native_relay_client: read failed: {error}");
+ return;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/// Brings the socket's open subscriptions in line with the desired set.
+///
+/// Returns false when the socket failed and the caller should reconnect.
+async fn reconcile(
+ conn: &mut NostrWsConnection,
+ session: &RelaySession,
+ open: &mut HashMap,
+ retries: &mut HashMap,
+) -> bool {
+ // Snapshot and drain in ONE acquisition. Taking them separately would let a
+ // `set_subscriptions` land in the gap, spending its removal against a
+ // desired set captured before it — reopening a subscription the caller had
+ // just dropped, with no record left to catch it on the next pass.
+ let (desired, removed) = {
+ let mut state = session.state.lock().await;
+ let removed = std::mem::take(&mut state.removed);
+ (
+ state
+ .desired
+ .iter()
+ .chain(&state.transient)
+ .cloned()
+ .collect::>(),
+ removed,
+ )
+ };
+
+ // Retry state is only valid while its id has been continuously desired
+ // since the CLOSED that created it. Every departure is here even when the
+ // id is desired again now, because the loop cannot see the gap: coalesced
+ // wakes make remove-then-re-add one pass whose desired set never lost it.
+ for id in removed {
+ retries.remove(&id);
+ }
+
+ for id in open.keys().cloned().collect::>() {
+ if desired.iter().any(|s| s.id == id) {
+ continue;
+ }
+ if conn
+ .send_raw(&serde_json::json!(["CLOSE", id]))
+ .await
+ .is_err()
+ {
+ return false;
+ }
+ open.remove(&id);
+ }
+
+ for sub in desired {
+ // A filter change under the same id must reopen, not be skipped: the
+ // relay replaces a subscription by id, so re-sending REQ is the update.
+ if open.get(&sub.id) == Some(&sub.filter) {
+ continue;
+ }
+ // Held back by a CLOSED: either waiting out its backoff, or terminal
+ // and never to be retried on this socket. Both are `is_blocked`, which
+ // is what keeps a relay that rejects on policy from being re-asked at
+ // the speed of the event loop.
+ if retries.get(&sub.id).is_some_and(ClosedRetry::is_blocked) {
+ continue;
+ }
+ if conn
+ .send_raw(&serde_json::json!(["REQ", sub.id, sub.filter]))
+ .await
+ .is_err()
+ {
+ return false;
+ }
+ open.insert(sub.id, sub.filter);
+ }
+
+ true
+}
+
+/// Reopen schedule for one subscription the relay CLOSED.
+#[derive(Default)]
+struct ClosedRetry {
+ /// When the reopen is due. `None` means "not waiting": either the delay has
+ /// elapsed and reconcile may re-send, or `terminal` latched.
+ due_at: Option,
+ /// Consecutive CLOSEDs, driving the exponential delay. Reset by a delivered
+ /// event or EOSE, both of which drop the whole entry.
+ attempts: u32,
+ /// The relay rejected this filter for a reason retrying cannot change.
+ terminal: bool,
+}
+
+impl ClosedRetry {
+ /// True while reconcile must leave this subscription closed.
+ fn is_blocked(&self) -> bool {
+ self.terminal || self.due_at.is_some_and(|due| due > Instant::now())
+ }
+
+ /// Records a CLOSED and schedules the reopen its class calls for.
+ fn schedule(&mut self, message: &str) {
+ match classify_closed(message) {
+ // Auth, access, or filter errors will fail identically until
+ // something outside this socket changes, so stop asking. Scoped to
+ // this socket by construction: the state lives in `run_connection`,
+ // so a reconnect retries once through the normal path. That is
+ // deliberate — relay policy and our own auth can change across a
+ // reconnect, and one REQ per reconnect is bounded.
+ ClosedClass::Terminal => {
+ self.terminal = true;
+ self.due_at = None;
+ }
+ ClosedClass::RateLimited => {
+ // Arm the process-wide gate so the HTTP bridge backs off too,
+ // rather than keeping a second private notion of the same
+ // relay's back-pressure.
+ let hint = parse_retry_in_seconds(message);
+ crate::relay_admission::activate_rate_limit(hint);
+ let hinted = hint
+ .map(Duration::from_secs)
+ .unwrap_or(CLOSED_RATE_LIMIT_DEFAULT);
+ // The longer of the two: a short hint must not undercut a
+ // backoff already grown by repeated rejections.
+ self.due_at = Some(Instant::now() + self.backoff().max(hinted));
+ self.attempts = self.attempts.saturating_add(1);
+ }
+ ClosedClass::Retryable => {
+ self.due_at = Some(Instant::now() + self.backoff());
+ self.attempts = self.attempts.saturating_add(1);
+ }
+ }
+ }
+
+ /// Exponential delay for the current attempt, capped. The shift is bounded
+ /// before it is taken, so a long-lived rejection cannot overflow its way
+ /// back down to a short delay.
+ fn backoff(&self) -> Duration {
+ CLOSED_RETRY_BASE_DELAY
+ .saturating_mul(1_u32 << self.attempts.min(16))
+ .min(CLOSED_RETRY_MAX_DELAY)
+ }
+}
+
+/// How a CLOSED message should be handled.
+///
+/// Ported from `classifyRelayClosed` in `relayClosedPolicy.ts`; the prefixes are
+/// the relay's own machine-readable NIP-01 classes and must stay in step with
+/// that file.
+#[derive(Debug, PartialEq, Eq)]
+enum ClosedClass {
+ Retryable,
+ RateLimited,
+ Terminal,
+}
+
+fn classify_closed(message: &str) -> ClosedClass {
+ let normalized = message.trim().to_ascii_lowercase();
+ if normalized.starts_with("rate-limited:") {
+ return ClosedClass::RateLimited;
+ }
+ // `auth-required:` is deliberately absent, i.e. retryable: it occurs
+ // transiently when a REQ races the AUTH handshake after a reconnect, and
+ // the backoff reopen re-sends once authenticated. A session that is
+ // genuinely unauthenticated fails at `connect_authenticated` instead, so
+ // this cannot loop forever.
+ if [
+ "restricted:",
+ "blocked:",
+ "invalid:",
+ "pow:",
+ "duplicate:",
+ "unsupported:",
+ "error: mixed search",
+ "error: too many subscriptions",
+ ]
+ .iter()
+ .any(|prefix| normalized.starts_with(prefix))
+ {
+ return ClosedClass::Terminal;
+ }
+ ClosedClass::Retryable
+}
+
+/// Parses the relay's canonical `retry in Ns` hint. Same format the HTTP bridge
+/// parses in `relay::extract_retry_in_hint`.
+fn parse_retry_in_seconds(message: &str) -> Option {
+ let after = &message[message.find("retry in ")? + "retry in ".len()..];
+ after
+ .chars()
+ .take_while(char::is_ascii_digit)
+ .collect::()
+ .parse()
+ .ok()
+}
+
+/// A lapsed read is an idle relay, not a failure. Distinguished by variant
+/// rather than by message text so a reworded error cannot turn every idle
+/// period into a reconnect storm.
+fn is_read_timeout(error: &buzz_ws_client_pkg::WsClientError) -> bool {
+ matches!(error, buzz_ws_client_pkg::WsClientError::Timeout)
+}
+
+#[cfg(test)]
+#[path = "native_relay_client_tests.rs"]
+mod closed_recovery_tests;
+
+#[cfg(test)]
+mod relay_backed_tests {
+ use super::*;
+ use nostr::{EventBuilder, Tag};
+
+ /// Relay-backed proof that the session's wire shape is one a real relay
+ /// accepts and answers.
+ ///
+ /// Every other test in this commit drives `run_sync` through a fake
+ /// [`crate::archive::sync::ArchiveSyncIo`], which is the right default:
+ /// batching and demultiplexing are the logic worth pinning, and they must
+ /// not need a socket. But a fake cannot fail the one way this layer
+ /// actually can — by sending a REQ the relay rejects, or by filtering on a
+ /// tag key that matches nothing. The JS manager's filters were validated by
+ /// years of production traffic; this port's have been validated by my
+ /// reading of that code, which is exactly the claim a real relay can check
+ /// and I cannot.
+ ///
+ /// `#[ignore]`d because it needs a relay on `BUZZ_TEST_RELAY_URL`. Run:
+ ///
+ /// ```text
+ /// ./scripts/start-isolated-test-relay.sh # ws://localhost:3030
+ /// BUZZ_TEST_RELAY_URL=ws://localhost:3030 \
+ /// cargo test -p buzz-desktop -- --ignored archive_sync_session
+ /// ```
+ #[tokio::test]
+ #[ignore = "requires a local relay (set BUZZ_TEST_RELAY_URL)"]
+ async fn archive_sync_session_receives_live_events_from_a_real_relay() {
+ let Ok(relay_url) = std::env::var("BUZZ_TEST_RELAY_URL") else {
+ panic!("set BUZZ_TEST_RELAY_URL to a running relay");
+ };
+
+ let owner = Keys::generate();
+ let author = Keys::generate();
+ let owner_pk = owner.public_key();
+
+ // Kind 1 rather than the archive's own kind 24200. Publishing a real
+ // observer frame requires a registered agent-owner binding in the
+ // relay's database — a relay ACL concern that says nothing about this
+ // layer. What this test can prove, and what no fake can, is the wire
+ // shape: that the `#p` tag key and the `limit: 0` live tail produce a
+ // REQ a real relay accepts and answers. Scope demultiplexing on the
+ // archive side is covered in `archive/sync_tests.rs`.
+ let (session, mut events) = start(relay_url.clone(), owner.clone(), None).await;
+ session
+ .set_subscriptions(vec![Subscription {
+ id: "archive:owner_p:test".to_string(),
+ filter: serde_json::json!({
+ "kinds": [1],
+ "limit": 0,
+ "#p": [owner_pk.to_hex()],
+ }),
+ }])
+ .await;
+
+ // The subscription must be live at the relay before the event is
+ // published. A `limit: 0` filter is a live tail: it replays nothing,
+ // so anything published into a not-yet-open subscription is missed.
+ // That is the same ordering hazard the renderer start gate exists to
+ // prevent for the ephemeral archive kind.
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ let mut publisher = NostrWsConnection::connect_authenticated(&relay_url, &author, None)
+ .await
+ .expect("publisher connect");
+ let frame = EventBuilder::text_note("archive-sync-probe")
+ .tag(Tag::public_key(owner_pk))
+ .sign_with_keys(&author)
+ .expect("sign event");
+ let frame_id = frame.id.to_hex();
+ let ok = publisher.send_event(frame).await.expect("publish frame");
+ assert!(
+ ok.accepted,
+ "relay rejected the observer frame, so a delivery timeout below would \
+ blame the subscription for a publish failure: {}",
+ ok.message
+ );
+
+ let received = tokio::time::timeout(Duration::from_secs(10), events.recv())
+ .await
+ .expect("timed out waiting for the relay to deliver the frame")
+ .expect("session channel closed");
+
+ assert_eq!(
+ received.subscription_id, "archive:owner_p:test",
+ "delivered event must carry the subscription id the loop demultiplexes on"
+ );
+ assert_eq!(
+ received.event.id.to_hex(),
+ frame_id,
+ "must deliver the published frame"
+ );
+
+ session.shutdown();
+ }
+}
diff --git a/desktop/src-tauri/src/native_relay_client_tests.rs b/desktop/src-tauri/src/native_relay_client_tests.rs
new file mode 100644
index 00000000000..96ec39a4bf0
--- /dev/null
+++ b/desktop/src-tauri/src/native_relay_client_tests.rs
@@ -0,0 +1,896 @@
+//! Lifecycle tests for [`super`]'s CLOSED recovery and subscription bookkeeping.
+//!
+//! Split out of `native_relay_client.rs` to keep that file under the desktop
+//! file-size ratchet. Same `#[path]` sibling-module convention as
+//! `archive/sync.rs` and its `sync_tests.rs`.
+
+use super::*;
+use futures_util::{SinkExt, StreamExt};
+use nostr::EventBuilder;
+use tokio_tungstenite::tungstenite::protocol::Message;
+
+/// The subscription id every test below drives.
+const PROBE_ID: &str = "archive:probe";
+
+/// Minimal relay that completes the NIP-42 handshake, records every REQ,
+/// and sends a CLOSED only when the test asks it to.
+///
+/// A real socket rather than a fake `NostrWsConnection`, because the bug
+/// this covers lives in the lifecycle between frames — the loop's only
+/// reconcile triggers — and a fake that hands the loop a `Closed` value
+/// cannot show that a REQ went back out over the wire afterwards. Same
+/// `accept_async` stub shape as `native_websocket.rs`'s live-TCP tests.
+///
+/// CLOSED is test-driven rather than a scripted reply to the first REQ so
+/// the test can wait for the session to go quiet first. `set_subscriptions`
+/// queues a wake that may still be pending when an immediate CLOSED lands,
+/// and that wake reopens the subscription on its own — which made the first
+/// version of this test pass against the unfixed code.
+///
+/// `frames` reports REQ and CLOSE in wire order, not REQ alone: the
+/// lifecycle tests below assert that a CLOSE was sent before the REQ that
+/// follows it, which a REQ-only channel cannot express.
+async fn stub_relay() -> (String, mpsc::Receiver , mpsc::Sender) {
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("bind stub relay");
+ let address = listener.local_addr().expect("stub relay address");
+ let (req_tx, req_rx) = mpsc::channel(16);
+ let (closed_tx, mut closed_rx) = mpsc::channel::(4);
+
+ tokio::spawn(async move {
+ let (stream, _) = listener.accept().await.expect("accept");
+ let mut socket = tokio_tungstenite::accept_async(stream)
+ .await
+ .expect("websocket handshake");
+
+ socket
+ .send(Message::Text(r#"["AUTH","stub-challenge"]"#.into()))
+ .await
+ .expect("send challenge");
+
+ loop {
+ tokio::select! {
+ incoming = socket.next() => {
+ let Some(Ok(Message::Text(text))) = incoming else { return };
+ let Ok(frame) = serde_json::from_str::(&text) else {
+ continue;
+ };
+ match frame[0].as_str() {
+ Some("AUTH") => {
+ let id = frame[1]["id"].as_str().unwrap_or_default();
+ socket
+ .send(Message::Text(
+ serde_json::json!(["OK", id, true, ""]).to_string().into(),
+ ))
+ .await
+ .expect("send auth ok");
+ }
+ Some("REQ") => {
+ let id = frame[1].as_str().unwrap_or_default().to_string();
+ if req_tx.send(Frame::Req(id)).await.is_err() {
+ return;
+ }
+ }
+ Some("CLOSE") => {
+ let id = frame[1].as_str().unwrap_or_default().to_string();
+ if req_tx.send(Frame::Close(id)).await.is_err() {
+ return;
+ }
+ }
+ _ => {}
+ }
+ }
+ Some(command) = closed_rx.recv() => {
+ let frame = match command {
+ StubCommand::Closed(id, message) => {
+ serde_json::json!(["CLOSED", id, message])
+ }
+ StubCommand::Eose(id) => serde_json::json!(["EOSE", id]),
+ StubCommand::Event(id, event) => {
+ serde_json::json!(["EVENT", id, event])
+ }
+ };
+ socket
+ .send(Message::Text(frame.to_string().into()))
+ .await
+ .expect("send stub frame");
+ }
+ }
+ }
+ });
+
+ (format!("ws://{address}"), req_rx, closed_tx)
+}
+
+/// A client→relay frame the stub observed, in wire order.
+#[derive(Debug, PartialEq, Eq)]
+enum Frame {
+ Req(String),
+ Close(String),
+}
+
+/// A relay→client frame the test asks the stub to emit.
+enum StubCommand {
+ Closed(String, String),
+ Eose(String),
+ Event(String, serde_json::Value),
+}
+
+fn probe_subscription() -> Subscription {
+ Subscription {
+ id: PROBE_ID.to_string(),
+ filter: serde_json::json!({ "kinds": [1], "limit": 0 }),
+ }
+}
+
+async fn next_frame(frames: &mut mpsc::Receiver , label: &str) -> Frame {
+ tokio::time::timeout(Duration::from_secs(10), frames.recv())
+ .await
+ .unwrap_or_else(|_| panic!("timed out waiting for {label}"))
+ .unwrap_or_else(|| panic!("stub relay closed before {label}"))
+}
+
+/// Waits for the next REQ, tolerating the CLOSE frames a reconcile sends
+/// first. Asserting on `Frame::Req` directly would couple every test to
+/// whether a particular reconcile also had cleanup to do.
+async fn next_req(frames: &mut mpsc::Receiver , label: &str) -> String {
+ loop {
+ if let Frame::Req(id) = next_frame(frames, label).await {
+ return id;
+ }
+ }
+}
+
+/// Waits out the wake `set_subscriptions` queued, so a CLOSED sent after
+/// this cannot be reopened by anything but the CLOSED path itself.
+///
+/// A pending wake is harmless while the subscription is still open — that
+/// reconcile is a no-op — so draining it before the CLOSED is what makes
+/// the assertion below attributable.
+async fn settle() {
+ tokio::time::sleep(Duration::from_millis(500)).await;
+}
+
+/// C's acceptance edge: a finite request shares the authenticated real socket
+/// with a persistent subscription, completes on wire EOSE, and does not steal
+/// later persistent delivery. A fake connection cannot establish any of those
+/// transport/lifetime properties.
+#[tokio::test]
+async fn finite_fetch_multiplexes_with_persistent_delivery_on_a_real_websocket() {
+ let (relay_url, mut frames, commands) = stub_relay().await;
+ let (session, mut events) = start(relay_url, Keys::generate(), None).await;
+ session.set_subscriptions(vec![probe_subscription()]).await;
+ assert_eq!(next_req(&mut frames, "the persistent REQ").await, PROBE_ID);
+
+ let fetch = {
+ let session = Arc::clone(&session);
+ tokio::spawn(async move {
+ session
+ .fetch_events(
+ serde_json::json!({ "kinds": [buzz_core_pkg::kind::KIND_PERSONA], "limit": 500 }),
+ Duration::from_secs(10),
+ )
+ .await
+ })
+ };
+ let request_id = next_req(&mut frames, "the finite fetch REQ").await;
+ assert_ne!(request_id, PROBE_ID);
+
+ let relay_keys = Keys::generate();
+ let mut forged = EventBuilder::text_note("forged catalog page event")
+ .sign_with_keys(&relay_keys)
+ .unwrap();
+ forged.content = "tampered after signing".into();
+ commands
+ .send(StubCommand::Event(
+ request_id.clone(),
+ serde_json::to_value(forged).unwrap(),
+ ))
+ .await
+ .unwrap();
+ let fetched = EventBuilder::text_note("catalog page event")
+ .sign_with_keys(&relay_keys)
+ .unwrap();
+ commands
+ .send(StubCommand::Event(
+ request_id.clone(),
+ serde_json::to_value(&fetched).unwrap(),
+ ))
+ .await
+ .unwrap();
+ commands
+ .send(StubCommand::Eose(request_id.clone()))
+ .await
+ .unwrap();
+
+ assert_eq!(fetch.await.unwrap().unwrap(), vec![fetched]);
+ assert_eq!(
+ next_frame(&mut frames, "finite fetch CLOSE").await,
+ Frame::Close(request_id)
+ );
+
+ let persistent = EventBuilder::text_note("persistent event after fetch")
+ .sign_with_keys(&relay_keys)
+ .unwrap();
+ commands
+ .send(StubCommand::Event(
+ PROBE_ID.into(),
+ serde_json::to_value(&persistent).unwrap(),
+ ))
+ .await
+ .unwrap();
+ let delivered = tokio::time::timeout(Duration::from_secs(10), events.recv())
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(delivered.subscription_id, PROBE_ID);
+ assert_eq!(*delivered.event, persistent);
+ session.shutdown();
+}
+
+async fn run_persistent_burst(drain_concurrently: bool) {
+ const BURST: usize = 1_200;
+
+ let (relay_url, mut frames, commands) = stub_relay().await;
+ let (session, mut events) = start(relay_url, Keys::generate(), None).await;
+ session.set_subscriptions(vec![probe_subscription()]).await;
+ assert_eq!(next_req(&mut frames, "the burst REQ").await, PROBE_ID);
+
+ let relay_keys = Keys::generate();
+ let event = EventBuilder::text_note("persistent burst event")
+ .sign_with_keys(&relay_keys)
+ .unwrap();
+ let send_burst = tokio::spawn({
+ let commands = commands.clone();
+ let event = serde_json::to_value(&event).unwrap();
+ async move {
+ for _ in 0..BURST {
+ commands
+ .send(StubCommand::Event(PROBE_ID.into(), event.clone()))
+ .await
+ .unwrap();
+ }
+ }
+ });
+
+ if !drain_concurrently {
+ // Let the bounded archive channel fill before draining. The socket loop
+ // must wait here rather than evicting live-only events.
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ }
+ for _ in 0..BURST {
+ tokio::time::timeout(Duration::from_secs(60), events.recv())
+ .await
+ .expect("timed out draining persistent burst")
+ .expect("archive receiver closed during persistent burst");
+ }
+ send_burst.await.unwrap();
+
+ let after = EventBuilder::text_note("persistent event after burst")
+ .sign_with_keys(&relay_keys)
+ .unwrap();
+ commands
+ .send(StubCommand::Event(
+ PROBE_ID.into(),
+ serde_json::to_value(&after).unwrap(),
+ ))
+ .await
+ .unwrap();
+ let delivered = tokio::time::timeout(Duration::from_secs(60), events.recv())
+ .await
+ .expect("timed out after persistent burst")
+ .expect("archive receiver closed after persistent burst");
+ assert_eq!(*delivered.event, after);
+ session.shutdown();
+}
+
+/// Persistent archive subscriptions use `limit: 0`, so an event lost during a
+/// slow-consumer burst cannot be replayed. Both a fast control and a receiver
+/// that starts late must therefore get the whole burst and remain live after it.
+#[tokio::test]
+async fn persistent_delivery_applies_backpressure_without_losing_a_burst() {
+ run_persistent_burst(true).await;
+ run_persistent_burst(false).await;
+}
+
+/// The blocker: a CLOSED with the desired set never changing again must
+/// still reopen the subscription.
+///
+/// Before the fix the loop removed the id from `open` and waited on a wake
+/// that only `set_subscriptions` can produce, so a stable desired set left
+/// the subscription dead for the life of the socket — silent permanent
+/// loss for ephemeral kind 24200.
+#[tokio::test]
+async fn a_closed_subscription_reopens_without_a_desired_set_change() {
+ let (relay_url, mut frames, closed) = stub_relay().await;
+ let (session, _events) = start(relay_url, Keys::generate(), None).await;
+
+ session.set_subscriptions(vec![probe_subscription()]).await;
+ assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID);
+ settle().await;
+
+ // Retryable class, sent once: the reopen is answered normally, so a
+ // failure here means "never retried" rather than "retried into another
+ // rejection".
+ closed
+ .send(StubCommand::Closed(
+ PROBE_ID.into(),
+ "error: temporary".into(),
+ ))
+ .await
+ .expect("stub relay accepts the closed command");
+
+ // No `set_subscriptions` between the two REQs: the reopen must come
+ // from the CLOSED itself, which is exactly the edge that was missing.
+ assert_eq!(next_req(&mut frames, "the reopened REQ").await, PROBE_ID);
+
+ session.shutdown();
+}
+
+/// A relay that rejects on policy must not be re-asked in a tight loop.
+#[tokio::test]
+async fn a_terminal_closed_is_not_retried_on_the_same_socket() {
+ let (relay_url, mut frames, closed) = stub_relay().await;
+ let (session, _events) = start(relay_url, Keys::generate(), None).await;
+
+ session.set_subscriptions(vec![probe_subscription()]).await;
+ assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID);
+ settle().await;
+
+ closed
+ .send(StubCommand::Closed(
+ PROBE_ID.into(),
+ "restricted: not authorized".into(),
+ ))
+ .await
+ .expect("stub relay accepts the closed command");
+
+ // Long enough that a retryable class (1s base) would have reopened
+ // several times, so this asserts suppression rather than just slowness.
+ let retried = tokio::time::timeout(Duration::from_secs(5), frames.recv()).await;
+ assert!(
+ retried.is_err(),
+ "a terminal CLOSED must not be retried on this socket, got {retried:?}"
+ );
+
+ session.shutdown();
+}
+
+/// M18: a subscription deleted and recreated must get a fresh REQ, even
+/// though its terminal latch says never to retry.
+///
+/// The latch is scoped to the subscription that earned it. Recreating the
+/// id is a new subscription that happens to share a name — `archive::sync`
+/// derives the id from scope and kinds, so a delete/recreate of the same
+/// saved subscription produces a byte-identical id and would otherwise
+/// inherit a permanent suppression for the life of the socket.
+#[tokio::test]
+async fn a_recreated_subscription_does_not_inherit_a_terminal_latch() {
+ let (relay_url, mut frames, closed) = stub_relay().await;
+ let (session, _events) = start(relay_url, Keys::generate(), None).await;
+
+ session.set_subscriptions(vec![probe_subscription()]).await;
+ assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID);
+ settle().await;
+
+ closed
+ .send(StubCommand::Closed(
+ PROBE_ID.into(),
+ "restricted: not authorized".into(),
+ ))
+ .await
+ .expect("stub relay accepts the closed command");
+ settle().await;
+
+ // Delete, then recreate — each observed as its own reconcile.
+ session.set_subscriptions(vec![]).await;
+ settle().await;
+ session.set_subscriptions(vec![probe_subscription()]).await;
+
+ assert_eq!(
+ next_req(&mut frames, "the REQ for the recreated subscription").await,
+ PROBE_ID,
+ );
+
+ session.shutdown();
+}
+
+/// M19: the same schedule, with both writes landing before the loop
+/// consumes its single wake.
+///
+/// This is the mutant that discriminates the mechanism. The wake channel
+/// has capacity 1 and `set_subscriptions` only ever queues "reconcile
+/// pending", so the delete and the recreate collapse into ONE observed
+/// reconcile whose desired set already contains the id again. A prune that
+/// reads only the current desired set never sees the id absent and leaves
+/// the latch in place — passing the test above while failing this one.
+/// The departure is therefore recorded at write time, where it is visible.
+///
+/// No `settle()` between the two writes: that gap is the whole point, and
+/// adding one would silently convert this into a duplicate of M18.
+#[tokio::test]
+async fn a_recreated_subscription_is_not_suppressed_when_the_writes_coalesce() {
+ let (relay_url, mut frames, closed) = stub_relay().await;
+ let (session, _events) = start(relay_url, Keys::generate(), None).await;
+
+ session.set_subscriptions(vec![probe_subscription()]).await;
+ assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID);
+ settle().await;
+
+ closed
+ .send(StubCommand::Closed(
+ PROBE_ID.into(),
+ "restricted: not authorized".into(),
+ ))
+ .await
+ .expect("stub relay accepts the closed command");
+ settle().await;
+
+ session.set_subscriptions(vec![]).await;
+ session.set_subscriptions(vec![probe_subscription()]).await;
+
+ assert_eq!(
+ next_req(&mut frames, "the REQ for the recreated subscription").await,
+ PROBE_ID,
+ );
+
+ session.shutdown();
+}
+
+/// M20: pruning must be scoped to departures, not run every pass.
+///
+/// A reconcile triggered while the id is still desired must leave its
+/// pending backoff alone. Clearing wholesale would collapse the CLOSED
+/// backoff — every unrelated subscription change would re-ask a relay that
+/// just rejected us, at the speed of the event loop.
+#[tokio::test]
+async fn a_reconcile_preserves_the_backoff_of_a_still_desired_subscription() {
+ let (relay_url, mut frames, closed) = stub_relay().await;
+ let (session, _events) = start(relay_url, Keys::generate(), None).await;
+
+ session.set_subscriptions(vec![probe_subscription()]).await;
+ assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID);
+ settle().await;
+
+ // Rate-limited: a long, unambiguously pending backoff, so a reopen
+ // inside the window is the prune and not the timer.
+ closed
+ .send(StubCommand::Closed(
+ PROBE_ID.into(),
+ "rate-limited: slow down; retry in 30s".into(),
+ ))
+ .await
+ .expect("stub relay accepts the closed command");
+ settle().await;
+
+ // A change that adds an unrelated subscription. The probe never leaves
+ // the desired set, so its backoff must survive this reconcile.
+ session
+ .set_subscriptions(vec![
+ probe_subscription(),
+ Subscription {
+ id: "archive:other".to_string(),
+ filter: serde_json::json!({ "kinds": [7], "limit": 0 }),
+ },
+ ])
+ .await;
+
+ assert_eq!(
+ next_req(&mut frames, "the REQ for the newly added subscription").await,
+ "archive:other",
+ );
+ let reopened = tokio::time::timeout(Duration::from_secs(3), frames.recv()).await;
+ assert!(
+ reopened.is_err(),
+ "a still-desired subscription must keep its pending backoff across a \
+ reconcile, got {reopened:?}"
+ );
+
+ crate::relay_admission::reset_rate_limit_gate();
+ session.shutdown();
+}
+
+/// M21: a CLOSED that arrives after we stopped running the subscription is
+/// stale and must mint nothing.
+///
+/// Our CLOSE races the relay's in-flight frames — the EVENT arm already
+/// guards this. Without the same guard on CLOSED, the frame recreates the
+/// retry entry the drain just removed, and nothing can evict it: the id is
+/// gone from the desired set, so no future departure records it again.
+#[tokio::test]
+async fn a_closed_arriving_after_removal_does_not_mint_retry_state() {
+ let (relay_url, mut frames, closed) = stub_relay().await;
+ let (session, _events) = start(relay_url, Keys::generate(), None).await;
+
+ session.set_subscriptions(vec![probe_subscription()]).await;
+ assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID);
+ settle().await;
+
+ // Delete first, and wait for our CLOSE to reach the wire: that ordering
+ // is what makes the CLOSED below arrive after the drain rather than
+ // before it, which is the schedule M18 and M19 do not cover.
+ session.set_subscriptions(vec![]).await;
+ assert_eq!(
+ next_frame(&mut frames, "the CLOSE for the deleted subscription").await,
+ Frame::Close(PROBE_ID.to_string()),
+ );
+
+ closed
+ .send(StubCommand::Closed(
+ PROBE_ID.into(),
+ "restricted: not authorized".into(),
+ ))
+ .await
+ .expect("stub relay accepts the closed command");
+ settle().await;
+
+ session.set_subscriptions(vec![probe_subscription()]).await;
+
+ assert_eq!(
+ next_req(&mut frames, "the REQ for the recreated subscription").await,
+ PROBE_ID,
+ );
+
+ session.shutdown();
+}
+
+/// M22: a stale *terminal* CLOSED landing after the id was recreated must
+/// not blackhole the live subscription.
+///
+/// This one survives every defense above. The CLOSED is legitimately
+/// attributed — the id is open again, so the M21 guard passes it — and
+/// terminal means no `due_at`, so the timer arm is disabled and no wake is
+/// pending. `open` loses the id while the relay keeps delivering, and the
+/// EVENT arm drops every frame in silence.
+///
+/// EOSE is the recovery edge because it is the only ordered fence
+/// available: frames on one socket are totally ordered, so the previous
+/// generation's CLOSED necessarily precedes the new generation's EOSE.
+#[tokio::test]
+async fn a_stale_terminal_closed_does_not_blackhole_a_recreated_subscription() {
+ let (relay_url, mut frames, closed) = stub_relay().await;
+ let (session, mut events) = start(relay_url, Keys::generate(), None).await;
+
+ session.set_subscriptions(vec![probe_subscription()]).await;
+ assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID);
+ settle().await;
+
+ // Delete and recreate, so the id is open again under a new generation.
+ session.set_subscriptions(vec![]).await;
+ assert_eq!(
+ next_frame(&mut frames, "the CLOSE for the deleted subscription").await,
+ Frame::Close(PROBE_ID.to_string()),
+ );
+ session.set_subscriptions(vec![probe_subscription()]).await;
+ assert_eq!(
+ next_req(&mut frames, "the REQ for the recreated subscription").await,
+ PROBE_ID,
+ );
+ settle().await;
+
+ // The old generation's terminal CLOSED, delayed past the new REQ.
+ closed
+ .send(StubCommand::Closed(
+ PROBE_ID.into(),
+ "restricted: not authorized".into(),
+ ))
+ .await
+ .expect("stub relay accepts the closed command");
+ // The new generation's EOSE, which the wire orders after it.
+ closed
+ .send(StubCommand::Eose(PROBE_ID.into()))
+ .await
+ .expect("stub relay accepts the eose command");
+
+ // The EOSE found the id closed, so it must drive a reconcile that
+ // reopens it. Nothing else can: terminal schedules no timer, and the
+ // desired set is stable.
+ assert_eq!(
+ next_req(&mut frames, "the REQ healing the open-map mismatch").await,
+ PROBE_ID,
+ );
+
+ // And the heal converges rather than storming: the replacement EOSE
+ // finds the id open, so it wakes nothing.
+ closed
+ .send(StubCommand::Eose(PROBE_ID.into()))
+ .await
+ .expect("stub relay accepts the second eose command");
+ let extra = tokio::time::timeout(Duration::from_secs(3), frames.recv()).await;
+ assert!(
+ extra.is_err(),
+ "an EOSE for an already-open subscription must not re-reconcile, got {extra:?}"
+ );
+
+ // The point of the heal: events flow again.
+ let event = EventBuilder::text_note("post-heal")
+ .sign_with_keys(&Keys::generate())
+ .expect("sign event");
+ let event_id = event.id.to_hex();
+ closed
+ .send(StubCommand::Event(
+ PROBE_ID.into(),
+ serde_json::to_value(&event).expect("serialize event"),
+ ))
+ .await
+ .expect("stub relay accepts the event command");
+
+ let delivered = tokio::time::timeout(Duration::from_secs(10), events.recv())
+ .await
+ .expect("timed out waiting for an event after the heal")
+ .expect("session channel closed");
+ assert_eq!(
+ delivered.event.id.to_hex(),
+ event_id,
+ "events must flow again once the open map is healed"
+ );
+
+ session.shutdown();
+}
+
+/// M23: reusing an id for a changed filter must be *detected*.
+///
+/// This test pins detection and nothing else. Post-violation behavior —
+/// whether the subscription reopens, what happens to its retry state, what
+/// the relay is sent — is unspecified by design, because the wire carries
+/// only the id and an in-flight CLOSED from the old filter is
+/// indistinguishable from one caused by the new one. Asserting any of that
+/// would turn an unsupported input into a supported one.
+///
+/// It exists because the `(id, filter)` departure diff is otherwise
+/// unpinned: on every supported path it is byte-equivalent to an id-only
+/// diff, so a refactor could revert it, pass every other test here, and
+/// silently remove the one signal that tells C and D they broke the
+/// contract.
+#[test]
+fn a_filter_change_under_a_reused_id_is_reported_as_a_contract_violation() {
+ let mut state = SessionState::default();
+
+ assert!(
+ state.replace_desired(vec![probe_subscription()]).is_empty(),
+ "a first desired set violates nothing"
+ );
+ assert!(
+ state.replace_desired(vec![probe_subscription()]).is_empty(),
+ "an unchanged subscription is not a filter change"
+ );
+
+ let violations = state.replace_desired(vec![Subscription {
+ id: PROBE_ID.to_string(),
+ filter: serde_json::json!({ "kinds": [7], "limit": 0 }),
+ }]);
+
+ assert_eq!(
+ violations,
+ vec![PROBE_ID.to_string()],
+ "a filter changed under a reused id must be reported"
+ );
+}
+
+#[test]
+fn closed_messages_classify_like_the_renderer_policy() {
+ assert_eq!(
+ classify_closed("rate-limited: quota exceeded; retry in 4s"),
+ ClosedClass::RateLimited
+ );
+ assert_eq!(
+ classify_closed("restricted: not authorized"),
+ ClosedClass::Terminal
+ );
+ assert_eq!(
+ classify_closed("error: too many subscriptions"),
+ ClosedClass::Terminal
+ );
+ // Transient AUTH race, not a permanent rejection — the one prefix that
+ // looks terminal and deliberately is not.
+ assert_eq!(
+ classify_closed("auth-required: we can't serve unauthenticated"),
+ ClosedClass::Retryable
+ );
+ assert_eq!(classify_closed(""), ClosedClass::Retryable);
+ // Case and padding come from the relay, not from us.
+ assert_eq!(
+ classify_closed(" RESTRICTED: nope "),
+ ClosedClass::Terminal
+ );
+}
+
+#[test]
+fn retry_delay_grows_and_stops_at_the_ceiling() {
+ let mut retry = ClosedRetry::default();
+ assert_eq!(retry.backoff(), CLOSED_RETRY_BASE_DELAY);
+
+ retry.schedule("error: temporary");
+ assert_eq!(retry.backoff(), CLOSED_RETRY_BASE_DELAY * 2);
+
+ for _ in 0..40 {
+ retry.schedule("error: temporary");
+ }
+ assert_eq!(
+ retry.backoff(),
+ CLOSED_RETRY_MAX_DELAY,
+ "backoff must saturate at the ceiling rather than wrapping"
+ );
+}
+
+#[test]
+fn a_rate_limited_closed_waits_at_least_the_relay_hint() {
+ let mut retry = ClosedRetry::default();
+ retry.schedule("rate-limited: quota exceeded; retry in 12s");
+
+ let due = retry.due_at.expect("rate-limited must schedule a reopen");
+ // The hint dominates the 1s first backoff, so this asserts the hint was
+ // honored rather than that anything at all was scheduled.
+ assert!(
+ due >= Instant::now() + Duration::from_secs(11),
+ "a 12s hint must not be undercut by the base backoff"
+ );
+ crate::relay_admission::reset_rate_limit_gate();
+}
+
+#[test]
+fn a_hintless_rate_limited_closed_uses_the_shared_default() {
+ let mut retry = ClosedRetry::default();
+ retry.schedule("rate-limited: quota exceeded");
+
+ let due = retry.due_at.expect("rate-limited must schedule a reopen");
+ assert!(
+ due >= Instant::now() + CLOSED_RATE_LIMIT_DEFAULT - Duration::from_secs(1),
+ "a hintless rate-limit must fall back to the shared default window"
+ );
+ crate::relay_admission::reset_rate_limit_gate();
+}
+
+#[test]
+fn retry_hints_parse_the_relays_canonical_format() {
+ assert_eq!(
+ parse_retry_in_seconds("rate-limited: quota exceeded; retry in 4s"),
+ Some(4)
+ );
+ assert_eq!(parse_retry_in_seconds("rate-limited: quota exceeded"), None);
+ assert_eq!(parse_retry_in_seconds("retry in s"), None);
+}
+
+// ── Scope fencing at the client boundary ─────────────────────────────────────
+//
+// `ensure_session` is destructive on entry: a different scope's socket is shut
+// down before the new one is installed. The archive lifecycle earns that right
+// with `ArchiveOwnership`; the persona catalog and unread catch-up hold no such
+// proof and reach the client through `session` instead.
+//
+// These tests drive `ensure_session` directly rather than `archive_session`,
+// because `ArchiveOwnership` is un-constructible outside `archive::sync` — the
+// compiler already enforces that half. `archive_session` delegates to
+// `ensure_session` with no other effect on the slot, so this stages the exact
+// state a live archive leaves behind.
+//
+// The relay URLs never accept a connection. Nothing here waits on a socket:
+// the session task is spawned, its connect fails, and it backs off — while the
+// slot bookkeeping and cancellation these tests assert on are synchronous.
+
+/// A scope's relay URL. Distinct ports, on a closed loopback address, so the
+/// two scopes are unequal and neither can connect.
+fn scope_url(port: u16) -> String {
+ format!("ws://127.0.0.1:{port}")
+}
+
+async fn installed_session(client: &NativeRelayClient) -> Option> {
+ client
+ .current
+ .lock()
+ .await
+ .as_ref()
+ .map(|managed| Arc::clone(&managed.session))
+}
+
+/// The required regression: a finite request that resumes after the scope
+/// switched must not disturb the new scope's live session.
+///
+/// Staged in the order the bug needs — archive A installed, scope switches and
+/// archive B installs, and only then does A's delayed fetch acquire. Against
+/// the unfenced `session` (a straight `ensure_session` call) A's late arrival
+/// shut B's socket down and installed its own, leaving B's archive attached to
+/// a cancelled session: no events, no error, until the next lifecycle edge.
+#[tokio::test]
+async fn a_stale_finite_request_cannot_displace_the_new_scopes_session() {
+ let client = NativeRelayClient::default();
+ let scope_a = (scope_url(9), Keys::generate());
+ let scope_b = (scope_url(10), Keys::generate());
+
+ let archive_a = client
+ .ensure_session(scope_a.0.clone(), scope_a.1.clone())
+ .await;
+ let archive_b = client
+ .ensure_session(scope_b.0.clone(), scope_b.1.clone())
+ .await;
+ assert!(
+ archive_a.cancel.is_cancelled(),
+ "the archive lifecycle must still replace its own scope's session"
+ );
+
+ // Scope A's in-flight catalog/catch-up command, resuming late.
+ let stale = client.session(scope_a.0.clone(), scope_a.1.clone()).await;
+
+ assert!(
+ !archive_b.cancel.is_cancelled(),
+ "a stale finite request cancelled the live scope's session; its archive \
+ is now attached to a dead socket and will sit silent until the next \
+ lifecycle edge"
+ );
+ let installed = installed_session(&client)
+ .await
+ .expect("the slot must still hold a session");
+ assert!(
+ Arc::ptr_eq(&installed, &archive_b),
+ "a stale finite request replaced the installed session, so the next \
+ same-scope caller shares the wrong socket"
+ );
+ assert!(
+ !Arc::ptr_eq(&stale.session, &archive_b),
+ "the stale request must run on its own session, not the live scope's"
+ );
+
+ // Its own session is the lease's to end, and it must actually end: an
+ // un-cancelled private session leaks a reconnecting socket per request.
+ let private = stale.handle();
+ drop(stale);
+ assert!(
+ private.cancel.is_cancelled(),
+ "dropping a private lease must shut its session down"
+ );
+}
+
+/// The sharing half, and the mutant that matters: making every lease private
+/// would satisfy the test above while quietly undoing the one-socket design and
+/// letting a finite request's drop cancel the archive's session.
+#[tokio::test]
+async fn a_same_scope_lease_shares_the_installed_session_and_never_ends_it() {
+ let client = NativeRelayClient::default();
+ let (relay_url, keys) = (scope_url(11), Keys::generate());
+
+ let archive = client.ensure_session(relay_url.clone(), keys.clone()).await;
+ let lease = client.session(relay_url.clone(), keys.clone()).await;
+ assert!(
+ Arc::ptr_eq(&lease.session, &archive),
+ "a same-scope finite request must multiplex over the installed socket \
+ rather than opening a second one"
+ );
+
+ drop(lease);
+ assert!(
+ !archive.cancel.is_cancelled(),
+ "dropping a shared lease cancelled the archive's session"
+ );
+ assert!(
+ installed_session(&client)
+ .await
+ .is_some_and(|installed| Arc::ptr_eq(&installed, &archive)),
+ "the shared session must stay installed after a lease is dropped"
+ );
+}
+
+/// A lease taken before any archive start installs, so the archive start that
+/// follows reuses that socket instead of opening a second one. This is the
+/// common boot order: the catalog fetch runs before archive sync.
+#[tokio::test]
+async fn the_first_lease_installs_a_session_the_archive_then_reuses() {
+ let client = NativeRelayClient::default();
+ let (relay_url, keys) = (scope_url(12), Keys::generate());
+
+ let lease = client.session(relay_url.clone(), keys.clone()).await;
+ let leased = lease.handle();
+ drop(lease);
+ assert!(
+ !leased.cancel.is_cancelled(),
+ "the first lease owns the slot, so dropping it must not cancel the \
+ session the archive is about to reuse"
+ );
+
+ let archive = client.ensure_session(relay_url, keys).await;
+ assert!(
+ Arc::ptr_eq(&archive, &leased),
+ "the archive start must reuse the installed session rather than \
+ replacing an identically scoped one"
+ );
+}
diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs
index 5c1a3f78f13..50d4557259d 100644
--- a/desktop/src-tauri/src/native_websocket.rs
+++ b/desktop/src-tauri/src/native_websocket.rs
@@ -2,7 +2,13 @@ use std::{collections::HashMap, sync::Arc, time::Duration};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
-use tauri::{ipc::Channel, plugin::TauriPlugin, Manager, Runtime};
+use tauri::{
+ ipc::{Channel, InvokeResponseBody},
+ plugin::TauriPlugin,
+ Manager, Runtime,
+};
+
+use crate::native_websocket_batch::{is_auth_challenge, FrameBatch, BATCH_MAX_SERIALIZED_BYTES};
use tokio::sync::{mpsc, oneshot, Mutex};
use tokio_tungstenite::{
connect_async,
@@ -124,7 +130,7 @@ impl WebSocketManager {
async fn open_connection(
manager: &WebSocketManager,
url: &str,
- on_message: Channel,
+ on_message: Channel,
) -> Result {
// FORK-LOCAL PATCH (adrienlacombe/buzz): this fork ships a single-relay
// client. Every relay session — community add, stored communities, deep
@@ -183,7 +189,7 @@ async fn open_connection(
async fn connect(
manager: tauri::State<'_, WebSocketManager>,
url: String,
- on_message: Channel,
+ on_message: Channel,
_config: Option,
) -> Result {
open_connection(manager.inner(), &url, on_message).await
@@ -268,11 +274,12 @@ async fn run_connection(
mut socket: tokio_tungstenite::WebSocketStream,
mut receiver: mpsc::Receiver,
cancel: CancellationToken,
- on_message: Channel,
+ on_message: Channel,
manager: WebSocketManager,
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
+ let mut batch = FrameBatch::default();
loop {
tokio::select! {
_ = cancel.cancelled() => {
@@ -285,6 +292,7 @@ async fn run_connection(
).await;
break;
}
+ _ = batch.due() => batch.flush(&on_message),
request = receiver.recv() => {
let Some(request) = request else { break };
let result = tokio::time::timeout(WRITE_TIMEOUT, socket.send(request.message))
@@ -302,13 +310,35 @@ async fn run_connection(
None => OutboundMessage::Close(None),
};
let terminal = matches!(message, OutboundMessage::Close(_) | OutboundMessage::Error(_));
- if let Ok(value) = serde_json::to_value(message) {
- let _ = on_message.send(value);
+ // Classify the relay payload before it is wrapped, while its
+ // structure is still readable.
+ let urgent = match &message {
+ OutboundMessage::Text(payload) => is_auth_challenge(payload),
+ _ => false,
+ };
+ let Ok(frame) = serde_json::to_string(&message) else { continue };
+
+ // Flush before appending when the frame would carry the batch
+ // over the direct-eval ceiling, so the oversized frame starts a
+ // batch of its own rather than pushing its predecessors onto the
+ // fetch path. A frame that exceeds the bound alone is delivered
+ // alone, exactly as it is today.
+ if batch.projected_len(&frame) > BATCH_MAX_SERIALIZED_BYTES {
+ batch.flush(&on_message);
+ }
+ batch.push(frame);
+ // Ordering is FIFO in all cases: buffered frames are flushed
+ // together with the frame that forced the flush, never after it.
+ if terminal || urgent {
+ batch.flush(&on_message);
}
if terminal { break; }
}
}
}
+ // A terminal frame already flushed; this covers cancellation and send
+ // failure, which must not strand frames the relay already delivered.
+ batch.flush(&on_message);
manager.remove(id).await;
}
@@ -345,17 +375,241 @@ pub fn init() -> TauriPlugin {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::native_websocket_batch::BATCH_WINDOW;
use futures_util::FutureExt;
use std::sync::atomic::{AtomicBool, Ordering};
- use tauri::ipc::InvokeResponseBody;
use tokio::io::duplex;
use tokio_tungstenite::{tungstenite::protocol::Role, WebSocketStream};
- fn silent_channel() -> Channel {
+ fn silent_channel() -> Channel {
Channel::new(|_: InvokeResponseBody| Ok(()))
}
+ /// Records each delivery as its raw JSON payload, so tests assert on what
+ /// the renderer actually receives rather than on internal batch state.
+ fn recording_channel() -> (
+ Channel,
+ Arc>>,
+ ) {
+ // A std mutex: `Channel::send` is synchronous and runs on whatever
+ // thread flushed, including inside the async runtime.
+ let deliveries = Arc::new(std::sync::Mutex::new(Vec::new()));
+ let sink = deliveries.clone();
+ let channel = Channel::new(move |body: InvokeResponseBody| {
+ let payload = match body {
+ InvokeResponseBody::Json(json) => json,
+ InvokeResponseBody::Raw(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
+ };
+ sink.lock().unwrap().push(payload);
+ Ok(())
+ });
+ (channel, deliveries)
+ }
+
+ /// Drives the real `run_connection` loop over a live in-memory socket, so
+ /// flush policy is exercised as the loop applies it. Asserting against
+ /// `FrameBatch` alone cannot see the loop's decisions and lets a broken
+ /// policy pass.
+ struct LoopHarness {
+ server: WebSocketStream,
+ deliveries: Arc>>,
+ cancel: CancellationToken,
+ _sender: mpsc::Sender,
+ }
+
+ impl LoopHarness {
+ async fn start() -> Self {
+ let manager = WebSocketManager::default();
+ let (client_io, server_io) = duplex(256 * 1024);
+ let (client, server) = tokio::join!(
+ WebSocketStream::from_raw_socket(client_io, Role::Client, None),
+ WebSocketStream::from_raw_socket(server_io, Role::Server, None),
+ );
+ let (channel, deliveries) = recording_channel();
+ // The sender is held by the harness: dropping it would end the
+ // loop before the test could drive it.
+ let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY);
+ let cancel = CancellationToken::new();
+ // `tokio::spawn`, not `tauri::async_runtime::spawn`: the latter
+ // runs the task on Tauri's own runtime, where this test's paused
+ // clock does not apply and `advance` would silently do nothing.
+ tokio::spawn(run_connection(
+ 1,
+ client,
+ receiver,
+ cancel.clone(),
+ channel,
+ manager,
+ ));
+ Self {
+ server,
+ deliveries,
+ cancel,
+ _sender: sender,
+ }
+ }
+
+ async fn relay_says(&mut self, payload: &str) {
+ self.server
+ .send(Message::Text(payload.into()))
+ .await
+ .unwrap();
+ }
+
+ /// Lets the connection task run without letting the batch timer
+ /// elapse, so what arrives here arrived because policy forced it out.
+ async fn settle(&self) {
+ for _ in 0..64 {
+ tokio::task::yield_now().await;
+ }
+ }
+
+ fn deliveries(&self) -> Vec {
+ self.deliveries.lock().unwrap().clone()
+ }
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn auth_challenge_does_not_wait_for_the_batch_timer() {
+ let mut harness = LoopHarness::start().await;
+
+ // Control: an ordinary frame stays buffered, proving the window is
+ // genuinely holding frames back rather than the clock running out.
+ harness.relay_says(r#"["EOSE","sub"]"#).await;
+ harness.settle().await;
+ assert!(
+ harness.deliveries().is_empty(),
+ "EOSE must ride the batch window"
+ );
+
+ harness.relay_says(r#"["AUTH","challenge"]"#).await;
+ harness.settle().await;
+
+ let deliveries = harness.deliveries();
+ assert_eq!(deliveries.len(), 1, "AUTH must not wait for the timer");
+ let frames: Vec = serde_json::from_str(&deliveries[0]).unwrap();
+ assert_eq!(frames.len(), 2, "the buffered EOSE rides out with AUTH");
+ assert_eq!(frames[0]["data"], r#"["EOSE","sub"]"#, "FIFO preserved");
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn batch_window_eventually_delivers_unforced_frames() {
+ let mut harness = LoopHarness::start().await;
+ harness.relay_says(r#"["EOSE","sub"]"#).await;
+ harness.settle().await;
+ assert!(harness.deliveries().is_empty());
+
+ // Same frame, once the window elapses: the control above is waiting on
+ // the timer, not stuck.
+ tokio::time::advance(BATCH_WINDOW * 2).await;
+ harness.settle().await;
+ assert_eq!(harness.deliveries().len(), 1);
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn cancellation_delivers_frames_the_relay_already_sent() {
+ let mut harness = LoopHarness::start().await;
+ harness.relay_says(r#"["EVENT","sub",{}]"#).await;
+ harness.settle().await;
+ assert!(harness.deliveries().is_empty(), "frame is buffered");
+
+ // Teardown must not strand a frame that never reached the renderer.
+ harness.cancel.cancel();
+ harness.settle().await;
+
+ let seen = harness.deliveries().join("");
+ assert!(
+ seen.contains("EVENT"),
+ "buffered frame lost on cancel: {seen}"
+ );
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn oversize_frame_does_not_drag_buffered_frames_over_the_threshold() {
+ let mut harness = LoopHarness::start().await;
+ harness.relay_says(r#"["EOSE","sub"]"#).await;
+ harness.settle().await;
+
+ let big = format!(
+ r#"["EVENT","sub","{}"]"#,
+ "x".repeat(BATCH_MAX_SERIALIZED_BYTES)
+ );
+ harness.relay_says(&big).await;
+ harness.settle().await;
+ // The small frame is forced out by the straddle; the oversize frame
+ // itself still rides the window.
+ assert_eq!(
+ harness.deliveries().len(),
+ 1,
+ "straddle flushes immediately"
+ );
+ tokio::time::advance(BATCH_WINDOW * 2).await;
+ harness.settle().await;
+
+ // The small frame must ship on its own rather than riding a delivery
+ // that crosses tauri's direct-eval threshold.
+ let deliveries = harness.deliveries();
+ assert_eq!(
+ deliveries.len(),
+ 2,
+ "straddling frames must not share a batch"
+ );
+ assert!(
+ deliveries[0].len() < 8192,
+ "first delivery {} crossed the direct-eval threshold",
+ deliveries[0].len()
+ );
+ let first: Vec = serde_json::from_str(&deliveries[0]).unwrap();
+ assert_eq!(first[0]["data"], r#"["EOSE","sub"]"#);
+ }
+
+ #[tokio::test]
+ async fn eof_delivers_buffered_frames_before_the_close() {
+ let manager = WebSocketManager::default();
+ let (client_io, server_io) = duplex(4096);
+ let (client, mut server) = tokio::join!(
+ WebSocketStream::from_raw_socket(client_io, Role::Client, None),
+ WebSocketStream::from_raw_socket(server_io, Role::Server, None),
+ );
+ let (channel, deliveries) = recording_channel();
+ let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY);
+ let handle = Arc::new(ConnectionHandle {
+ sender,
+ cancel: CancellationToken::new(),
+ task: Mutex::new(None),
+ });
+ manager.connections.lock().await.insert(1, handle.clone());
+ let task = tauri::async_runtime::spawn(run_connection(
+ 1,
+ client,
+ receiver,
+ handle.cancel.clone(),
+ channel,
+ manager.clone(),
+ ));
+ *handle.task.lock().await = Some(task);
+
+ server.send(Message::Text("buffered".into())).await.unwrap();
+ drop(server);
+
+ tokio::time::timeout(Duration::from_secs(2), async {
+ while manager.connections.lock().await.contains_key(&1) {
+ tokio::task::yield_now().await;
+ }
+ })
+ .await
+ .expect("EOF should clean up its native connection ID");
+
+ // A frame the relay already delivered must reach the renderer even
+ // though the socket closed inside the batch window.
+ let seen = deliveries.lock().unwrap().join("");
+ assert!(
+ seen.contains("buffered"),
+ "buffered frame was dropped: {seen}"
+ );
+ }
+
#[tokio::test]
async fn secure_websocket_reaches_tls_without_panicking() {
install_crypto_provider();
diff --git a/desktop/src-tauri/src/native_websocket_batch.rs b/desktop/src-tauri/src/native_websocket_batch.rs
new file mode 100644
index 00000000000..bf82804fd2d
--- /dev/null
+++ b/desktop/src-tauri/src/native_websocket_batch.rs
@@ -0,0 +1,265 @@
+use std::time::Duration;
+
+use tauri::ipc::{Channel, InvokeResponseBody};
+use tokio::time::Instant;
+
+/// Inbound text frames are coalesced into one `Channel::send` for this long
+/// before delivery. Collapses N main-run-loop wakeups into one under a
+/// catch-up storm without adding latency the relay protocol can observe.
+pub(crate) const BATCH_WINDOW: Duration = Duration::from_millis(8);
+/// Byte ceiling for a coalesced batch, measured on the *serialized* payload.
+///
+/// `tauri::ipc::Channel::send` forks on payload size: below
+/// `MAX_JSON_DIRECT_EXECUTE_THRESHOLD` (8192) it goes straight to
+/// `webview.eval`; at or above it the body is parked in a `ChannelDataIpcQueue`
+/// and the webview is made to call *back* into Rust over the IPC to fetch it
+/// (tauri-2.11.5 `src/ipc/channel.rs:37,154-181,319-331`). That round-trip is
+/// what batching is supposed to remove, so a batch must never cross the line —
+/// bounding by frame count instead would put every batch on the slow path.
+/// The margin absorbs the envelope; the check itself uses real serialized
+/// length, because JSON escaping inflates payloads by an amount no fixed
+/// per-frame estimate can bound.
+pub(crate) const BATCH_MAX_SERIALIZED_BYTES: usize = 7680;
+
+/// Coalesces inbound frames into a single IPC delivery.
+///
+/// Frames are serialized once on arrival so the batch can be bounded by its
+/// true serialized length, and are concatenated into a JSON array at flush —
+/// no value is serialized twice. Every delivery is an array, including the
+/// single-frame case; the renderer accepts both shapes.
+#[derive(Default)]
+pub(crate) struct FrameBatch {
+ frames: Vec,
+ /// Serialized length of the delivered array, kept in sync with `frames`:
+ /// the enclosing brackets plus each frame and its separating comma.
+ serialized_len: usize,
+ deadline: Option,
+}
+
+impl FrameBatch {
+ /// Serialized length of the array if `frame` were appended.
+ pub(crate) fn projected_len(&self, frame: &str) -> usize {
+ let separator = usize::from(!self.frames.is_empty());
+ self.serialized_len.max(2) + separator + frame.len()
+ }
+
+ pub(crate) fn push(&mut self, frame: String) {
+ self.serialized_len = self.projected_len(&frame);
+ self.frames.push(frame);
+ self.deadline
+ .get_or_insert_with(|| Instant::now() + BATCH_WINDOW);
+ }
+
+ /// Resolves when the open batch is due, or never while there is none.
+ pub(crate) async fn due(&self) {
+ match self.deadline {
+ Some(deadline) => tokio::time::sleep_until(deadline).await,
+ None => std::future::pending().await,
+ }
+ }
+
+ pub(crate) fn flush(&mut self, on_message: &Channel) {
+ if self.frames.is_empty() {
+ return;
+ }
+ let payload = format!("[{}]", self.frames.join(","));
+ self.frames.clear();
+ self.serialized_len = 0;
+ self.deadline = None;
+ let _ = on_message.send(InvokeResponseBody::Json(payload));
+ }
+}
+
+/// Whether a relay frame must reach the renderer without waiting out the batch
+/// window. Only the NIP-42 challenge qualifies: it gates a round trip the
+/// relay is waiting on, whereas `OK`/`EOSE` ride the window so catch-up
+/// batching survives.
+///
+/// Takes the relay payload, not the serialized envelope — inside the envelope
+/// the payload's quotes are escaped and no plain `"AUTH"` prefix exists.
+///
+/// Conservative by construction — a missed match costs at most one batch
+/// window of latency against a 25s auth timeout, never correctness.
+pub(crate) fn is_auth_challenge(payload: &str) -> bool {
+ payload
+ .trim_start()
+ .strip_prefix('[')
+ .unwrap_or_default()
+ .trim_start()
+ .starts_with("\"AUTH\"")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Arc;
+
+ /// Records each delivery as its raw JSON payload, so tests assert on what
+ /// the renderer actually receives rather than on internal batch state.
+ fn recording_channel() -> (
+ Channel,
+ Arc>>,
+ ) {
+ // A std mutex: `Channel::send` is synchronous and runs on whatever
+ // thread flushed, including inside the async runtime.
+ let deliveries = Arc::new(std::sync::Mutex::new(Vec::new()));
+ let sink = deliveries.clone();
+ let channel = Channel::new(move |body: InvokeResponseBody| {
+ let payload = match body {
+ InvokeResponseBody::Json(json) => json,
+ InvokeResponseBody::Raw(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
+ };
+ sink.lock().unwrap().push(payload);
+ Ok(())
+ });
+ (channel, deliveries)
+ }
+
+ /// Mirrors the envelope `native_websocket` serializes, so these tests bind
+ /// to the real wire shape rather than a convenient stand-in.
+ fn text_frame(payload: &str) -> String {
+ serde_json::json!({ "type": "Text", "data": payload }).to_string()
+ }
+
+ #[test]
+ fn batch_bound_tracks_real_serialized_length() {
+ let mut batch = FrameBatch::default();
+ let first = text_frame("one");
+ let second = text_frame("two");
+ batch.push(first.clone());
+ batch.push(second.clone());
+
+ // The tracked length must equal the payload actually built at flush;
+ // an estimate that drifts from it would silently cross the 8192 fork.
+ let expected = format!("[{first},{second}]");
+ assert_eq!(batch.serialized_len, expected.len());
+ }
+
+ #[test]
+ fn escape_heavy_frames_stay_under_the_direct_eval_threshold() {
+ // Quotes double under JSON escaping, so a bound applied to raw relay
+ // bytes would pass here while the serialized body crosses 8192 and
+ // silently moves every batch onto the fetch round-trip.
+ let mut batch = FrameBatch::default();
+ let mut pushed = 0;
+ loop {
+ let frame = text_frame(&"\"".repeat(512));
+ if batch.projected_len(&frame) > BATCH_MAX_SERIALIZED_BYTES {
+ break;
+ }
+ batch.push(frame);
+ pushed += 1;
+ }
+
+ assert!(
+ pushed > 0,
+ "bound must admit at least one escape-heavy frame"
+ );
+ assert!(
+ batch.serialized_len < 8192,
+ "serialized batch {} must stay under the direct-eval threshold",
+ batch.serialized_len
+ );
+ }
+
+ #[tokio::test]
+ async fn frames_within_the_window_arrive_as_one_delivery() {
+ let (channel, deliveries) = recording_channel();
+ let mut batch = FrameBatch::default();
+ batch.push(text_frame("one"));
+ batch.push(text_frame("two"));
+ batch.push(text_frame("three"));
+ batch.flush(&channel);
+
+ let deliveries = deliveries.lock().unwrap();
+ assert_eq!(deliveries.len(), 1, "three frames must cost one IPC wakeup");
+ // Asserted as the wire shape the renderer parses, not as a Rust type.
+ let frames: Vec