From 105e2b46419741ae9f9bab8dda0d947a82b6cfea Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 18:43:39 +1000 Subject: [PATCH 01/27] refactor(runtime): lift main_loop's select arms into a Runtime struct (#528) The ~2,500-line StateHandler::main_loop kept some thirty loop-local variables and 21 select! arms in one function nothing could call from a test. Those locals are now the fields of state_handler::runtime::Runtime, each arm is one method on it (runtime/arms.rs, inbound_arm.rs, timers.rs), and Runtime::run is a router over them plus the end-of-iteration work. Pure restructuring: the arm bodies moved verbatim (each method destructures the fields it uses, so the bodies read as they did with locals), the setup after the State-A branch became Runtime::assemble, and the shutdown sequence Runtime::shutdown. No behaviour change. Signed-off-by: Glenn Gore --- openvtc/src/state_handler/mod.rs | 1967 +---------------- openvtc/src/state_handler/runtime/arms.rs | 573 +++++ .../src/state_handler/runtime/inbound_arm.rs | 481 ++++ openvtc/src/state_handler/runtime/mod.rs | 767 +++++++ openvtc/src/state_handler/runtime/tests.rs | 1 + openvtc/src/state_handler/runtime/timers.rs | 640 ++++++ 6 files changed, 2487 insertions(+), 1942 deletions(-) create mode 100644 openvtc/src/state_handler/runtime/arms.rs create mode 100644 openvtc/src/state_handler/runtime/inbound_arm.rs create mode 100644 openvtc/src/state_handler/runtime/mod.rs create mode 100644 openvtc/src/state_handler/runtime/tests.rs create mode 100644 openvtc/src/state_handler/runtime/timers.rs diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index 30da77a8..60172ec4 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -278,6 +278,7 @@ mod persona_binding_refresh; pub(crate) mod repos_actions; pub(crate) mod repos_workspace; mod rooms_actions; +mod runtime; mod signing_health; /// The DIDComm transport module, which now lives in `openvtc-core`. /// @@ -857,7 +858,7 @@ impl StateHandler { // single frame. Holds the session through a watch rather than a clone, // so the heartbeat loop outlives any one borrow of the admin session — // and any one session, when a stalled one is rebuilt. - let (presence_tx, mut presence_rx) = + let (presence_tx, presence_rx) = mpsc::unbounded_channel::(); // When the loop rebuilds a stalled session it publishes the new client // here, and the presence task's next call uses it. @@ -1009,7 +1010,7 @@ impl StateHandler { // overflow drop destroys a membership credential or join verdict rather // than deferring it (#221, fixed in #224). Full rationale on // `didcomm::DIDCOMM_EVENT_CHANNEL_CAPACITY`. - let (didcomm_event_tx, mut didcomm_event_rx) = mpsc::unbounded_channel(); + let (didcomm_event_tx, didcomm_event_rx) = mpsc::unbounded_channel(); let shutdown_token = tokio_util::sync::CancellationToken::new(); let didcomm_service = didcomm::start_empty_service(didcomm_event_tx.clone(), shutdown_token.clone()); @@ -1047,7 +1048,7 @@ impl StateHandler { // context back as `DegradedOutcome::Joined` instead of looping, so we // fall through to the messaging setup below — no process restart needed // to receive the approval credential. - let (tdk, mut config, mut admin_vta) = if config.active_identity().is_none() { + let (tdk, config, admin_vta) = if config.active_identity().is_none() { state.connection.status = state::MediatorStatus::NoActiveCommunity; let _ = self.state_tx.send(state.clone()); // No persona yet (State A). Hand the always-on admin session to the @@ -1087,1946 +1088,28 @@ impl StateHandler { (tdk, config, admin_vta) }; - // Point the runtime active identity at the default working community and - // refilter the community-scoped panels before the first paint (D10 / - // R-C-6) — otherwise a multi-persona account renders empty panels until - // the first event re-syncs (the per-path syncs above run with no - // selection set yet). - state.reconcile_selected_community_among( - &config.account, - Some(&config.identities.keys().copied().collect()), - ); - let initial_active = state - .selected_community - .as_ref() - .map(|(_, persona)| *persona); - config.set_active_persona(initial_active); - state.main_page.sync_from_config(&config); - - // Send initial state immediately so the UI renders without blocking. - // Connecting only when there is a community session to connect: with no - // live membership there is none, and "Connecting..." would sit on the - // screen until the seed below corrected it — or read as a hang. - state.connection.status = if config.account.memberships().any(|m| m.is_live()) { - state::MediatorStatus::Connecting - } else { - state::MediatorStatus::NoActiveCommunity - }; - let _ = self.state_tx.send(state.clone()); - - // Install this account's listeners on the runtime started above. Skips - // any that already exist, so a State-A join's own persona listener is - // bound to rather than duplicated (one websocket per DID). - // - // The specs are built here because that needs `Config`, which is not - // `Clone` and cannot cross into a task. Bringing the listeners *up* is - // the expensive half — a mediator auth handshake and a websocket connect - // each, in series — and that half runs off this thread. This is the only - // thread that services UI actions, and the loading screen has already - // been told to offer "Press Enter to continue": awaiting the connects - // here meant the keypress sat unread in the action channel until the last - // socket was up, and the main page then arrived in one late jump. - // Outbound TSP sends (`tsp::send_trust_task`) run on `tdk.atm`; inbound - // frames are unsealed by listener ATMs. Both must record relationships in - // the one durable store, or the invite we send is `Pending` where no - // listener looks and the peer's accept and replies are dropped (VTI-61). - let tdk = match didcomm_service.tsp_store().adopt_into(tdk.clone()).await { - Ok(adopted) => adopted, - Err(e) => { - warn!(error = %e, "could not share the TSP relationship store with the sending ATM"); - tdk - } - }; - - let listener_specs = didcomm::build_listener_configs(&config, &tdk).await; - let _listener_install = - didcomm::spawn_install_listeners(didcomm_service.clone(), listener_specs); - - // Process-lifetime LRU of inbound message IDs. Backstop for replay - // and mediator-pickup duplicates beyond what the TDK already filters. - let mut seen_messages = openvtc_core::messaging::SeenMessages::new(); - - // Forward lifecycle events (connect/disconnect/restart) to the activity log - let (lifecycle_log_tx, mut lifecycle_log_rx) = - mpsc::unbounded_channel::(); - let _lifecycle_handle = didcomm::spawn_lifecycle_logger(&didcomm_service, lifecycle_log_tx); - - // Phase 2 (community/persona DIDComm connection) runs ASYNCHRONOUSLY: we - // do NOT block the UI waiting for the listener. The main page is already - // showing (Connecting); the persona connection proceeds in the - // background and the runtime loop below flips the status to Connected the - // moment a `ListenerEvent::Connected` arrives — and back to Connecting on - // a disconnect. Subscribe to typed lifecycle events for that. - let mut listener_events = didcomm_service.subscribe(); - - // Supervised multi-session manager (D11/D15): one persona-session per - // active community (a reused persona shares one), tracking per-session - // connection status over the listeners `start_service` already launched. - // Messaging runs through it from here on; at N=1 it holds one session and - // behaves identically to the previous single global flag. - let mut session_manager = session_manager::SessionManager::default(); - { - use session_manager::RegisterOutcome; - // NOTE: a mid-session leave/reject/expire does NOT yet deregister its - // session (that is T6/T7), so until then `any_connected()` can read - // live for a persona whose communities have all gone inactive. Startup - // registers exactly the communities that require a live session at load - // time (`IdentityRegistry::sessions`). - let registry = openvtc_core::identity::IdentityRegistry::new(&config.account); - let mut at_capacity = 0usize; - for (persona_id, vtcs) in registry.sessions() { - let Some(ctx) = config.identities.get(&persona_id) else { - // A live community whose persona didn't resolve to an identity - // has no listener either — surfaced here for parity, not silent. - debug!(%persona_id, "live-community persona missing from resolved identities — not tracked"); - continue; - }; - let lid = didcomm::persona_listener_id(&ctx.did); - for vtc in vtcs { - if matches!( - session_manager.register(persona_id, &lid, vtc.clone()), - RegisterOutcome::AtCapacity - ) { - at_capacity += 1; - warn!(%persona_id, vtc = %vtc, "session manager at capacity — community not tracked"); - } - } - } - // No silent caps (D15): tell the user if any community exceeded the bound. - if at_capacity > 0 { - state.main_page.log(format!( - "Warning: {at_capacity} communit{} exceeded the session limit ({}) and are not actively connected.", - if at_capacity == 1 { "y" } else { "ies" }, - session_manager.max_sessions(), - )); - } - } - - // Seed the indicator from the messaging layer's *live* state rather than - // assuming nothing is up yet. `ListenerStatus` is edge-triggered, and a - // State-A join has already brought its persona listener up and connected - // by the time this loop subscribes — the install step above binds to that - // socket instead of reinstalling it, so no second `Connected` edge is - // coming. Without this seed the session sits `Connecting` for the life of - // the process over a perfectly live socket, which is the "Inbox: - // Connecting…" that only a restart appeared to fix. - // - // Applied unconditionally rather than only when the reconcile changed - // something: an account with no community session (an applicant whose - // join is still being vetted) has nothing to change, and must still - // land on `NoActiveCommunity` rather than the `Connecting` shown while - // the page came up — no session will ever send the edge that moves it. - reconcile_sessions(&mut session_manager, &didcomm_service, &mut state); - apply_session_aggregate(&session_manager, &mut state); - match state.connection.status { - state::MediatorStatus::Connected => state.main_page.log("Connected to the mediator."), - state::MediatorStatus::NoActiveCommunity => state - .main_page - .log("No community membership yet — no community session to connect."), - _ => state.main_page.log("Connecting to the mediator…"), - } - let _ = self.state_tx.send(state.clone()); - - // Track when a manual trust-ping was sent (for activity log latency display). - let mut ping_sent_at: Option = None; - - // Background-dispatch plumbing (R13). Network-bound actions whose await - // would otherwise park the whole select loop are spawned as background - // tasks; they do I/O only and send their result back as a - // `DispatchOutcome` on this channel. The select arm below applies the - // outcome on the loop thread (the single mutator), keeping the loop live - // during the wait. `in_flight` rejects a second action on a busy domain. - let (dispatch_tx, mut dispatch_rx) = - mpsc::unbounded_channel::(); - let mut in_flight = background_dispatch::InFlight::default(); - // Hands a witness's answers from the inbound arm to the witnessing waiting on them - // (`witness_actions`); the job verifies each one before reading it. - let witness_router = openvtc_core::witness::ReplyRouter::default(); - // Membership contexts already checked at the VTA this run. A failure is - // logged and retried on the next launch rather than every tick. - let mut contexts_checked: std::collections::HashSet = - std::collections::HashSet::new(); - // Whether this run has read the membership contexts' device grants for - // the communities panel. Once a run; the device-access view re-reads on - // demand. - let mut grants_swept = false; - - // Coalesced + offloaded config persistence (R11). Mutation sites mark the - // config dirty on the loop thread instead of saving inline; the - // `deadline` arm below debounces a burst into a single `spawn_blocking` - // save, with at most one in flight at a time. Durability-critical points - // (Exit, passphrase/protection change, export) force-flush synchronously. - let mut save = save_coalesce::SaveScheduler::new(self.profile.clone()); - // Channel carrying a completed background save's success flag, so the loop - // can clear the in-flight flag and re-arm if the config was dirtied again - // while the save ran. Save failures are surfaced as a status/log here, - // matching the pre-R11 inline `save_config` failure handling. - let (save_done_tx, mut save_done_rx) = mpsc::unbounded_channel::>(); - - // Account-state sync with the VTA (`openvtc_core::vta_sync`). The load - // already ran one; report it, and save whatever it changed. From here - // a cycle runs after each save and on a timer, off the loop: the plan - // is taken here, the network half is spawned, and its result is merged - // back here — the loop stays the only thing that mutates `config`. - if let Some(report) = config.private.vta_sync.load_report.take() { - for line in report.log_lines() { - state.main_page.log(line); - } - if report.touched() { - save.mark_dirty(); - } - } - let mut vta_sync = vta_sync_driver::SyncDriver::new(tokio::time::Instant::now()); - let mut vta_sync_tick = tokio::time::interval(vta_sync_driver::TICK); - vta_sync_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - let (vta_sync_tx, mut vta_sync_rx) = - mpsc::unbounded_channel::(); - - // 7-day Pending timeout sweep (R-B-7 / D16). `interval`'s first tick fires - // immediately, so a Pending that aged out while the app was closed expires - // on launch; thereafter it sweeps hourly. - let mut pending_expiry_tick = tokio::time::interval(std::time::Duration::from_secs(3600)); - // Reply-window sweep for the capabilities view (cheap no-op when the - // view is closed or idle). - let mut capabilities_sweep = tokio::time::interval(std::time::Duration::from_secs(5)); - // Backstop for the edge-triggered listener-status stream. A transition - // that reaches nobody — the broadcast receiver lagging, a listener - // adopted after its connect — would otherwise latch a session's status - // (and the indicator) wrong until the next real edge, which for a healthy - // socket may never come. Cheap: a read of the transports' live state, no - // I/O and no allocation beyond the listener ids. - let mut session_reconcile_tick = tokio::time::interval(std::time::Duration::from_secs(15)); - // Agent-name refresh sweep. The first tick fires immediately so names - // resolve shortly after launch; thereafter it picks up newly-added DIDs - // (a fresh relationship/contact) and re-verifies stale entries every few - // minutes. Cheap when there is nothing to do — it only spawns a job when - // `agent_name_refresh_targets` finds uncached/stale DIDs, and the resolver - // caches keep repeat sweeps quiet. - let mut agent_name_tick = tokio::time::interval(std::time::Duration::from_secs(300)); - // Reconcile Pending joins with their communities. The first tick fires - // immediately, which is the point: a join still Pending at launch is the - // one whose answer may have been lost, and asking is how we find out - // rather than waiting to be told again. Per-record backoff lives in the - // pacer, so a minute-by-minute tick does not mean a minute-by-minute - // poll; ticks over an account with no Pending join do no work at all. - let mut join_status_tick = tokio::time::interval(std::time::Duration::from_secs(60)); - let mut join_status_pacer = openvtc_core::join::status_poll::PollPacer::default(); - - // Ask each Active community, once per session, for its declared - // `relationshipIdentifierDefault` (issue #241). The pacer asks each - // community once, so this tick over communities already asked does no - // work; a bare account (no Active membership) does none at all. - let mut community_profile_tick = tokio::time::interval(std::time::Duration::from_secs(60)); - let mut community_profile_pacer = community_profile_poll::ProfilePacer::default(); - - // The vetter side of hidden vetting: re-read each community we vet for, - // enrol, and draw the token drip — on the communities' tick windows and - // at least hourly, never on demand (`hidden_vetting_poll`). The interval - // only checks the pacer; the first check is at launch, which is when a - // client that was off catches up the ticks it missed. - let mut hidden_vetting_tick = tokio::time::interval(std::time::Duration::from_secs(30)); - hidden_vetting_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - let mut hidden_vetting_pacer = hidden_vetting_poll::HiddenVettingPacer::default(); - - // Network-bound checks on inbound messages (a DID resolve, a status - // list fetch) run off this loop, one at a time, so a slow community - // never freezes the UI. A message waiting on its check is not applied - // at all; it comes back from `verifier` with the result and is - // dispatched from the top. The queue is bounded, with a lane for the - // communities we belong to and a cap per sender (`verify_queue`). - let mut verifier = verify_queue::Verifier::new(tdk.clone()); - // Messages from communities that were still queued at the last exit. - // The mediator deleted them on delivery, so their kept copies are the - // only ones. They are queued again as room allows; the rest wait (kept) - // until earlier checks finish, and none is dropped for want of room. - let mut restore_backlog: std::collections::VecDeque<_> = - config.private.deferred_inbound.iter().cloned().collect(); - if restore_kept( - &mut verifier, - &mut restore_backlog, - &mut config, - &mut state, - &tdk, - &didcomm_service, - &mut seen_messages, - ) + // Everything from here on is the runtime loop's (#528): its fields are + // what used to be this function's locals, and each `select!` arm is a + // method on it. + runtime::Runtime::assemble(runtime::Startup { + handler: self, + terminator, + action_rx, + interrupt_rx, + state, + tdk, + config, + admin_vta, + presence_rx, + vta_supervisor, + didcomm_event_rx, + shutdown_token, + didcomm_service, + tsp_dirty, + }) + .await + .run() .await - { - save.mark_dirty(); - } - - // The join flow to enter at the end of this iteration, and a join - // waiting on a community's requirements (see `join_flow::AwaitingRequirements`). - let mut join_entry: Option = None; - let mut awaiting_requirements: Option = None; - // A join parked on a hidden-vetting challenge (see - // `join_flow::AwaitingChallenge`) — the same hand-off, for the question - // a launch asks rather than the one the vetting page does. - let mut awaiting_challenge: Option = None; - // A join parked on a wallet session (see `join_flow::AwaitingWallet`). - let mut awaiting_wallet: Option = None; - let result = loop { - tokio::select! { - Some(action) = action_rx.recv() => match action { - Action::Exit => { - if let Err(e) = terminator.terminate(Interrupted::UserInt) { - debug!("Failed to send terminate signal: {e}"); - } - - break Interrupted::UserInt; - }, - - Action::UXError(interrupted) => { - // An error has occurred on the UX side - if let Err(e) = terminator.terminate(interrupted.clone()) { - debug!("Failed to send terminate signal: {e}"); - } - - break interrupted; - }, - - Action::StartJoin => { - // State-B join from the live runtime: reuse the always-on - // admin VTA session. The DIDComm service keeps running in - // the background; the join flow owns the screen until the - // user returns. Entered at the loop's tail, which is also - // where a join waiting on a community's requirements is - // entered again. - join_entry = Some(join_flow::JoinEntry::Fresh { hears_replies: true }); - }, - // The same join, for a community already chosen: `j` on a - // vetting application resumes the join that application was - // made for, without going back for its DID. - Action::StartJoinFor(vtc_did) => { - join_entry = Some(join_flow::JoinEntry::ForCommunity { - vtc_did, - hears_replies: true, - }); - }, - // A join waiting on a community's requirements keeps its screen - // up while this loop hears the answer, so the two keys that - // page offers land here rather than in the join flow. - Action::JoinCancel if awaiting_requirements.is_some() => { - if let Some(awaiting) = awaiting_requirements.take() { - config.private.vetting.forget_query(&awaiting.document_id); - } - state.active_page = state::ActivePage::Main; - }, - Action::JoinVettingJoin if awaiting_requirements.is_some() => { - if let Some(awaiting) = awaiting_requirements.take() { - config.private.vetting.forget_query(&awaiting.document_id); - join_entry = Some(join_flow::JoinEntry::Resume { - vtc_did: awaiting.vtc_did, - outcome: join_flow::RequirementsOutcome::Skipped, - }); - } - }, - // Leaving a join parked on its challenge. Nothing was sent - // but the question, and the challenge it brings stands - // unused until the community lets it lapse. - Action::JoinCancel if awaiting_challenge.is_some() => { - if let Some(awaiting) = awaiting_challenge.take() { - config.private.vetting.forget_query(&awaiting.document_id); - } - state.join.processing = false; - state.join.waiting_on_challenge = false; - state.main_page.log( - "Stopped waiting for the community's challenge. Nothing was sent — \ - join again when you are ready.", - ); - state.active_page = state::ActivePage::Main; - }, - // Leaving a wallet session. The session lapses on its own; - // the page offers to try again or go on without it. - Action::JoinCancel if awaiting_wallet.is_some() => { - if let Some(query) = - awaiting_wallet.take().and_then(|a| a.asking) - { - config.private.vetting.forget_query(&query); - } - join_entry = Some(join_flow::JoinEntry::Wallet { - end: join_flow::WalletEnd::Unfinished( - "You stopped waiting for the wallet. Nothing has been sent to \ - the community but the session request." - .to_string(), - ), - }); - }, - // Everything else acts on state and the resources below, - // and lives in `runtime_actions` where a test can call it. - // Only the three arms above need what a loop has and a - // handler cannot be given: the terminator, the action - // receiver, and the ability to `break` with this loop's own - // outcome type. - action => { - let mut ctx = runtime_actions::ActionCtx { - state: &mut state, - config: &mut config, - save: &mut save, - in_flight: &mut in_flight, - dispatch_tx: &dispatch_tx, - tdk: &tdk, - admin_vta: admin_vta.as_ref(), - didcomm_service: &didcomm_service, - session_manager: &mut session_manager, - ping_sent_at: &mut ping_sent_at, - state_tx: &self.state_tx, - profile: self.profile.as_str(), - witness_router: &witness_router, - }; - if matches!( - runtime_actions::handle_action(&mut ctx, action).await, - runtime_actions::Handled::ExitUserInt - ) { - // A confirmed profile wipe. The handler cannot break - // this loop or reach the terminator, so it says so. - if let Err(e) = terminator.terminate(Interrupted::UserInt) { - debug!("Failed to send terminate signal: {e}"); - } - break Interrupted::UserInt; - } - }, - }, - // DIDComm inbound message events — as they arrive, or handed - // back with the result of their off-loop check. - Some((event, pre)) = next_inbound(&mut didcomm_event_rx, &mut verifier) => { - match event { - didcomm::DIDCommEvent::InboundMessage { message, transport, authenticated, .. } => { - // Capture message info before processing for detailed logging - let msg_type = message.typ.clone(); - let msg_from = message.from.clone().unwrap_or_else(|| "unknown".into()); - let msg_to = message.to.as_ref().and_then(|v| v.first()).cloned().unwrap_or_default(); - let msg_thid = message.thid.clone().unwrap_or_else(|| "none".into()); - - // A credential that has been checked no longer - // shows its membership as verifying. - let returning_credential = matches!( - pre, - Some(openvtc_core::inbound::PreVerified::Credential(_)) - ); - let returning = pre.is_some(); - let mut effects = openvtc_core::inbound::InboundEffects::default(); - // Only the sender the transport authenticated can be - // waited on: a claimed `from` alone neither holds a - // community's messages back nor jumps its queue. - let waiting = |claimed: &str| { - authenticated.as_deref() == Some(claimed) - && verifier.is_waiting_on(claimed) - }; - let arrival = match pre { - Some(pre) => openvtc_core::inbound::Arrival::Returning(pre), - None => openvtc_core::inbound::Arrival::New { waiting: &waiting }, - }; - let dispatched = openvtc_core::inbound::process_inbound_message( - &mut config, - &tdk, - &didcomm_service, - &mut seen_messages, - &message, - authenticated.as_deref(), - &mut effects, - arrival, - ) - .await; - // Applied (or refused): its kept copy goes. - if returning { - let before = config.private.deferred_inbound.len(); - config.private.deferred_inbound.retain(|kept| { - Some(kept.sender.as_str()) != authenticated.as_deref() - || kept.message.id != message.id - }); - if config.private.deferred_inbound.len() != before { - save.mark_dirty(); - } - } - let openvtc_core::inbound::InboundEffects { - inactivated, - capability_replies, - git_ns_replies, - rooms_replies, - elections_replies, - personhood_challenges, - vetting_answers, - vetting_grant_checks, - renewals, - witness_replies, - notifications, - deferred, - } = effects; - // Notifications for our personas, to the member's VTA as received, - // and the sender answered (VTI-NTF-054). - for mut received in notifications { - received.over_tsp = transport == openvtc_core::didcomm::MessagingTransport::Tsp; - notification_actions::hand_over( - &config, - &tdk, - admin_vta.as_ref(), - received, - ) - .await; - } - // A witness's answers, to the witnessing waiting on their thread. - for reply in witness_replies { - let thread = reply - .document - .get("threadId") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_string(); - if witness_router.deliver(&reply.sender, reply.document) { - debug!(%thread, "a witness's answer, handed to its witnessing"); - } - } - if returning_credential { - state - .main_page - .content_panel - .communities - .verifying - .remove(&msg_from); - state.main_page.sync_from_config(&config); - } - // Set aside for its check: the membership shows - // "verifying…" until the result comes back. - if let Some(deferred) = deferred { - let _ = queue_for_check( - &mut verifier, - &mut config, - &mut state, - deferred, - transport, - authenticated.as_deref(), - ); - save.mark_dirty(); - } - // A check finished, so there may be room for a - // message kept from before the restart. - if returning - && !restore_backlog.is_empty() - && restore_kept( - &mut verifier, - &mut restore_backlog, - &mut config, - &mut state, - &tdk, - &didcomm_service, - &mut seen_messages, - ) - .await - { - save.mark_dirty(); - } - - // A live challenge is display state, not account - // state: single-use, ten-minute life, and worthless - // after a restart. It is folded in here rather than - // persisted, and unconditionally — a reply that - // arrived is a reply the member should see whether - // or not the message also changed the config. - for reply in personhood_challenges { - // The challenge belongs to the persona it was - // addressed to. Without one we cannot sign for - // it, so there is nothing to offer the member — - // and silently showing an unanswerable code - // would be worse than saying nothing. - let Some(persona) = config.account.persona_id_for_did(&msg_to) - else { - warn!( - vtc = %msg_from, - to = %msg_to, - "personhood challenge addressed to a DID this account \ - holds no persona for — ignoring", - ); - continue; - }; - state.main_page.content_panel.communities.personhood_challenge = - Some(crate::state_handler::main_page::content::PersonhoodChallengeView { - vtc_did: msg_from.clone(), - persona, - challenge_id: reply.challenge_id, - match_code: reply.match_code.clone(), - expires_at: reply.expires_at, - }); - state.main_page.content_panel.communities.status_message = Some(Status::info( - format!( - "Personhood challenge received — confirm the code {} with \ - whoever is vetting you, then assert.", - reply.match_code - ), - )); - } - - // How a renewal the member asked for ended: on the - // Communities status line, where they asked, and in - // the activity log with the community named. - for (vtc, outcome) in renewals { - let line = outcome.describe(); - match &outcome { - openvtc_core::renewal::RenewalOutcome::Renewed { .. } => { - state.main_page.log(&line) - } - _ => state.main_page.log_error( - "Membership renewal", - &format!("{vtc}: {line}"), - ), - } - let severity = match &outcome { - openvtc_core::renewal::RenewalOutcome::Renewed { .. } => { - crate::state_handler::main_page::status::Severity::Success - } - _ => crate::state_handler::main_page::status::Severity::Error, - }; - state.main_page.content_panel.communities.status_message = - Some(Status::new(severity, line)); - } - - match dispatched - { - Ok(true) => { - // R11: a config-mutating inbound message used - // to save inline here — the per-message cost - // that turned a mediator redelivery burst into - // N sequential keyring+file+card writes. Now we - // mark dirty (coalesced + offloaded); the UI - // sync stays immediate. - save.mark_dirty(); - // R-S-3: a community resolved to an inactive - // status (e.g. a rejection) — tear down its - // live session so a dead community stops - // holding a mediator connection. - for (vtc, persona) in &inactivated { - deregister_inactive_community( - &mut session_manager, - &didcomm_service, - &config, - &mut state, - vtc, - *persona, - ) - .await; - } - state.main_page.sync_from_config(&config); - let short_type = openvtc_core::display::task_label(&msg_type); - state.main_page.log_detailed( - format!("Inbound {transport}: {short_type}"), - format!( - "Inbound {transport} Message\n\ - ───────────────────────\n\ - Type: {msg_type}\n\ - From: {msg_from}\n\ - To: {msg_to}\n\ - thid: {msg_thid}", - ), - ); - } - Ok(false) => {} - Err(e) => { - state.main_page.log_detailed( - format!("Message error: {e}"), - format!( - "Failed Inbound Message\n\ - ──────────────────────\n\ - Type: {msg_type}\n\ - From: {msg_from}\n\ - To: {msg_to}\n\ - thid: {msg_thid}\n\ - Error: {e}", - ), - ); - debug!("message dispatch error: {e}"); - } - } - apply_capability_replies(&mut state, capability_replies); - // git-ns answers for the Repos panel; a change that - // landed is followed by a fresh read of the view. - if !git_ns_replies.is_empty() - && repos_actions::apply_replies(&mut state, &config, git_ns_replies) - { - repos_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - } - .refresh(true) - .await; - } - // The answer names the repositories to look for - // on this machine. - repos_workspace::probe_if_due(&mut state, &dispatch_tx, &mut in_flight); - // Data-room answers for the Rooms panel; each may call for the - // next step of a refresh or an acceptance. - if !rooms_replies.is_empty() - && rooms_actions::apply_replies(&mut state, rooms_replies) - { - rooms_actions::advance(&mut rooms_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - vta: admin_vta.as_ref(), - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - }) - .await; - } - // Election answers for the Elections panel; each may call for - // the next step of a ticket or a vote. - if !elections_replies.is_empty() - && elections_actions::apply_replies(&mut state, elections_replies) - { - elections_actions::advance(&mut elections_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - vta: admin_vta.as_ref(), - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - }) - .await; - } - // A vetter's grant is checked for revocation off the - // loop: the check fetches the community's status list. - for check in vetting_grant_checks { - vetting_actions::spawn_grant_check(&dispatch_tx, &tdk, check); - } - // The manifest or challenge a waiting join asked for - // resumes it; every other answer goes to the Vetting page. - let mut answers = Vec::with_capacity(vetting_answers.len()); - for answer in vetting_answers { - if let Some(entry) = awaiting_requirements - .as_ref() - .and_then(|a| join_flow::resume_for(a, &answer)) - { - awaiting_requirements = None; - join_entry = Some(entry); - } else if let Some(entry) = awaiting_challenge - .as_ref() - .and_then(|a| join_flow::challenge_resume_for(a, &answer)) - { - awaiting_challenge = None; - join_entry = Some(entry); - } else if let Some(step) = awaiting_wallet - .as_mut() - .and_then(|a| join_flow::wallet_answer(a, &answer, &mut state)) - { - if let Some(entry) = step { - awaiting_wallet = None; - join_entry = Some(entry); - } - } else { - answers.push(answer); - } - } - vetting_actions::apply_answers(&mut state, &config, answers); - // A manifest in is a fresh reading of how its community vets: say a - // switch, and decide a ticket that was waiting on it. - vetting_actions::settle_pending_tickets( - &mut state, - &mut config, - &mut save, - chrono::Utc::now(), - ); - } - didcomm::DIDCommEvent::TrustPingReceived { from, listener_id, message_id } => { - // `from` is the sender the transport authenticated - // (never the plaintext header); with none there is - // nobody to answer. - let sender = from.as_deref().unwrap_or("unknown"); - let sender_arc = std::sync::Arc::new(sender.to_string()); - - // Only respond to pings from the mediator or established relationships - let is_mediator = - from.is_some() && sender == config.mediator_did(); - let has_relationship = from.is_some() - && config - .private - .relationships - .find_by_remote_did(&sender_arc) - .map(|r| { - r.state == openvtc_core::relationships::RelationshipState::Established - }) - .unwrap_or(false); - - if is_mediator || has_relationship { - // Send pong to the authenticated sender, setting `from` to our - // listener's DID so the recipient can identify us. - let our_listener_did = didcomm_service - .listener_did(&listener_id) - .await - .unwrap_or_else(|| config.persona_did().to_string()); - if let Ok(pong_msg) = - build_trust_pong(&our_listener_did, sender, &message_id) - && let Err(e) = didcomm::send_message_via( - &didcomm_service, - &pong_msg, - &listener_id, - sender, - ) - .await - { - state.main_page.log_error("Failed to send pong", &e); - } - let ping_display = resolve_did_to_display(&config, sender); - state.main_page.log_detailed( - format!("Ping from {ping_display} — pong sent"), - format!( - "Trust-Ping Received\n\ - ───────────────────\n\ - From (display): {ping_display}\n\ - From (DID): {sender}\n\ - Listener: {listener_id}\n\ - Response: pong sent", - ), - ); - } else { - state.main_page.log_detailed( - format!( - "Ping from {} — ignored", - resolve_did_to_display(&config, sender) - ), - format!( - "Trust-Ping Rejected\n\ - ───────────────────\n\ - From (DID): {sender}\n\ - Reason: {}", - if from.is_some() { - "no established relationship" - } else { - "the transport authenticated no sender" - }, - ), - ); - } - } - didcomm::DIDCommEvent::TrustPongReceived { from } => { - debug!(from = ?from, "TrustPongReceived event"); - let sender_did = from.as_deref().unwrap_or(""); - // `from` is the sender the transport authenticated. - // A pong with none is logged as such rather than - // credited to whoever we pinged last: it closes no - // ping task either (see the dispatcher). - let sender_display = if sender_did.is_empty() { - "an unauthenticated sender".to_string() - } else { - resolve_did_to_display(&config, sender_did) - }; - let ms = ping_sent_at - .take() - .map(|sent_at| sent_at.elapsed().as_millis()); - let latency_str = ms - .map(|v| format!(" ({v}ms)")) - .unwrap_or_default(); - // The answer belongs where the ping was sent, not only - // in the log (#584). - if !sender_did.is_empty() { - state.main_page.content_panel.relationships.status_message = - Some(Status::success(format!( - "Pong from {sender_display}{latency_str} — they are reachable." - ))); - } - state.main_page.log_detailed( - format!("Pong from {sender_display}{latency_str}"), - format!( - "Trust-Pong Received\n\ - ───────────────────\n\ - From (display): {sender_display}\n\ - From (DID): {sender_did}\n\ - Latency: {}", - ms.map(|v| format!("{v}ms")).unwrap_or_else(|| "n/a".into()), - ), - ); - } - } - }, - // Background-dispatch completions (R13). A network-bound action - // that was spawned off the loop (e.g. the mediator reconnect) - // delivers its result here; the outcome is applied on this thread - // (the single mutator) and the domain's busy-flag is cleared. - // Because this is just another select arm, nav actions, `q`/Exit, - // and inbound DIDComm events are all serviced *while* the spawned - // I/O is still pending. - Some(outcome) = dispatch_rx.recv() => { - let after = background_dispatch::apply_outcome( - &mut state, - &mut config, - &mut save, - &mut in_flight, - outcome, - ); - // A VTA answer or a failed send may call for the Rooms panel's next - // step, and frees the lane it waits on. - rooms_actions::advance(&mut rooms_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - vta: admin_vta.as_ref(), - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - }) - .await; - // The same for the Elections panel. - elections_actions::advance(&mut elections_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - vta: admin_vta.as_ref(), - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - }) - .await; - let settled = settle_after_apply( - after, &mut state, &mut config, &tdk, self.profile.as_str(), - ) - .await; - match settled { - // A community we have just left still holds a messaging - // session. The session manager lives here, not in the - // shared apply path, so the outcome reports the teardown - // and this arm performs it. Local work — removing a - // listener — not a network call. - AfterSettle::Deregister(vtc, persona) => { - deregister_inactive_community( - &mut session_manager, - &didcomm_service, - &config, - &mut state, - &vtc, - persona, - ) - .await; - state.main_page.sync_from_config(&config); - } - // A persona minted while running has no listener yet - // (startup builds one per identity; this path does not). - // Bring it up so it can vet/apply/join without a restart. - // It has no community, so — like a community-less identity - // at startup — it gets a listener but no session-manager - // entry (that is per (persona, community)). - AfterSettle::PersonaMinted(persona_id) => { - install_persona_listener(&didcomm_service, &tdk, &config, persona_id, &mut state) - .await; - } - AfterSettle::HiddenVetting(outcome) => { - let mut ctx = runtime_actions::ActionCtx { - state: &mut state, - config: &mut config, - save: &mut save, - in_flight: &mut in_flight, - dispatch_tx: &dispatch_tx, - tdk: &tdk, - admin_vta: admin_vta.as_ref(), - didcomm_service: &didcomm_service, - session_manager: &mut session_manager, - ping_sent_at: &mut ping_sent_at, - state_tx: &self.state_tx, - profile: self.profile.as_str(), - witness_router: &witness_router, - }; - vetting_actions::apply_pcs_outcome(&mut ctx, *outcome).await; - } - AfterSettle::Nothing => {} - } - // A refresh asked for while the vault was busy is re-issued - // here, now that the domain is free — see `vic_refresh_queued`. - if state.main_page.content_panel.vta.vic_refresh_queued { - state.main_page.content_panel.vta.vic_refresh_queued = false; - spawn_vic_refresh(&dispatch_tx, &mut in_flight, &mut state, admin_vta.as_ref()); - } - // Same rule for the identity pane: a write that just landed - // asked for the listing it invalidated, and could not start - // it while its own outcome held the domain. - if state.main_page.content_panel.identity.refresh_queued { - state.main_page.content_panel.identity.refresh_queued = false; - spawn_persona_effect( - &dispatch_tx, - &mut in_flight, - &mut state, - admin_vta.as_ref(), - persona_actions::PersonaEffect::Read, - ); - } - }, - // D13 presence: another install using this account, or a - // report that we could not tell. The task never touches - // `State` — everything lands here, in the single mutator. - Some(report) = presence_rx.recv() => { - match report { - device_presence::PresenceReport::Registered { device_id } => { - debug!(%device_id, "this install is registered with the VTA"); - state.self_device_id = Some(device_id); - } - device_presence::PresenceReport::NewSiblings(siblings) => { - if let Some(warning) = - openvtc_core::devices::sibling_warning(&siblings) - { - state.main_page.log_detailed( - format!("WARNING: {warning}"), - siblings - .iter() - .map(|d| { - format!( - "{} — last seen {}", - d.label(), - d.last_seen_at.as_deref().unwrap_or("unknown") - ) - }) - .collect::>() - .join("\n"), - ); - } - state.live_siblings = siblings; - let _ = self.state_tx.send(state.clone()); - } - device_presence::PresenceReport::Failing { - what, - reason, - replies_not_arriving, - } => { - // Shown in the panel (the task has already logged it - // at WARN), so an absence of sibling warnings is not - // mistaken for an absence of siblings. - // Logged once per failing spell, not every interval. - let problem = format!("{} failed: {reason}", what.label()); - let receive = &mut state.main_page.content_panel.vta.receive; - let first = receive.presence_problem.is_none(); - receive.presence_problem = Some(problem.clone()); - if first { - state.main_page.log(format!("WARNING: {problem}")); - } - // The session's replies have stopped: act on it now, - // not at the next health tick. - if replies_not_arriving { - vta_supervisor.check(admin_vta.as_ref(), &mut state.main_page); - } - } - device_presence::PresenceReport::Recovered => { - state.main_page.content_panel.vta.receive.presence_problem = None; - } - } - }, - // The admin session's receive leg: read its health (no I/O) for - // the panel, and start a rebuild when one is due. - _ = vta_supervisor.tick.tick() => { - vta_supervisor.check(admin_vta.as_ref(), &mut state.main_page); - }, - // A rebuild of the stalled admin session landed. Swap the new - // client in everywhere the session is held: `admin_vta` (which - // every later operation borrows or clones from) and the - // presence task's watch. - Some(outcome) = vta_supervisor.done_rx.recv() => { - if let Some(client) = vta_supervisor.land(outcome, &mut state.main_page) { - // Collection claims are made on the live session too. - didcomm_service.set_collection_vta(Some(client.clone())); - admin_vta = Some(client); - } - }, - // Lifecycle log messages from the Messaging - Some(event) = lifecycle_log_rx.recv() => { - // Formatted here, not in the logger task: naming a listener - // needs `config`, which only this thread holds. - let line = format_lifecycle_log(&config, &event); - match line.detail { - Some(detail) => state.main_page.log_detailed(line.summary, detail), - None => state.main_page.log(line.summary), - } - }, - // Typed listener lifecycle events → drive the connection status - // asynchronously (phase 2). The persona listener connecting flips - // the status to Connected; a disconnect drops back to Connecting - // while the service auto-reconnects. - ev = listener_events.recv() => { - if let Ok(ev) = ev { - // Route persona-listener lifecycle through the session - // manager (D11/D15), which holds per-session status; a - // disconnect carrying an error is recorded as that one - // session failing (isolated — others are untouched). The - // SDK keeps retrying per its restart policy, so a restart - // or clean drop is "not connected" until the next connect. - // Events for R-DID listeners (not persona sessions) match - // nothing and are ignored. - // Every listener's state, session or not, for the - // indicator's no-community reading. - let online = &mut state.connection.online_listeners; - let listener_changed = match &ev { - didcomm::ListenerStatus::Connected { listener_id } => { - online.insert(listener_id.clone()) - } - didcomm::ListenerStatus::Disconnected { listener_id, .. } => { - online.remove(listener_id) - } - }; - // A listener that has just come up can carry what could not be sent - // while it was down: re-ask the communities' modes now. - if matches!(ev, didcomm::ListenerStatus::Connected { .. }) { - config.private.vetting.listener_connected(); - } - let changed = listener_changed | match ev { - didcomm::ListenerStatus::Connected { listener_id } => { - session_manager.mark_connected(&listener_id) - } - didcomm::ListenerStatus::Disconnected { listener_id, error } => { - match error { - Some(e) => session_manager.mark_failed(&listener_id, e), - None => session_manager.mark_disconnected(&listener_id), - } - } - }; - // Derive the global connection indicator from the - // aggregate of all persona-sessions (a per-community - // status panel is future UI work). - if changed { - apply_session_aggregate(&session_manager, &mut state); - } - } - }, - _ = session_reconcile_tick.tick() => { - // The loop tail broadcasts `state`, so a change here reaches - // the UI without an explicit send. - reconcile_sessions(&mut session_manager, &didcomm_service, &mut state); - } - // Coalesced-save debounce (R11). Fires when the debounce window - // since the first dirty mark of a burst elapses. Builds an owned - // snapshot on this (the single mutator) thread and runs the heavy - // serialize+encrypt+keyring+card I/O on a blocking thread so the - // loop stays responsive. At most one save is in flight; a mark - // that lands while a save runs is re-scheduled on completion. - // When nothing is scheduled the arm parks forever (no busy-wait). - _ = capabilities_sweep.tick() => { - // The Repos panel: reply windows, and polling a forge-account - // link no faster than the spec's five seconds. - repos_actions::tick(&mut repos_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - }) - .await; - // The Rooms panel: reply windows, and a step left on a busy lane. - rooms_actions::tick(&mut rooms_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - vta: admin_vta.as_ref(), - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - }) - .await; - // The Elections panel: the same. - elections_actions::tick(&mut elections_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - vta: admin_vta.as_ref(), - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - }) - .await; - // Questions to communities have a reply window too (R1.2). - vetting_actions::expire_queries(&mut state, &mut config, chrono::Utc::now()); - // A ticket whose question about its community's mode went unanswered, or - // could not be sent, ends here, saying which. - vetting_actions::settle_pending_tickets( - &mut state, - &mut config, - &mut save, - chrono::Utc::now(), - ); - // Hidden-vetting work for the VTA — answers a community or a vetter sent, - // an application that needs its engine — goes as soon as it arises, - // backed off while the VTA cannot be reached (R1.4). - if vetting_actions::pcs_work_due(&config, chrono::Utc::now()) { - let mut ctx = runtime_actions::ActionCtx { - state: &mut state, - config: &mut config, - save: &mut save, - in_flight: &mut in_flight, - dispatch_tx: &dispatch_tx, - tdk: &tdk, - admin_vta: admin_vta.as_ref(), - didcomm_service: &didcomm_service, - session_manager: &mut session_manager, - ping_sent_at: &mut ping_sent_at, - state_tx: &self.state_tx, - profile: self.profile.as_str(), - witness_router: &witness_router, - }; - vetting_actions::start_pcs_work(&mut ctx, Vec::new()); - } - // A request reached the desk: re-read what its community - // requires now, before a session opens on a stale answer. - if std::mem::take(&mut config.private.vetting.vetter_refresh_due) { - let mut ctx = runtime_actions::ActionCtx { - state: &mut state, - config: &mut config, - save: &mut save, - in_flight: &mut in_flight, - dispatch_tx: &dispatch_tx, - tdk: &tdk, - admin_vta: admin_vta.as_ref(), - didcomm_service: &didcomm_service, - session_manager: &mut session_manager, - ping_sent_at: &mut ping_sent_at, - state_tx: &self.state_tx, - profile: self.profile.as_str(), - witness_router: &witness_router, - }; - vetting_actions::dispatch( - &mut ctx, - actions::VettingAction::RefreshVetterSide, - ) - .await; - } else if vetting_actions::desk_open(&state) { - // While the desk is open, how each community vets is kept no older - // than its TTL — asked at most once per re-ask interval (R1.4). - let mut ctx = runtime_actions::ActionCtx { - state: &mut state, - config: &mut config, - save: &mut save, - in_flight: &mut in_flight, - dispatch_tx: &dispatch_tx, - tdk: &tdk, - admin_vta: admin_vta.as_ref(), - didcomm_service: &didcomm_service, - session_manager: &mut session_manager, - ping_sent_at: &mut ping_sent_at, - state_tx: &self.state_tx, - profile: self.profile.as_str(), - witness_router: &witness_router, - }; - vetting_actions::refresh_stale_modes(&mut ctx).await; - } - if awaiting_requirements - .as_ref() - .is_some_and(|a| a.asked_at.elapsed() >= join_flow::REQUIREMENTS_WAIT) - && let Some(awaiting) = awaiting_requirements.take() - { - config.private.vetting.forget_query(&awaiting.document_id); - join_entry = Some(join_flow::JoinEntry::Resume { - vtc_did: awaiting.vtc_did, - outcome: join_flow::RequirementsOutcome::Unanswered(format!( - "no answer within {} seconds — its service may be offline", - join_flow::REQUIREMENTS_WAIT.as_secs() - )), - }); - } - // R1.2 / R6.4: a challenge that never comes is the community - // not answering — our side sent the question, so it is not - // our mediator, and nothing refused it. - if awaiting_challenge - .as_ref() - .is_some_and(|a| a.asked_at.elapsed() >= join_flow::CHALLENGE_WAIT) - && let Some(awaiting) = awaiting_challenge.take() - { - config.private.vetting.forget_query(&awaiting.document_id); - join_entry = Some(join_flow::JoinEntry::Challenge { - awaiting, - outcome: join_flow::ChallengeOutcome::Unanswered(format!( - "the request was sent, but no answer came within {} seconds — \ - its service may be offline or unreachable", - join_flow::CHALLENGE_WAIT.as_secs() - )), - }); - } - // A wallet session is polled on this tick, and given up on - // when the community stops answering (R1.2). - if let Some(awaiting) = awaiting_wallet.as_mut() - && let Some(entry) = join_flow::wallet_tick( - awaiting, - &mut config, - &tdk, - Some(&didcomm_service), - ) - .await - { - awaiting_wallet = None; - join_entry = Some(entry); - } - // A ping has a reply window too (#584): past it, say so in - // the panel that sent it and expire the outbound task, so - // the Inbox does not keep a ping nobody will answer. - if ping_sent_at.is_some_and(|t| t.elapsed() > PING_REPLY_WINDOW) { - ping_sent_at = None; - let cutoff = chrono::Utc::now() - - chrono::Duration::from_std(PING_REPLY_WINDOW).unwrap_or_default(); - let before = config.private.tasks.tasks.len(); - config.private.tasks.tasks.retain(|_, t| { - !(matches!(t.type_, openvtc_core::tasks::TaskType::TrustPing { .. }) - && t.created < cutoff) - }); - if config.private.tasks.tasks.len() != before { - save.mark_dirty(); - state.main_page.sync_from_config(&config); - } - let line = format!( - "No reply to the ping after {} s — they may be offline, or not \ - listening on that DID.", - PING_REPLY_WINDOW.as_secs() - ); - state.main_page.content_panel.relationships.status_message = - Some(Status::warning(line.clone())); - state.main_page.log_warning(line); - } - // R4.3: a capability query has a defined reply window. The - // send was fire-and-forget; if no correlated reply arrived, - // fail the view closed with a distinct, actionable message. - if let Some(view) = state.main_page.content_panel.capabilities.view.as_mut() - && view.pending_thid.is_some() - && view.sent_at.is_some_and(|t| t.elapsed() > std::time::Duration::from_secs(30)) - { - view.pending_thid = None; - view.sent_at = None; - if matches!(view.phase, crate::state_handler::main_page::content::CapabilitiesPhase::Loading) { - view.phase = crate::state_handler::main_page::content::CapabilitiesPhase::Failed( - "no reply within 30s — the community's governance host may be offline".to_string(), - ); - } else { - view.status_message = Some(Status::error( - "no reply to the change within 30s — refresh (r) to re-check".to_string(), - )); - } - } - } - _ = pending_expiry_tick.tick() => { - // The vetting book's own clock: forget cards past their - // retention, move finished desk requests to the record of - // who we vetted, close sessions nobody answered. Fires at - // start-up too, so a card from a previous run does not wait - // an hour to go. - let mut vetting_changed = config.private.vetting.prune(chrono::Utc::now()); - // Applications whose persona this account no longer holds: - // left by a deletion before deletions took them along, and - // unusable — their DID is gone. Judged by the persona - // record, not the loaded identity, which can be missing for - // a run without the persona being gone. - let personas = &config.account.personas; - let applications = &mut config.private.vetting.applications; - let before = applications.len(); - applications.retain(|a| personas.contains_key(&a.persona)); - let orphaned = before - applications.len(); - if orphaned > 0 { - vetting_changed = true; - state.main_page.log(format!( - "Dropped {orphaned} vetting application{} made as an identity that no \ - longer exists.", - if orphaned == 1 { "" } else { "s" } - )); - } - // Applications whose join is done and that hold nothing a - // rejoin could present again (see `retire_joined`). - let members: std::collections::HashSet<(String, openvtc_core::config::account::PersonaId)> = - config - .account - .memberships() - .filter(|m| m.status.is_active()) - .map(|m| (m.vtc_did.clone(), m.persona_ref)) - .collect(); - let retired = config.private.vetting.retire_joined( - |a| members.contains(&(a.community.clone(), a.persona)), - chrono::Utc::now(), - ); - if retired > 0 { - vetting_changed = true; - state.main_page.log(format!( - "Cleared {retired} finished vetting application{} — joined, and its \ - statements have expired.", - if retired == 1 { "" } else { "s" } - )); - } - if vetting_changed { - save.mark_dirty(); - state.main_page.sync_from_config(&config); - } - // The vetter side runs on its own schedule — the - // hidden-vetting tick below, at least hourly. - // R-B-7: expire Pending joins unanswered for 7 days — or for - // the `decisionSla` a vetting community publishes — raising - // actions-required, and tear down each one's now-dead session - // (R-S-3). Records are retained read-only (R-S-1). - let vetting = &config.private.vetting; - let default_timeout = chrono::TimeDelta::days( - openvtc_core::config::account::PENDING_TIMEOUT_DAYS, - ); - let timeouts: std::collections::HashMap<_, _> = config - .account - .memberships() - .filter_map(|c| { - vetting - .decision_sla(&c.vtc_did, c.persona_ref) - .map(|sla| ((c.vtc_did.clone(), c.persona_ref), sla)) - }) - .collect(); - let expired = config.account.expire_stale_pending_with(chrono::Utc::now(), |c| { - timeouts - .get(&(c.vtc_did.clone(), c.persona_ref)) - .copied() - .unwrap_or(default_timeout) - }); - if !expired.is_empty() { - save.mark_dirty(); - for (vtc, persona) in &expired { - deregister_inactive_community( - &mut session_manager, - &didcomm_service, - &config, - &mut state, - vtc, - *persona, - ) - .await; - } - state.main_page.sync_from_config(&config); - state.main_page.log(format!( - "{} pending join{} expired (no decision within the time allowed).", - expired.len(), - if expired.len() == 1 { "" } else { "s" }, - )); - let _ = self.state_tx.send(state.clone()); - } - - // The other thing that lapses by the clock: our own `vetter` - // role credential. Nothing arrives to announce it — the first - // sign is an applicant's request refused at their end — so - // this sweep is where it gets noticed. Said once per grant - // per state; see `take_grant_warnings`. - let warnings = config.private.vetting.take_grant_warnings(chrono::Utc::now()); - if !warnings.is_empty() { - for warning in &warnings { - let community = crate::state_handler::community_label( - &config, - &warning.community, - None, - 64, - ); - state.main_page.log(if warning.expired { - format!( - "Your vetter credential from {community} expired on {}. \ - Requests to you there are refused until it is reissued — \ - ask for it again from your vetting desk (g).", - warning.valid_until.format("%Y-%m-%d"), - ) - } else { - format!( - "Your vetter credential from {community} expires on {}. \ - Ask for it again from your vetting desk (g).", - warning.valid_until.format("%Y-%m-%d"), - ) - }); - let id = warning.id(); - config.private.tasks.tasks.insert( - id.clone().into(), - openvtc_core::tasks::Task { - id: std::sync::Arc::new(id), - type_: openvtc_core::tasks::TaskType::VetterGrantExpiring { - community: std::sync::Arc::new(community), - expired: warning.expired, - valid_until: warning - .valid_until - .format("%Y-%m-%d") - .to_string(), - }, - created: chrono::Utc::now(), - our_persona: Some(warning.persona), - }, - ); - } - save.mark_dirty(); - state.main_page.sync_from_config(&config); - let _ = self.state_tx.send(state.clone()); - } - }, - _ = join_status_tick.tick() => { - // Kept messages still waiting for room (the request rate, - // say) are tried again even if no check is running. - if !restore_backlog.is_empty() - && restore_kept( - &mut verifier, - &mut restore_backlog, - &mut config, - &mut state, - &tdk, - &didcomm_service, - &mut seen_messages, - ) - .await - { - save.mark_dirty(); - } - // Ask each community about a join it has not resolved — - // every Pending join, including one that never got any - // reply, which is asked id-less; see `openvtc_core::join::status_poll`. - // - // Spawned detached rather than through `background_dispatch`: - // there is no outcome to apply on the loop thread (the reply - // arrives on the persona's listener and goes through inbound - // dispatch like any other), and the pacer already prevents - // pile-up by marking a record polled before the send. - let candidates = config.account.pollable_pending(chrono::Utc::now()); - if !candidates.is_empty() - && let Some(atm) = tdk.atm.clone() - { - let due = join_status_pacer.due(candidates, std::time::Instant::now()); - let polls = openvtc_core::join::status_poll::build(&config, &tdk, due).await; - if !polls.is_empty() { - tokio::spawn(openvtc_core::join::status_poll::send_all(atm, polls)); - } - } - }, - _ = hidden_vetting_tick.tick() => { - let now = chrono::Utc::now(); - // A question left unanswered since the last pass is retried on its - // backoff, not at the next scheduled pass (R1.4). - hidden_vetting_pacer.pull_forward(&config.private.vetting, now); - if hidden_vetting_pacer.due(now) { - let mut ctx = runtime_actions::ActionCtx { - state: &mut state, - config: &mut config, - save: &mut save, - in_flight: &mut in_flight, - dispatch_tx: &dispatch_tx, - tdk: &tdk, - admin_vta: admin_vta.as_ref(), - didcomm_service: &didcomm_service, - session_manager: &mut session_manager, - ping_sent_at: &mut ping_sent_at, - state_tx: &self.state_tx, - profile: self.profile.as_str(), - witness_router: &witness_router, - }; - vetting_actions::dispatch( - &mut ctx, - actions::VettingAction::RefreshVetterSide, - ) - .await; - let next = hidden_vetting_pacer.schedule( - &config.private.vetting, - chrono::Utc::now(), - &mut rand::thread_rng(), - ); - debug!(%next, "next hidden-vetting pass"); - } - }, - _ = community_profile_tick.tick() => { - // Ask each Active community, once, for its profile so the - // new-relationship form can default to the community's - // declared identifier form (issue #241). Spawned detached, - // like the status poll: the reply arrives on the persona's - // listener and folds in through inbound dispatch, and the - // pacer marks each community asked before the send so a tick - // never re-asks one. - if let Some(atm) = tdk.atm.clone() { - let mut asks = Vec::new(); - for ask in community_profile_pacer.due(&config) { - // Local key lookup; an ask whose key cannot be - // read is dropped rather than sent unsigned. - if let Ok(keys) = config.get_persona_keys_for(ask.persona, &tdk).await { - asks.push(ask.with_signer(keys.authentication.secret.clone())); - } - } - if !asks.is_empty() { - tokio::spawn(community_profile_poll::send_all(atm, asks)); - } - } - }, - _ = agent_name_tick.tick() => { - // Collect DIDs whose agent name is uncached or stale and, if - // any, resolve them in one background job (read-only I/O). - // Results are folded into the persisted cache on the loop - // thread by `apply_outcome`. The busy-guard drops the tick if - // a prior sweep is still running, so ticks never pile up. - let targets = config.agent_name_refresh_targets(chrono::Utc::now()); - if !targets.is_empty() - && in_flight.try_begin(background_dispatch::DispatchDomain::AgentName) - { - let resolver = tdk.did_resolver().clone(); - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::AgentName, - async move { - let results = - agent_name_refresh::resolve_batch(resolver, targets).await; - background_dispatch::DispatchOutcome::AgentName(results) - }, - ); - } - - // Ask what each persona presents. Shares this tick for the - // same reason as the transport probe — the loop gains no - // extra timer — and re-asks every sweep rather than on a - // TTL, because the answer is the holder's own decision and - // they may have changed it from `pnm` a moment ago. The - // busy-guard keeps a slow agent from stacking sweeps. - dispatch_binding_refresh(&config, admin_vta.as_ref(), &mut in_flight, &dispatch_tx); - - // Register the contexts memberships name. Joins before - // per-community contexts recorded an id without creating it; - // faces worn there worked only because nothing checked. - let top_context_id = config.account.top_context_id.clone(); - let unchecked: Vec = config - .account - .memberships() - .map(|c| c.sub_context_id.clone()) - .filter(|id| { - openvtc_core::config::community_context::is_sub_context( - id, - &top_context_id, - ) && !contexts_checked.contains(id) - }) - .collect::>() - .into_iter() - .collect(); - if !unchecked.is_empty() - && let Some(client) = admin_vta.as_ref() - && in_flight - .try_begin(background_dispatch::DispatchDomain::CommunityContexts) - { - contexts_checked.extend(unchecked.iter().cloned()); - let client = client.clone(); - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::CommunityContexts, - async move { - let mut results = Vec::with_capacity(unchecked.len()); - for context_id in unchecked { - let name = openvtc_core::config::context_path::parse_sub_context_id( - &context_id, - ) - .map_or(context_id.clone(), |(_, slug)| slug.to_string()); - let result = - openvtc_core::config::community_context::ensure_context( - &client, - &top_context_id, - &context_id, - &name, - ) - .await - .map_err(|e| e.to_string()); - results.push((context_id, result)); - } - background_dispatch::DispatchOutcome::CommunityContexts(results) - }, - ); - } - - // Read each membership context's device grants, once a run, - // after registration has made sure the contexts exist — a - // grant listing for a context not yet created would read as - // a failure. - if !grants_swept - && !in_flight.is_busy(background_dispatch::DispatchDomain::CommunityContexts) - && let Some(client) = admin_vta.as_ref() - { - let targets = community_context_actions::sweep_targets(&config); - if !targets.is_empty() - && in_flight - .try_begin(background_dispatch::DispatchDomain::DeviceGrantSweep) - { - grants_swept = true; - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::DeviceGrantSweep, - community_context_actions::sweep(client.clone(), targets), - ); - } - } - - // Probe what transports the VTA advertises, for the VTA - // panel. Runs on this tick rather than its own so the loop - // gains no extra timer. Re-probed only while the answer is - // still missing or failed — once a VTA has answered, its - // advertised services are stable for the session, so a - // healthy setup probes exactly once per launch (R1.4). - let needs_probe = state - .main_page - .content_panel - .vta - .transports - .advertised - .as_ref() - .is_none_or(|a| a.error.is_some()); - if needs_probe - && let openvtc_core::config::KeyBackend::Vta { vta_did, .. } = - &config.key_backend - && !vta_did.is_empty() - && in_flight.try_begin(background_dispatch::DispatchDomain::VtaTransports) - { - let vta_did = vta_did.clone(); - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::VtaTransports, - async move { - background_dispatch::DispatchOutcome::VtaTransports( - vta_transports::probe(vta_did).await, - ) - }, - ); - } - }, - // A background TSP relationship write (an invite recorded by the - // inbound dispatcher, a relationship formed by a send) changed the - // durable store. Snapshot it into the config and mark it dirty; the - // deadline arm below persists it on the same coalesced schedule as - // every other mutation. `Notify` holds one permit, so a write that - // lands between iterations is not missed, and a burst collapses to - // one snapshot — which is exactly the coalescing we want. - _ = tsp_dirty.notified() => { - config.private.tsp_relationships = - didcomm_service.tsp_store().snapshot().await; - save.mark_dirty(); - // A relationship changed, so the pane's view of it is stale. - // Recompute here — the same event that persists it also - // refreshes what the user sees. - let rows = - compute_tsp_relationship_rows(&config, &didcomm_service.tsp_store()).await; - state.main_page.content_panel.tsp_relationships.rows = rows.into(); - }, - _ = save.wait_deadline() => { - match save.take_for_save(|| config.clone_for_save()) { - Ok(Ok(pending)) => { - let done_tx = save_done_tx.clone(); - tokio::task::spawn_blocking(move || { - let result = pending.run().map_err(|e| format!("{e}")); - let _ = done_tx.send(result); - }); - } - // Snapshot failed: surface like an inline save failure. - // The scheduler kept the config dirty + re-armed, so it - // retries on the next deadline. - Ok(Err(e)) => { - state.main_page.log_error("Failed to save config", &e); - } - // NotDirty / InFlight — nothing to start right now. - Err(_) => {} - } - }, - // A backgrounded coalesced save finished (R11). Clear the - // in-flight flag and re-arm if the config was dirtied again. A - // failed save is surfaced exactly as the old inline `save_config` - // failure did (status + log) and left dirty for retry. - Some(result) = save_done_rx.recv() => { - match &result { - // What was just saved may be news for the VTA. - Ok(()) => vta_sync.request(), - Err(reason) => { - state - .main_page - .log_error("Failed to save config", &anyhow::anyhow!("{reason}")); - } - } - save.finish(result.is_ok()); - }, - _ = vta_sync_tick.tick() => { - if let Some(client) = admin_vta.as_ref() - && !config.account.top_context_id.is_empty() - && vta_sync.should_start(tokio::time::Instant::now()) - { - let context_id = config.account.top_context_id.clone(); - config.private.vta_sync.bind_context(&context_id); - let view = openvtc_core::vta_sync::SyncView { - account: &config.account, - private: &config.private, - key_info: &config.key_info, - }; - let out = openvtc_core::vta_sync::outgoing(&view, &config.private.vta_sync); - let since = config.private.vta_sync.watermark; - let client = client.clone(); - let tx = vta_sync_tx.clone(); - tokio::spawn(async move { - let ex = - openvtc_core::vta_sync::exchange(&client, &context_id, since, out) - .await; - let _ = tx.send(ex); - }); - } - }, - Some(ex) = vta_sync_rx.recv() => { - let mut report = openvtc_core::vta_sync::SyncReport::default(); - let mut sync_state = std::mem::take(&mut config.private.vta_sync); - let lost_race = { - let mut parts = openvtc_core::vta_sync::SyncParts { - account: &mut config.account, - private: &mut config.private, - key_info: &mut config.key_info, - }; - openvtc_core::vta_sync::absorb( - &mut parts, - &mut sync_state, - ex, - openvtc_core::vta_sync::ApplyMode::Live, - &mut report, - ) - }; - config.private.vta_sync = sync_state; - let error = report.error.take(); - for line in report.log_lines() { - state.main_page.log(line); - } - if vta_sync.finished(tokio::time::Instant::now(), error.as_ref()) - && let Some(e) = &error - { - state.main_page.log(e.explain()); - } - if lost_race { - // The merged record goes up on the next cycle. - vta_sync.request(); - } - if !report.changed.is_empty() { - state.main_page.sync_from_config(&config); - } - if report.touched() { - save.mark_dirty(); - } - }, - // (keepalive removed — WebSocket-level pings handle connectivity) - // Catch and handle interrupt signal to gracefully shutdown - Ok(interrupted) = interrupt_rx.recv() => { - break interrupted; - } - } - // Enter the join flow: from the Communities panel, or back from waiting - // on a community's requirements. Here rather than in an arm so every - // way in is handled the same. - if let Some(entry) = join_entry.take() { - awaiting_requirements = None; - awaiting_challenge = None; - awaiting_wallet = None; - match self - .join_flow( - &mut action_rx, - &mut interrupt_rx, - &mut state, - &tdk, - &mut config, - admin_vta.as_ref(), - self.profile.as_str(), - Some(&didcomm_service), - entry, - ) - .await - { - Ok(join_flow::JoinExit::Returned(joined)) => { - state.active_page = state::ActivePage::Main; - // R-B-5 / D11: bring the new community's session up live - // so the VTC's async receipt is received now, not only - // after a restart. - if let Some(joined) = joined { - register_joined_session( - &mut session_manager, - &didcomm_service, - &tdk, - &config, - joined, - &mut state, - ) - .await; - // What the new community's persona wears, now rather than at the - // next sweep — until then its row could only say it had not - // been read. - dispatch_binding_refresh( - &config, - admin_vta.as_ref(), - &mut in_flight, - &dispatch_tx, - ); - } - } - Ok(join_flow::JoinExit::AwaitRequirements(awaiting)) => { - awaiting_requirements = Some(awaiting); - } - Ok(join_flow::JoinExit::AwaitChallenge(awaiting)) => { - awaiting_challenge = Some(awaiting); - } - Ok(join_flow::JoinExit::AwaitWallet(awaiting)) => { - awaiting_wallet = Some(awaiting); - } - // F10 on a join page lands here. Like every other exit it - // must tell the UI to stop: the UI leaves its loop only on - // an interrupt, so breaking without one stopped this loop - // and left the screen up reading keys nobody acted on — - // the "join page freezes, even F10 and Esc" report. - Ok(join_flow::JoinExit::Exit(interrupted)) => { - if let Err(e) = terminator.terminate(interrupted.clone()) { - debug!("Failed to send terminate signal: {e}"); - } - break interrupted; - } - Err(e) => { - state.main_page.log_error("Join flow failed", &e); - state.active_page = state::ActivePage::Main; - } - } - } - // Keep the working-context selection valid against any account change - // this iteration applied (join/leave/status transition) before the UI - // re-renders from the broadcast (D10 / R-C-6), then point the runtime - // active identity at the selected community's persona so all - // identity-derived reads scope to the working community. When the - // working persona actually changes (e.g. the active community left and - // the default shifted), refilter the community-scoped panels so the UI - // reflects the new context this frame. - state.reconcile_selected_community_among( - &config.account, - Some(&config.identities.keys().copied().collect()), - ); - let active_persona = state - .selected_community - .as_ref() - .map(|(_, persona)| *persona); - if active_persona != config.active_persona { - config.set_active_persona(active_persona); - state.main_page.sync_from_config(&config); - } - let _ = self.state_tx.send(state.clone()); - }; - - // R11: if a backgrounded coalesced save was still running when the loop - // broke, wait for it to complete before the force-flush below. A - // `spawn_blocking` task is NOT cancelled when its `JoinHandle` is dropped, - // so that save is still live; running the shutdown save concurrently would - // mean two `Config::save`s racing the same (non-atomic) file + keyring - // writes. Draining the completion channel serialises shutdown after it. - // After `finish`, `needs_flush()` is only still true if the config was - // dirtied *after* the in-flight save's snapshot — exactly what the - // force-flush must persist. - if save.in_flight() - && let Some(result) = save_done_rx.recv().await - { - save.finish(result.is_ok()); - } - - // R11 force-flush: persist the latest state before tearing down, so - // coalescing never loses the final mutation on Exit/interrupt. Runs a - // direct blocking save (the loop has broken; there is no runtime arm left - // to schedule against). `needs_flush` is true when the config is dirty or - // a background save was still in flight when the loop broke. - if save.needs_flush() { - match save.snapshot_now(&config) { - Ok(pending) => { - if let Err(e) = pending.run() { - state - .main_page - .log_error("Failed to save config on exit", &e); - } - } - Err(e) => { - state - .main_page - .log_error("Failed to snapshot config on exit", &e); - } - } - } - - // Shut down the DIDComm service gracefully - shutdown_token.cancel(); - didcomm_service.shutdown().await; - - // Close the always-on admin VTA session. - if let Some(c) = admin_vta { - c.shutdown().await; - } - - Ok(result) } /// Minimal event loop for when there is no active community / messaging diff --git a/openvtc/src/state_handler/runtime/arms.rs b/openvtc/src/state_handler/runtime/arms.rs new file mode 100644 index 00000000..631b9a20 --- /dev/null +++ b/openvtc/src/state_handler/runtime/arms.rs @@ -0,0 +1,573 @@ +//! The runtime's event arms other than inbound messages and the periodic +//! sweeps: UI actions, dispatch outcomes, presence and the admin-session +//! supervisor, listener lifecycle, the TSP store, saves and VTA account sync. + +use super::*; +use crate::state_handler::*; + +impl Runtime { + /// A UI action. Everything but leaving and the join flow's entry points is + /// `runtime_actions::handle_action`'s. + pub(super) async fn on_action(&mut self, action: Action) -> Flow { + let Runtime { + state, + config, + join_entry, + awaiting_requirements, + awaiting_challenge, + awaiting_wallet, + terminator, + .. + } = self; + match action { + Action::Exit => { + if let Err(e) = terminator.terminate(Interrupted::UserInt) { + debug!("Failed to send terminate signal: {e}"); + } + + return Flow::Exit(Interrupted::UserInt); + } + + Action::UXError(interrupted) => { + // An error has occurred on the UX side + if let Err(e) = terminator.terminate(interrupted.clone()) { + debug!("Failed to send terminate signal: {e}"); + } + + return Flow::Exit(interrupted); + } + + Action::StartJoin => { + // State-B join from the live runtime: reuse the always-on + // admin VTA session. The DIDComm service keeps running in + // the background; the join flow owns the screen until the + // user returns. Entered at the loop's tail, which is also + // where a join waiting on a community's requirements is + // entered again. + *join_entry = Some(join_flow::JoinEntry::Fresh { + hears_replies: true, + }); + } + // The same join, for a community already chosen: `j` on a + // vetting application resumes the join that application was + // made for, without going back for its DID. + Action::StartJoinFor(vtc_did) => { + *join_entry = Some(join_flow::JoinEntry::ForCommunity { + vtc_did, + hears_replies: true, + }); + } + // A join waiting on a community's requirements keeps its screen + // up while this loop hears the answer, so the two keys that + // page offers land here rather than in the join flow. + Action::JoinCancel if awaiting_requirements.is_some() => { + if let Some(awaiting) = awaiting_requirements.take() { + config.private.vetting.forget_query(&awaiting.document_id); + } + state.active_page = state::ActivePage::Main; + } + Action::JoinVettingJoin if awaiting_requirements.is_some() => { + if let Some(awaiting) = awaiting_requirements.take() { + config.private.vetting.forget_query(&awaiting.document_id); + *join_entry = Some(join_flow::JoinEntry::Resume { + vtc_did: awaiting.vtc_did, + outcome: join_flow::RequirementsOutcome::Skipped, + }); + } + } + // Leaving a join parked on its challenge. Nothing was sent + // but the question, and the challenge it brings stands + // unused until the community lets it lapse. + Action::JoinCancel if awaiting_challenge.is_some() => { + if let Some(awaiting) = awaiting_challenge.take() { + config.private.vetting.forget_query(&awaiting.document_id); + } + state.join.processing = false; + state.join.waiting_on_challenge = false; + state.main_page.log( + "Stopped waiting for the community's challenge. Nothing was sent — \ + join again when you are ready.", + ); + state.active_page = state::ActivePage::Main; + } + // Leaving a wallet session. The session lapses on its own; + // the page offers to try again or go on without it. + Action::JoinCancel if awaiting_wallet.is_some() => { + if let Some(query) = awaiting_wallet.take().and_then(|a| a.asking) { + config.private.vetting.forget_query(&query); + } + *join_entry = Some(join_flow::JoinEntry::Wallet { + end: join_flow::WalletEnd::Unfinished( + "You stopped waiting for the wallet. Nothing has been sent to \ + the community but the session request." + .to_string(), + ), + }); + } + // Everything else acts on state and the resources below, + // and lives in `runtime_actions` where a test can call it. + // Only the three arms above need what a loop has and a + // handler cannot be given: the terminator, the action + // receiver, and the ability to `break` with this loop's own + // outcome type. + action => { + if matches!( + runtime_actions::handle_action(&mut self.action_ctx(), action).await, + runtime_actions::Handled::ExitUserInt + ) { + // A confirmed profile wipe. The handler cannot break + // this loop or reach the terminator, so it says so. + if let Err(e) = self.terminator.terminate(Interrupted::UserInt) { + debug!("Failed to send terminate signal: {e}"); + } + return Flow::Exit(Interrupted::UserInt); + } + } + } + Flow::Continue + } + + /// Background-dispatch completions (R13). A network-bound action + /// that was spawned off the loop (e.g. the mediator reconnect) + /// delivers its result here; the outcome is applied on this thread + /// (the single mutator) and the domain's busy-flag is cleared. + /// Because this is just another select arm, nav actions, `q`/Exit, + /// and inbound DIDComm events are all serviced *while* the spawned + /// I/O is still pending. + pub(super) async fn on_dispatch_outcome( + &mut self, + outcome: background_dispatch::DispatchOutcome, + ) { + let Runtime { + state, + config, + tdk, + admin_vta, + didcomm_service, + save, + in_flight, + dispatch_tx, + session_manager, + witness_router, + ping_sent_at, + handler, + .. + } = self; + let after = background_dispatch::apply_outcome(state, config, save, in_flight, outcome); + // A VTA answer or a failed send may call for the Rooms panel's next + // step, and frees the lane it waits on. + rooms_actions::advance(&mut rooms_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + }) + .await; + // The same for the Elections panel. + elections_actions::advance(&mut elections_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + }) + .await; + let settled = settle_after_apply(after, state, config, tdk, handler.profile.as_str()).await; + match settled { + // A community we have just left still holds a messaging + // session. The session manager lives here, not in the + // shared apply path, so the outcome reports the teardown + // and this arm performs it. Local work — removing a + // listener — not a network call. + AfterSettle::Deregister(vtc, persona) => { + deregister_inactive_community( + session_manager, + didcomm_service, + config, + state, + &vtc, + persona, + ) + .await; + state.main_page.sync_from_config(config); + } + // A persona minted while running has no listener yet + // (startup builds one per identity; this path does not). + // Bring it up so it can vet/apply/join without a restart. + // It has no community, so — like a community-less identity + // at startup — it gets a listener but no session-manager + // entry (that is per (persona, community)). + AfterSettle::PersonaMinted(persona_id) => { + install_persona_listener(didcomm_service, tdk, config, persona_id, state).await; + } + AfterSettle::HiddenVetting(outcome) => { + let mut ctx = runtime_actions::ActionCtx { + state, + config, + save, + in_flight, + dispatch_tx, + tdk, + admin_vta: admin_vta.as_ref(), + didcomm_service, + session_manager, + ping_sent_at, + state_tx: &handler.state_tx, + profile: handler.profile.as_str(), + witness_router, + }; + vetting_actions::apply_pcs_outcome(&mut ctx, *outcome).await; + } + AfterSettle::Nothing => {} + } + // A refresh asked for while the vault was busy is re-issued + // here, now that the domain is free — see `vic_refresh_queued`. + if state.main_page.content_panel.vta.vic_refresh_queued { + state.main_page.content_panel.vta.vic_refresh_queued = false; + spawn_vic_refresh(dispatch_tx, in_flight, state, admin_vta.as_ref()); + } + // Same rule for the identity pane: a write that just landed + // asked for the listing it invalidated, and could not start + // it while its own outcome held the domain. + if state.main_page.content_panel.identity.refresh_queued { + state.main_page.content_panel.identity.refresh_queued = false; + spawn_persona_effect( + dispatch_tx, + in_flight, + state, + admin_vta.as_ref(), + persona_actions::PersonaEffect::Read, + ); + } + } + + /// D13 presence: another install using this account, or a + /// report that we could not tell. The task never touches + /// `State` — everything lands here, in the single mutator. + pub(super) async fn on_presence(&mut self, report: device_presence::PresenceReport) { + let Runtime { + state, + admin_vta, + vta_supervisor, + handler, + .. + } = self; + match report { + device_presence::PresenceReport::Registered { device_id } => { + debug!(%device_id, "this install is registered with the VTA"); + state.self_device_id = Some(device_id); + } + device_presence::PresenceReport::NewSiblings(siblings) => { + if let Some(warning) = openvtc_core::devices::sibling_warning(&siblings) { + state.main_page.log_detailed( + format!("WARNING: {warning}"), + siblings + .iter() + .map(|d| { + format!( + "{} — last seen {}", + d.label(), + d.last_seen_at.as_deref().unwrap_or("unknown") + ) + }) + .collect::>() + .join("\n"), + ); + } + state.live_siblings = siblings; + let _ = handler.state_tx.send(state.clone()); + } + device_presence::PresenceReport::Failing { + what, + reason, + replies_not_arriving, + } => { + // Shown in the panel (the task has already logged it + // at WARN), so an absence of sibling warnings is not + // mistaken for an absence of siblings. + // Logged once per failing spell, not every interval. + let problem = format!("{} failed: {reason}", what.label()); + let receive = &mut state.main_page.content_panel.vta.receive; + let first = receive.presence_problem.is_none(); + receive.presence_problem = Some(problem.clone()); + if first { + state.main_page.log(format!("WARNING: {problem}")); + } + // The session's replies have stopped: act on it now, + // not at the next health tick. + if replies_not_arriving { + vta_supervisor.check(admin_vta.as_ref(), &mut state.main_page); + } + } + device_presence::PresenceReport::Recovered => { + state.main_page.content_panel.vta.receive.presence_problem = None; + } + } + } + + /// The admin session's receive leg: read its health (no I/O) for + /// the panel, and start a rebuild when one is due. + pub(super) async fn on_vta_health_tick(&mut self) { + let Runtime { + state, + admin_vta, + vta_supervisor, + .. + } = self; + vta_supervisor.check(admin_vta.as_ref(), &mut state.main_page); + } + + /// A rebuild of the stalled admin session landed. Swap the new + /// client in everywhere the session is held: `admin_vta` (which + /// every later operation borrows or clones from) and the + /// presence task's watch. + pub(super) async fn on_vta_rebuilt(&mut self, outcome: vta_recovery::RebuildOutcome) { + let Runtime { + state, + admin_vta, + didcomm_service, + vta_supervisor, + .. + } = self; + if let Some(client) = vta_supervisor.land(outcome, &mut state.main_page) { + // Collection claims are made on the live session too. + didcomm_service.set_collection_vta(Some(client.clone())); + *admin_vta = Some(client); + } + } + + /// Lifecycle log messages from the Messaging + pub(super) async fn on_lifecycle_log(&mut self, event: didcomm::LifecycleLog) { + let Runtime { state, config, .. } = self; + // Formatted here, not in the logger task: naming a listener + // needs `config`, which only this thread holds. + let line = format_lifecycle_log(config, &event); + match line.detail { + Some(detail) => state.main_page.log_detailed(line.summary, detail), + None => state.main_page.log(line.summary), + } + } + + /// Typed listener lifecycle events → drive the connection status + /// asynchronously (phase 2). The persona listener connecting flips + /// the status to Connected; a disconnect drops back to Connecting + /// while the service auto-reconnects. + pub(super) async fn on_listener_event( + &mut self, + ev: Result, + ) { + let Runtime { + state, + config, + session_manager, + .. + } = self; + if let Ok(ev) = ev { + // Route persona-listener lifecycle through the session + // manager (D11/D15), which holds per-session status; a + // disconnect carrying an error is recorded as that one + // session failing (isolated — others are untouched). The + // SDK keeps retrying per its restart policy, so a restart + // or clean drop is "not connected" until the next connect. + // Events for R-DID listeners (not persona sessions) match + // nothing and are ignored. + // Every listener's state, session or not, for the + // indicator's no-community reading. + let online = &mut state.connection.online_listeners; + let listener_changed = match &ev { + didcomm::ListenerStatus::Connected { listener_id } => { + online.insert(listener_id.clone()) + } + didcomm::ListenerStatus::Disconnected { listener_id, .. } => { + online.remove(listener_id) + } + }; + // A listener that has just come up can carry what could not be sent + // while it was down: re-ask the communities' modes now. + if matches!(ev, didcomm::ListenerStatus::Connected { .. }) { + config.private.vetting.listener_connected(); + } + let changed = listener_changed + | match ev { + didcomm::ListenerStatus::Connected { listener_id } => { + session_manager.mark_connected(&listener_id) + } + didcomm::ListenerStatus::Disconnected { listener_id, error } => match error { + Some(e) => session_manager.mark_failed(&listener_id, e), + None => session_manager.mark_disconnected(&listener_id), + }, + }; + // Derive the global connection indicator from the + // aggregate of all persona-sessions (a per-community + // status panel is future UI work). + if changed { + apply_session_aggregate(session_manager, state); + } + } + } + + pub(super) async fn on_session_reconcile(&mut self) { + let Runtime { + state, + didcomm_service, + session_manager, + .. + } = self; + // The loop tail broadcasts `state`, so a change here reaches + // the UI without an explicit send. + reconcile_sessions(session_manager, didcomm_service, state); + } + + /// A background TSP relationship write (an invite recorded by the + /// inbound dispatcher, a relationship formed by a send) changed the + /// durable store. Snapshot it into the config and mark it dirty; the + /// deadline arm below persists it on the same coalesced schedule as + /// every other mutation. `Notify` holds one permit, so a write that + /// lands between iterations is not missed, and a burst collapses to + /// one snapshot — which is exactly the coalescing we want. + pub(super) async fn on_tsp_dirty(&mut self) { + let Runtime { + state, + config, + didcomm_service, + save, + .. + } = self; + config.private.tsp_relationships = didcomm_service.tsp_store().snapshot().await; + save.mark_dirty(); + // A relationship changed, so the pane's view of it is stale. + // Recompute here — the same event that persists it also + // refreshes what the user sees. + let rows = compute_tsp_relationship_rows(config, &didcomm_service.tsp_store()).await; + state.main_page.content_panel.tsp_relationships.rows = rows.into(); + } + + pub(super) async fn on_save_deadline(&mut self) { + let Runtime { + state, + config, + save, + save_done_tx, + .. + } = self; + match save.take_for_save(|| config.clone_for_save()) { + Ok(Ok(pending)) => { + let done_tx = save_done_tx.clone(); + tokio::task::spawn_blocking(move || { + let result = pending.run().map_err(|e| format!("{e}")); + let _ = done_tx.send(result); + }); + } + // Snapshot failed: surface like an inline save failure. + // The scheduler kept the config dirty + re-armed, so it + // retries on the next deadline. + Ok(Err(e)) => { + state.main_page.log_error("Failed to save config", &e); + } + // NotDirty / InFlight — nothing to start right now. + Err(_) => {} + } + } + + /// A backgrounded coalesced save finished (R11). Clear the + /// in-flight flag and re-arm if the config was dirtied again. A + /// failed save is surfaced exactly as the old inline `save_config` + /// failure did (status + log) and left dirty for retry. + pub(super) async fn on_save_done(&mut self, result: Result<(), String>) { + let Runtime { + state, + save, + vta_sync, + .. + } = self; + match &result { + // What was just saved may be news for the VTA. + Ok(()) => vta_sync.request(), + Err(reason) => { + state + .main_page + .log_error("Failed to save config", &anyhow::anyhow!("{reason}")); + } + } + save.finish(result.is_ok()); + } + + pub(super) async fn on_vta_sync_tick(&mut self) { + let Runtime { + config, + admin_vta, + vta_sync, + vta_sync_tx, + .. + } = self; + if let Some(client) = admin_vta.as_ref() + && !config.account.top_context_id.is_empty() + && vta_sync.should_start(tokio::time::Instant::now()) + { + let context_id = config.account.top_context_id.clone(); + config.private.vta_sync.bind_context(&context_id); + let view = openvtc_core::vta_sync::SyncView { + account: &config.account, + private: &config.private, + key_info: &config.key_info, + }; + let out = openvtc_core::vta_sync::outgoing(&view, &config.private.vta_sync); + let since = config.private.vta_sync.watermark; + let client = client.clone(); + let tx = vta_sync_tx.clone(); + tokio::spawn(async move { + let ex = openvtc_core::vta_sync::exchange(&client, &context_id, since, out).await; + let _ = tx.send(ex); + }); + } + } + + pub(super) async fn on_vta_sync_exchange(&mut self, ex: openvtc_core::vta_sync::Exchange) { + let Runtime { + state, + config, + save, + vta_sync, + .. + } = self; + let mut report = openvtc_core::vta_sync::SyncReport::default(); + let mut sync_state = std::mem::take(&mut config.private.vta_sync); + let lost_race = { + let mut parts = openvtc_core::vta_sync::SyncParts { + account: &mut config.account, + private: &mut config.private, + key_info: &mut config.key_info, + }; + openvtc_core::vta_sync::absorb( + &mut parts, + &mut sync_state, + ex, + openvtc_core::vta_sync::ApplyMode::Live, + &mut report, + ) + }; + config.private.vta_sync = sync_state; + let error = report.error.take(); + for line in report.log_lines() { + state.main_page.log(line); + } + if vta_sync.finished(tokio::time::Instant::now(), error.as_ref()) + && let Some(e) = &error + { + state.main_page.log(e.explain()); + } + if lost_race { + // The merged record goes up on the next cycle. + vta_sync.request(); + } + if !report.changed.is_empty() { + state.main_page.sync_from_config(config); + } + if report.touched() { + save.mark_dirty(); + } + } +} diff --git a/openvtc/src/state_handler/runtime/inbound_arm.rs b/openvtc/src/state_handler/runtime/inbound_arm.rs new file mode 100644 index 00000000..7b8b7367 --- /dev/null +++ b/openvtc/src/state_handler/runtime/inbound_arm.rs @@ -0,0 +1,481 @@ +//! The runtime's inbound arm: a DIDComm event as it arrives, or handed +//! back with the result of its off-loop check. + +use super::*; +use crate::state_handler::*; + +impl Runtime { + /// DIDComm inbound message events — as they arrive, or handed + /// back with the result of their off-loop check. + pub(super) async fn on_inbound( + &mut self, + event: didcomm::DIDCommEvent, + pre: Option, + ) { + let Runtime { + state, + config, + tdk, + admin_vta, + didcomm_service, + save, + in_flight, + dispatch_tx, + session_manager, + verifier, + restore_backlog, + seen_messages, + witness_router, + ping_sent_at, + join_entry, + awaiting_requirements, + awaiting_challenge, + awaiting_wallet, + .. + } = self; + match event { + didcomm::DIDCommEvent::InboundMessage { + message, + transport, + authenticated, + .. + } => { + // Capture message info before processing for detailed logging + let msg_type = message.typ.clone(); + let msg_from = message.from.clone().unwrap_or_else(|| "unknown".into()); + let msg_to = message + .to + .as_ref() + .and_then(|v| v.first()) + .cloned() + .unwrap_or_default(); + let msg_thid = message.thid.clone().unwrap_or_else(|| "none".into()); + + // A credential that has been checked no longer + // shows its membership as verifying. + let returning_credential = + matches!(pre, Some(openvtc_core::inbound::PreVerified::Credential(_))); + let returning = pre.is_some(); + let mut effects = openvtc_core::inbound::InboundEffects::default(); + // Only the sender the transport authenticated can be + // waited on: a claimed `from` alone neither holds a + // community's messages back nor jumps its queue. + let waiting = |claimed: &str| { + authenticated.as_deref() == Some(claimed) && verifier.is_waiting_on(claimed) + }; + let arrival = match pre { + Some(pre) => openvtc_core::inbound::Arrival::Returning(pre), + None => openvtc_core::inbound::Arrival::New { waiting: &waiting }, + }; + let dispatched = openvtc_core::inbound::process_inbound_message( + config, + tdk, + didcomm_service, + seen_messages, + &message, + authenticated.as_deref(), + &mut effects, + arrival, + ) + .await; + // Applied (or refused): its kept copy goes. + if returning { + let before = config.private.deferred_inbound.len(); + config.private.deferred_inbound.retain(|kept| { + Some(kept.sender.as_str()) != authenticated.as_deref() + || kept.message.id != message.id + }); + if config.private.deferred_inbound.len() != before { + save.mark_dirty(); + } + } + let openvtc_core::inbound::InboundEffects { + inactivated, + capability_replies, + git_ns_replies, + rooms_replies, + elections_replies, + personhood_challenges, + vetting_answers, + vetting_grant_checks, + renewals, + witness_replies, + notifications, + deferred, + } = effects; + // Notifications for our personas, to the member's VTA as received, + // and the sender answered (VTI-NTF-054). + for mut received in notifications { + received.over_tsp = transport == openvtc_core::didcomm::MessagingTransport::Tsp; + notification_actions::hand_over(config, tdk, admin_vta.as_ref(), received) + .await; + } + // A witness's answers, to the witnessing waiting on their thread. + for reply in witness_replies { + let thread = reply + .document + .get("threadId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + if witness_router.deliver(&reply.sender, reply.document) { + debug!(%thread, "a witness's answer, handed to its witnessing"); + } + } + if returning_credential { + state + .main_page + .content_panel + .communities + .verifying + .remove(&msg_from); + state.main_page.sync_from_config(config); + } + // Set aside for its check: the membership shows + // "verifying…" until the result comes back. + if let Some(deferred) = deferred { + let _ = queue_for_check( + verifier, + config, + state, + deferred, + transport, + authenticated.as_deref(), + ); + save.mark_dirty(); + } + // A check finished, so there may be room for a + // message kept from before the restart. + if returning + && !restore_backlog.is_empty() + && restore_kept( + verifier, + restore_backlog, + config, + state, + tdk, + didcomm_service, + seen_messages, + ) + .await + { + save.mark_dirty(); + } + + // A live challenge is display state, not account + // state: single-use, ten-minute life, and worthless + // after a restart. It is folded in here rather than + // persisted, and unconditionally — a reply that + // arrived is a reply the member should see whether + // or not the message also changed the config. + for reply in personhood_challenges { + // The challenge belongs to the persona it was + // addressed to. Without one we cannot sign for + // it, so there is nothing to offer the member — + // and silently showing an unanswerable code + // would be worse than saying nothing. + let Some(persona) = config.account.persona_id_for_did(&msg_to) else { + warn!( + vtc = %msg_from, + to = %msg_to, + "personhood challenge addressed to a DID this account \ + holds no persona for — ignoring", + ); + continue; + }; + state + .main_page + .content_panel + .communities + .personhood_challenge = Some( + crate::state_handler::main_page::content::PersonhoodChallengeView { + vtc_did: msg_from.clone(), + persona, + challenge_id: reply.challenge_id, + match_code: reply.match_code.clone(), + expires_at: reply.expires_at, + }, + ); + state.main_page.content_panel.communities.status_message = + Some(Status::info(format!( + "Personhood challenge received — confirm the code {} with \ + whoever is vetting you, then assert.", + reply.match_code + ))); + } + + // How a renewal the member asked for ended: on the + // Communities status line, where they asked, and in + // the activity log with the community named. + for (vtc, outcome) in renewals { + let line = outcome.describe(); + match &outcome { + openvtc_core::renewal::RenewalOutcome::Renewed { .. } => { + state.main_page.log(&line) + } + _ => state + .main_page + .log_error("Membership renewal", &format!("{vtc}: {line}")), + } + let severity = match &outcome { + openvtc_core::renewal::RenewalOutcome::Renewed { .. } => { + crate::state_handler::main_page::status::Severity::Success + } + _ => crate::state_handler::main_page::status::Severity::Error, + }; + state.main_page.content_panel.communities.status_message = + Some(Status::new(severity, line)); + } + + match dispatched { + Ok(true) => { + // R11: a config-mutating inbound message used + // to save inline here — the per-message cost + // that turned a mediator redelivery burst into + // N sequential keyring+file+card writes. Now we + // mark dirty (coalesced + offloaded); the UI + // sync stays immediate. + save.mark_dirty(); + // R-S-3: a community resolved to an inactive + // status (e.g. a rejection) — tear down its + // live session so a dead community stops + // holding a mediator connection. + for (vtc, persona) in &inactivated { + deregister_inactive_community( + session_manager, + didcomm_service, + config, + state, + vtc, + *persona, + ) + .await; + } + state.main_page.sync_from_config(config); + let short_type = openvtc_core::display::task_label(&msg_type); + state.main_page.log_detailed( + format!("Inbound {transport}: {short_type}"), + format!( + "Inbound {transport} Message\n\ + ───────────────────────\n\ + Type: {msg_type}\n\ + From: {msg_from}\n\ + To: {msg_to}\n\ + thid: {msg_thid}", + ), + ); + } + Ok(false) => {} + Err(e) => { + state.main_page.log_detailed( + format!("Message error: {e}"), + format!( + "Failed Inbound Message\n\ + ──────────────────────\n\ + Type: {msg_type}\n\ + From: {msg_from}\n\ + To: {msg_to}\n\ + thid: {msg_thid}\n\ + Error: {e}", + ), + ); + debug!("message dispatch error: {e}"); + } + } + apply_capability_replies(state, capability_replies); + // git-ns answers for the Repos panel; a change that + // landed is followed by a fresh read of the view. + if !git_ns_replies.is_empty() + && repos_actions::apply_replies(state, config, git_ns_replies) + { + repos_actions::Loop { + state, + config, + tdk, + dispatch_tx, + in_flight, + } + .refresh(true) + .await; + } + // The answer names the repositories to look for + // on this machine. + repos_workspace::probe_if_due(state, dispatch_tx, in_flight); + // Data-room answers for the Rooms panel; each may call for the + // next step of a refresh or an acceptance. + if !rooms_replies.is_empty() && rooms_actions::apply_replies(state, rooms_replies) { + rooms_actions::advance(&mut rooms_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + }) + .await; + } + // Election answers for the Elections panel; each may call for + // the next step of a ticket or a vote. + if !elections_replies.is_empty() + && elections_actions::apply_replies(state, elections_replies) + { + elections_actions::advance(&mut elections_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + }) + .await; + } + // A vetter's grant is checked for revocation off the + // loop: the check fetches the community's status list. + for check in vetting_grant_checks { + vetting_actions::spawn_grant_check(dispatch_tx, tdk, check); + } + // The manifest or challenge a waiting join asked for + // resumes it; every other answer goes to the Vetting page. + let mut answers = Vec::with_capacity(vetting_answers.len()); + for answer in vetting_answers { + if let Some(entry) = awaiting_requirements + .as_ref() + .and_then(|a| join_flow::resume_for(a, &answer)) + { + *awaiting_requirements = None; + *join_entry = Some(entry); + } else if let Some(entry) = awaiting_challenge + .as_ref() + .and_then(|a| join_flow::challenge_resume_for(a, &answer)) + { + *awaiting_challenge = None; + *join_entry = Some(entry); + } else if let Some(step) = awaiting_wallet + .as_mut() + .and_then(|a| join_flow::wallet_answer(a, &answer, state)) + { + if let Some(entry) = step { + *awaiting_wallet = None; + *join_entry = Some(entry); + } + } else { + answers.push(answer); + } + } + vetting_actions::apply_answers(state, config, answers); + // A manifest in is a fresh reading of how its community vets: say a + // switch, and decide a ticket that was waiting on it. + vetting_actions::settle_pending_tickets(state, config, save, chrono::Utc::now()); + } + didcomm::DIDCommEvent::TrustPingReceived { + from, + listener_id, + message_id, + } => { + // `from` is the sender the transport authenticated + // (never the plaintext header); with none there is + // nobody to answer. + let sender = from.as_deref().unwrap_or("unknown"); + let sender_arc = std::sync::Arc::new(sender.to_string()); + + // Only respond to pings from the mediator or established relationships + let is_mediator = from.is_some() && sender == config.mediator_did(); + let has_relationship = from.is_some() + && config + .private + .relationships + .find_by_remote_did(&sender_arc) + .map(|r| { + r.state == openvtc_core::relationships::RelationshipState::Established + }) + .unwrap_or(false); + + if is_mediator || has_relationship { + // Send pong to the authenticated sender, setting `from` to our + // listener's DID so the recipient can identify us. + let our_listener_did = didcomm_service + .listener_did(&listener_id) + .await + .unwrap_or_else(|| config.persona_did().to_string()); + if let Ok(pong_msg) = build_trust_pong(&our_listener_did, sender, &message_id) + && let Err(e) = didcomm::send_message_via( + didcomm_service, + &pong_msg, + &listener_id, + sender, + ) + .await + { + state.main_page.log_error("Failed to send pong", &e); + } + let ping_display = resolve_did_to_display(config, sender); + state.main_page.log_detailed( + format!("Ping from {ping_display} — pong sent"), + format!( + "Trust-Ping Received\n\ + ───────────────────\n\ + From (display): {ping_display}\n\ + From (DID): {sender}\n\ + Listener: {listener_id}\n\ + Response: pong sent", + ), + ); + } else { + state.main_page.log_detailed( + format!( + "Ping from {} — ignored", + resolve_did_to_display(config, sender) + ), + format!( + "Trust-Ping Rejected\n\ + ───────────────────\n\ + From (DID): {sender}\n\ + Reason: {}", + if from.is_some() { + "no established relationship" + } else { + "the transport authenticated no sender" + }, + ), + ); + } + } + didcomm::DIDCommEvent::TrustPongReceived { from } => { + debug!(from = ?from, "TrustPongReceived event"); + let sender_did = from.as_deref().unwrap_or(""); + // `from` is the sender the transport authenticated. + // A pong with none is logged as such rather than + // credited to whoever we pinged last: it closes no + // ping task either (see the dispatcher). + let sender_display = if sender_did.is_empty() { + "an unauthenticated sender".to_string() + } else { + resolve_did_to_display(config, sender_did) + }; + let ms = ping_sent_at + .take() + .map(|sent_at| sent_at.elapsed().as_millis()); + let latency_str = ms.map(|v| format!(" ({v}ms)")).unwrap_or_default(); + // The answer belongs where the ping was sent, not only + // in the log (#584). + if !sender_did.is_empty() { + state.main_page.content_panel.relationships.status_message = + Some(Status::success(format!( + "Pong from {sender_display}{latency_str} — they are reachable." + ))); + } + state.main_page.log_detailed( + format!("Pong from {sender_display}{latency_str}"), + format!( + "Trust-Pong Received\n\ + ───────────────────\n\ + From (display): {sender_display}\n\ + From (DID): {sender_did}\n\ + Latency: {}", + ms.map(|v| format!("{v}ms")).unwrap_or_else(|| "n/a".into()), + ), + ); + } + } + } +} diff --git a/openvtc/src/state_handler/runtime/mod.rs b/openvtc/src/state_handler/runtime/mod.rs new file mode 100644 index 00000000..73e7015b --- /dev/null +++ b/openvtc/src/state_handler/runtime/mod.rs @@ -0,0 +1,767 @@ +//! The runtime loop (#528). +//! +//! `StateHandler::main_loop` used to be one ~2,500-line function: setup, then +//! a `select!` whose 21 arms read and wrote some thirty loop-local variables. +//! None of it could be called from a test. Those locals are now the fields of +//! [`Runtime`], each `select!` arm is one method on it, and [`Runtime::run`] is +//! a router: it waits for the next event, hands it to its method, and runs the +//! end-of-iteration work every arm used to fall through to. +//! +//! The arm methods live next to what they handle: +//! +//! | File | Arms | +//! |------|------| +//! | [`arms`] | actions, dispatch outcomes, presence, the admin-session supervisor, lifecycle/listener events, session reconcile, TSP store, saves, VTA sync | +//! | [`inbound_arm`] | inbound DIDComm events | +//! | [`timers`] | the periodic sweeps (capabilities/reply windows, pending expiry, join status, hidden vetting, community profiles, agent names) | +//! +//! Each method takes `&mut self` and destructures the fields it uses, so its +//! body reads as it did when the fields were locals, and the borrow checker +//! still sees disjoint borrows. +//! +//! The single-mutator invariant is unchanged: only these methods touch +//! `state` and `config`, and they all run on the loop's task. + +mod arms; +mod inbound_arm; +#[cfg(test)] +mod tests; +mod timers; + +use std::collections::{HashSet, VecDeque}; + +use affinidi_tdk::TDK; +use anyhow::Result; +use openvtc_core::config::Config; +use tokio::sync::{ + broadcast, + mpsc::{self, UnboundedReceiver, UnboundedSender}, +}; +use tracing::{debug, warn}; + +use crate::{ + Interrupted, Terminator, + state_handler::{ + StateHandler, actions::Action, background_dispatch, community_profile_poll, + device_presence, didcomm, hidden_vetting_poll, join_flow, runtime_actions, save_coalesce, + session_manager, state, state::State, verify_queue, vta_recovery, vta_sync_driver, + }, +}; + +/// What an arm asks the loop to do next. +#[derive(Debug)] +pub(super) enum Flow { + /// Carry on: run the end-of-iteration work and wait for the next event. + Continue, + /// Stop the loop with this outcome. The arm has already told the + /// terminator when that is its job (an `Exit` action does; an interrupt, + /// which came *from* the terminator, does not). + Exit(Interrupted), +} + +/// What `main_loop` brought up before the runtime can be assembled: the loaded +/// account, the admin VTA session and its supervisor, and the (possibly still +/// listener-less) messaging runtime. +/// +/// Built once, before the State-A branch, and carried across it: a State-A +/// join hands these back rather than restarting. +pub(super) struct Startup { + pub(super) handler: StateHandler, + pub(super) terminator: Terminator, + pub(super) action_rx: UnboundedReceiver, + pub(super) interrupt_rx: broadcast::Receiver, + pub(super) state: State, + pub(super) tdk: TDK, + pub(super) config: Box, + pub(super) admin_vta: Option, + pub(super) presence_rx: UnboundedReceiver, + pub(super) vta_supervisor: vta_recovery::AdminVtaSupervisor, + pub(super) didcomm_event_rx: UnboundedReceiver, + pub(super) shutdown_token: tokio_util::sync::CancellationToken, + pub(super) didcomm_service: didcomm::Messaging, + pub(super) tsp_dirty: std::sync::Arc, +} + +/// The runtime loop's world: everything its arms read and write. +/// +/// Field names match the loop locals they replaced, so the arm bodies did not +/// have to change to move. +pub(crate) struct Runtime { + // ── Owner, UI and lifecycle channels ── + pub(super) handler: StateHandler, + pub(super) terminator: Terminator, + pub(super) action_rx: UnboundedReceiver, + pub(super) interrupt_rx: broadcast::Receiver, + + // ── The single-mutator state ── + pub(super) state: State, + pub(super) config: Box, + + // ── Services ── + pub(super) tdk: TDK, + pub(super) admin_vta: Option, + pub(super) vta_supervisor: vta_recovery::AdminVtaSupervisor, + pub(super) presence_rx: UnboundedReceiver, + pub(super) didcomm_service: didcomm::Messaging, + pub(super) shutdown_token: tokio_util::sync::CancellationToken, + pub(super) session_manager: session_manager::SessionManager, + pub(super) didcomm_event_rx: UnboundedReceiver, + pub(super) listener_events: broadcast::Receiver, + pub(super) lifecycle_log_rx: UnboundedReceiver, + pub(super) tsp_dirty: std::sync::Arc, + /// Held so the listener install and the lifecycle logger are not detached + /// anonymously; neither is awaited. + _listener_install: tokio::task::JoinHandle<()>, + _lifecycle_handle: tokio::task::JoinHandle<()>, + + // ── Inbound ── + pub(super) seen_messages: openvtc_core::messaging::SeenMessages, + pub(super) verifier: verify_queue::Verifier, + pub(super) restore_backlog: VecDeque, + pub(super) witness_router: openvtc_core::witness::ReplyRouter, + pub(super) ping_sent_at: Option, + + // ── Background dispatch (R13) ── + pub(super) dispatch_tx: UnboundedSender, + pub(super) dispatch_rx: UnboundedReceiver, + pub(super) in_flight: background_dispatch::InFlight, + pub(super) contexts_checked: HashSet, + pub(super) grants_swept: bool, + + // ── Coalesced persistence (R11) ── + pub(super) save: save_coalesce::SaveScheduler, + pub(super) save_done_tx: UnboundedSender>, + pub(super) save_done_rx: UnboundedReceiver>, + + // ── VTA account sync ── + pub(super) vta_sync: vta_sync_driver::SyncDriver, + pub(super) vta_sync_tick: tokio::time::Interval, + pub(super) vta_sync_tx: UnboundedSender, + pub(super) vta_sync_rx: UnboundedReceiver, + + // ── Timers and their pacers ── + pub(super) pending_expiry_tick: tokio::time::Interval, + pub(super) capabilities_sweep: tokio::time::Interval, + pub(super) session_reconcile_tick: tokio::time::Interval, + pub(super) agent_name_tick: tokio::time::Interval, + pub(super) join_status_tick: tokio::time::Interval, + pub(super) join_status_pacer: openvtc_core::join::status_poll::PollPacer, + pub(super) community_profile_tick: tokio::time::Interval, + pub(super) community_profile_pacer: community_profile_poll::ProfilePacer, + pub(super) hidden_vetting_tick: tokio::time::Interval, + pub(super) hidden_vetting_pacer: hidden_vetting_poll::HiddenVettingPacer, + + // ── The join flow ── + /// The join flow to enter at the end of this iteration. + pub(super) join_entry: Option, + /// A join waiting on a community's requirements (see `join_flow::AwaitingRequirements`). + pub(super) awaiting_requirements: Option, + /// A join parked on a hidden-vetting challenge (see + /// `join_flow::AwaitingChallenge`) — the same hand-off, for the question + /// a launch asks rather than the one the vetting page does. + pub(super) awaiting_challenge: Option, + /// A join parked on a wallet session (see `join_flow::AwaitingWallet`). + pub(super) awaiting_wallet: Option, +} + +impl Runtime { + /// Bring the runtime up on what `main_loop` started: install this account's + /// listeners, register its sessions, seed the connection indicator, and + /// build the loop's timers, pacers and channels. + pub(super) async fn assemble(startup: Startup) -> Runtime { + let Startup { + handler, + terminator, + action_rx, + interrupt_rx, + mut state, + tdk, + mut config, + admin_vta, + presence_rx, + vta_supervisor, + didcomm_event_rx, + shutdown_token, + didcomm_service, + tsp_dirty, + } = startup; + + // Point the runtime active identity at the default working community and + // refilter the community-scoped panels before the first paint (D10 / + // R-C-6) — otherwise a multi-persona account renders empty panels until + // the first event re-syncs (the per-path syncs above run with no + // selection set yet). + state.reconcile_selected_community_among( + &config.account, + Some(&config.identities.keys().copied().collect()), + ); + let initial_active = state + .selected_community + .as_ref() + .map(|(_, persona)| *persona); + config.set_active_persona(initial_active); + state.main_page.sync_from_config(&config); + + // Send initial state immediately so the UI renders without blocking. + // Connecting only when there is a community session to connect: with no + // live membership there is none, and "Connecting..." would sit on the + // screen until the seed below corrected it — or read as a hang. + state.connection.status = if config.account.memberships().any(|m| m.is_live()) { + state::MediatorStatus::Connecting + } else { + state::MediatorStatus::NoActiveCommunity + }; + let _ = handler.state_tx.send(state.clone()); + + // Install this account's listeners on the runtime started above. Skips + // any that already exist, so a State-A join's own persona listener is + // bound to rather than duplicated (one websocket per DID). + // + // The specs are built here because that needs `Config`, which is not + // `Clone` and cannot cross into a task. Bringing the listeners *up* is + // the expensive half — a mediator auth handshake and a websocket connect + // each, in series — and that half runs off this thread. This is the only + // thread that services UI actions, and the loading screen has already + // been told to offer "Press Enter to continue": awaiting the connects + // here meant the keypress sat unread in the action channel until the last + // socket was up, and the main page then arrived in one late jump. + // Outbound TSP sends (`tsp::send_trust_task`) run on `tdk.atm`; inbound + // frames are unsealed by listener ATMs. Both must record relationships in + // the one durable store, or the invite we send is `Pending` where no + // listener looks and the peer's accept and replies are dropped (VTI-61). + let tdk = match didcomm_service.tsp_store().adopt_into(tdk.clone()).await { + Ok(adopted) => adopted, + Err(e) => { + warn!(error = %e, "could not share the TSP relationship store with the sending ATM"); + tdk + } + }; + + let listener_specs = didcomm::build_listener_configs(&config, &tdk).await; + let listener_install = + didcomm::spawn_install_listeners(didcomm_service.clone(), listener_specs); + + // Process-lifetime LRU of inbound message IDs. Backstop for replay + // and mediator-pickup duplicates beyond what the TDK already filters. + let mut seen_messages = openvtc_core::messaging::SeenMessages::new(); + + // Forward lifecycle events (connect/disconnect/restart) to the activity log + let (lifecycle_log_tx, lifecycle_log_rx) = + mpsc::unbounded_channel::(); + let lifecycle_handle = didcomm::spawn_lifecycle_logger(&didcomm_service, lifecycle_log_tx); + + // Phase 2 (community/persona DIDComm connection) runs ASYNCHRONOUSLY: we + // do NOT block the UI waiting for the listener. The main page is already + // showing (Connecting); the persona connection proceeds in the + // background and the runtime loop below flips the status to Connected the + // moment a `ListenerEvent::Connected` arrives — and back to Connecting on + // a disconnect. Subscribe to typed lifecycle events for that. + let listener_events = didcomm_service.subscribe(); + + // Supervised multi-session manager (D11/D15): one persona-session per + // active community (a reused persona shares one), tracking per-session + // connection status over the listeners `start_service` already launched. + // Messaging runs through it from here on; at N=1 it holds one session and + // behaves identically to the previous single global flag. + let mut session_manager = session_manager::SessionManager::default(); + { + use session_manager::RegisterOutcome; + // NOTE: a mid-session leave/reject/expire does NOT yet deregister its + // session (that is T6/T7), so until then `any_connected()` can read + // live for a persona whose communities have all gone inactive. Startup + // registers exactly the communities that require a live session at load + // time (`IdentityRegistry::sessions`). + let registry = openvtc_core::identity::IdentityRegistry::new(&config.account); + let mut at_capacity = 0usize; + for (persona_id, vtcs) in registry.sessions() { + let Some(ctx) = config.identities.get(&persona_id) else { + // A live community whose persona didn't resolve to an identity + // has no listener either — surfaced here for parity, not silent. + debug!(%persona_id, "live-community persona missing from resolved identities — not tracked"); + continue; + }; + let lid = didcomm::persona_listener_id(&ctx.did); + for vtc in vtcs { + if matches!( + session_manager.register(persona_id, &lid, vtc.clone()), + RegisterOutcome::AtCapacity + ) { + at_capacity += 1; + warn!(%persona_id, vtc = %vtc, "session manager at capacity — community not tracked"); + } + } + } + // No silent caps (D15): tell the user if any community exceeded the bound. + if at_capacity > 0 { + state.main_page.log(format!( + "Warning: {at_capacity} communit{} exceeded the session limit ({}) and are not actively connected.", + if at_capacity == 1 { "y" } else { "ies" }, + session_manager.max_sessions(), + )); + } + } + + // Seed the indicator from the messaging layer's *live* state rather than + // assuming nothing is up yet. `ListenerStatus` is edge-triggered, and a + // State-A join has already brought its persona listener up and connected + // by the time this loop subscribes — the install step above binds to that + // socket instead of reinstalling it, so no second `Connected` edge is + // coming. Without this seed the session sits `Connecting` for the life of + // the process over a perfectly live socket, which is the "Inbox: + // Connecting…" that only a restart appeared to fix. + // + // Applied unconditionally rather than only when the reconcile changed + // something: an account with no community session (an applicant whose + // join is still being vetted) has nothing to change, and must still + // land on `NoActiveCommunity` rather than the `Connecting` shown while + // the page came up — no session will ever send the edge that moves it. + super::reconcile_sessions(&mut session_manager, &didcomm_service, &mut state); + super::apply_session_aggregate(&session_manager, &mut state); + match state.connection.status { + state::MediatorStatus::Connected => state.main_page.log("Connected to the mediator."), + state::MediatorStatus::NoActiveCommunity => state + .main_page + .log("No community membership yet — no community session to connect."), + _ => state.main_page.log("Connecting to the mediator…"), + } + let _ = handler.state_tx.send(state.clone()); + + // Background-dispatch plumbing (R13). Network-bound actions whose await + // would otherwise park the whole select loop are spawned as background + // tasks; they do I/O only and send their result back as a + // `DispatchOutcome` on this channel. The select arm below applies the + // outcome on the loop thread (the single mutator), keeping the loop live + // during the wait. `in_flight` rejects a second action on a busy domain. + let (dispatch_tx, dispatch_rx) = + mpsc::unbounded_channel::(); + + // Coalesced + offloaded config persistence (R11). Mutation sites mark the + // config dirty on the loop thread instead of saving inline; the + // `deadline` arm below debounces a burst into a single `spawn_blocking` + // save, with at most one in flight at a time. Durability-critical points + // (Exit, passphrase/protection change, export) force-flush synchronously. + let mut save = save_coalesce::SaveScheduler::new(handler.profile.clone()); + // Channel carrying a completed background save's success flag, so the loop + // can clear the in-flight flag and re-arm if the config was dirtied again + // while the save ran. Save failures are surfaced as a status/log here, + // matching the pre-R11 inline `save_config` failure handling. + let (save_done_tx, save_done_rx) = mpsc::unbounded_channel::>(); + + // Account-state sync with the VTA (`openvtc_core::vta_sync`). The load + // already ran one; report it, and save whatever it changed. From here + // a cycle runs after each save and on a timer, off the loop: the plan + // is taken here, the network half is spawned, and its result is merged + // back here — the loop stays the only thing that mutates `config`. + if let Some(report) = config.private.vta_sync.load_report.take() { + for line in report.log_lines() { + state.main_page.log(line); + } + if report.touched() { + save.mark_dirty(); + } + } + let vta_sync = vta_sync_driver::SyncDriver::new(tokio::time::Instant::now()); + let mut vta_sync_tick = tokio::time::interval(vta_sync_driver::TICK); + vta_sync_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let (vta_sync_tx, vta_sync_rx) = + mpsc::unbounded_channel::(); + + // Network-bound checks on inbound messages (a DID resolve, a status + // list fetch) run off this loop, one at a time, so a slow community + // never freezes the UI. A message waiting on its check is not applied + // at all; it comes back from `verifier` with the result and is + // dispatched from the top. The queue is bounded, with a lane for the + // communities we belong to and a cap per sender (`verify_queue`). + let mut verifier = verify_queue::Verifier::new(tdk.clone()); + // Messages from communities that were still queued at the last exit. + // The mediator deleted them on delivery, so their kept copies are the + // only ones. They are queued again as room allows; the rest wait (kept) + // until earlier checks finish, and none is dropped for want of room. + let mut restore_backlog: VecDeque<_> = + config.private.deferred_inbound.iter().cloned().collect(); + if super::restore_kept( + &mut verifier, + &mut restore_backlog, + &mut config, + &mut state, + &tdk, + &didcomm_service, + &mut seen_messages, + ) + .await + { + save.mark_dirty(); + } + + let mut hidden_vetting_tick = tokio::time::interval(std::time::Duration::from_secs(30)); + hidden_vetting_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + Runtime { + handler, + terminator, + action_rx, + interrupt_rx, + state, + config, + tdk, + admin_vta, + vta_supervisor, + presence_rx, + didcomm_service, + shutdown_token, + session_manager, + didcomm_event_rx, + listener_events, + lifecycle_log_rx, + tsp_dirty, + _listener_install: listener_install, + _lifecycle_handle: lifecycle_handle, + seen_messages, + verifier, + restore_backlog, + // Hands a witness's answers from the inbound arm to the witnessing + // waiting on them (`witness_actions`); the job verifies each one + // before reading it. + witness_router: openvtc_core::witness::ReplyRouter::default(), + // Track when a manual trust-ping was sent (for activity log latency + // display). + ping_sent_at: None, + dispatch_tx, + dispatch_rx, + in_flight: background_dispatch::InFlight::default(), + // Membership contexts already checked at the VTA this run. A failure + // is logged and retried on the next launch rather than every tick. + contexts_checked: HashSet::new(), + // Whether this run has read the membership contexts' device grants + // for the communities panel. Once a run; the device-access view + // re-reads on demand. + grants_swept: false, + save, + save_done_tx, + save_done_rx, + vta_sync, + vta_sync_tick, + vta_sync_tx, + vta_sync_rx, + // 7-day Pending timeout sweep (R-B-7 / D16). `interval`'s first tick + // fires immediately, so a Pending that aged out while the app was + // closed expires on launch; thereafter it sweeps hourly. + pending_expiry_tick: tokio::time::interval(std::time::Duration::from_secs(3600)), + // Reply-window sweep for the capabilities view (cheap no-op when the + // view is closed or idle). + capabilities_sweep: tokio::time::interval(std::time::Duration::from_secs(5)), + // Backstop for the edge-triggered listener-status stream. A + // transition that reaches nobody — the broadcast receiver lagging, a + // listener adopted after its connect — would otherwise latch a + // session's status (and the indicator) wrong until the next real + // edge, which for a healthy socket may never come. Cheap: a read of + // the transports' live state, no I/O and no allocation beyond the + // listener ids. + session_reconcile_tick: tokio::time::interval(std::time::Duration::from_secs(15)), + // Agent-name refresh sweep. The first tick fires immediately so + // names resolve shortly after launch; thereafter it picks up + // newly-added DIDs (a fresh relationship/contact) and re-verifies + // stale entries every few minutes. Cheap when there is nothing to do + // — it only spawns a job when `agent_name_refresh_targets` finds + // uncached/stale DIDs, and the resolver caches keep repeat sweeps + // quiet. + agent_name_tick: tokio::time::interval(std::time::Duration::from_secs(300)), + // Reconcile Pending joins with their communities. The first tick + // fires immediately, which is the point: a join still Pending at + // launch is the one whose answer may have been lost, and asking is + // how we find out rather than waiting to be told again. Per-record + // backoff lives in the pacer, so a minute-by-minute tick does not + // mean a minute-by-minute poll; ticks over an account with no + // Pending join do no work at all. + join_status_tick: tokio::time::interval(std::time::Duration::from_secs(60)), + join_status_pacer: openvtc_core::join::status_poll::PollPacer::default(), + // Ask each Active community, once per session, for its declared + // `relationshipIdentifierDefault` (issue #241). The pacer asks each + // community once, so this tick over communities already asked does + // no work; a bare account (no Active membership) does none at all. + community_profile_tick: tokio::time::interval(std::time::Duration::from_secs(60)), + community_profile_pacer: community_profile_poll::ProfilePacer::default(), + // The vetter side of hidden vetting: re-read each community we vet + // for, enrol, and draw the token drip — on the communities' tick + // windows and at least hourly, never on demand + // (`hidden_vetting_poll`). The interval only checks the pacer; the + // first check is at launch, which is when a client that was off + // catches up the ticks it missed. + hidden_vetting_tick, + hidden_vetting_pacer: hidden_vetting_poll::HiddenVettingPacer::default(), + join_entry: None, + awaiting_requirements: None, + awaiting_challenge: None, + awaiting_wallet: None, + } + } + + /// Run the loop until an arm asks it to stop, then shut down. + pub(super) async fn run(mut self) -> Result { + let result = loop { + let flow = tokio::select! { + Some(action) = self.action_rx.recv() => self.on_action(action).await, + // DIDComm inbound message events — as they arrive, or handed + // back with the result of their off-loop check. + Some((event, pre)) = super::next_inbound(&mut self.didcomm_event_rx, &mut self.verifier) => { + self.on_inbound(event, pre).await; + Flow::Continue + } + Some(outcome) = self.dispatch_rx.recv() => { + self.on_dispatch_outcome(outcome).await; + Flow::Continue + } + Some(report) = self.presence_rx.recv() => { + self.on_presence(report).await; + Flow::Continue + } + _ = self.vta_supervisor.tick.tick() => { + self.on_vta_health_tick().await; + Flow::Continue + } + Some(outcome) = self.vta_supervisor.done_rx.recv() => { + self.on_vta_rebuilt(outcome).await; + Flow::Continue + } + Some(event) = self.lifecycle_log_rx.recv() => { + self.on_lifecycle_log(event).await; + Flow::Continue + } + ev = self.listener_events.recv() => { + self.on_listener_event(ev).await; + Flow::Continue + } + _ = self.session_reconcile_tick.tick() => { + self.on_session_reconcile().await; + Flow::Continue + } + _ = self.capabilities_sweep.tick() => { + self.on_capabilities_sweep().await; + Flow::Continue + } + _ = self.pending_expiry_tick.tick() => { + self.on_pending_expiry().await; + Flow::Continue + } + _ = self.join_status_tick.tick() => { + self.on_join_status_tick().await; + Flow::Continue + } + _ = self.hidden_vetting_tick.tick() => { + self.on_hidden_vetting_tick().await; + Flow::Continue + } + _ = self.community_profile_tick.tick() => { + self.on_community_profile_tick().await; + Flow::Continue + } + _ = self.agent_name_tick.tick() => { + self.on_agent_name_tick().await; + Flow::Continue + } + _ = self.tsp_dirty.notified() => { + self.on_tsp_dirty().await; + Flow::Continue + } + _ = self.save.wait_deadline() => { + self.on_save_deadline().await; + Flow::Continue + } + Some(result) = self.save_done_rx.recv() => { + self.on_save_done(result).await; + Flow::Continue + } + _ = self.vta_sync_tick.tick() => { + self.on_vta_sync_tick().await; + Flow::Continue + } + Some(ex) = self.vta_sync_rx.recv() => { + self.on_vta_sync_exchange(ex).await; + Flow::Continue + } + // (keepalive removed — WebSocket-level pings handle connectivity) + // Catch and handle interrupt signal to gracefully shutdown + Ok(interrupted) = self.interrupt_rx.recv() => Flow::Exit(interrupted), + }; + if let Flow::Exit(interrupted) = flow { + break interrupted; + } + if let Some(interrupted) = self.end_of_iteration().await { + break interrupted; + } + }; + self.shutdown().await; + Ok(result) + } + + /// The work every arm falls through to: enter the join flow when an arm + /// asked for it, keep the working-community selection valid, and broadcast + /// the state. `Some` when the join flow ended the application. + async fn end_of_iteration(&mut self) -> Option { + // Enter the join flow: from the Communities panel, or back from waiting + // on a community's requirements. Here rather than in an arm so every + // way in is handled the same. + if let Some(entry) = self.join_entry.take() { + self.awaiting_requirements = None; + self.awaiting_challenge = None; + self.awaiting_wallet = None; + match self + .handler + .join_flow( + &mut self.action_rx, + &mut self.interrupt_rx, + &mut self.state, + &self.tdk, + &mut self.config, + self.admin_vta.as_ref(), + self.handler.profile.as_str(), + Some(&self.didcomm_service), + entry, + ) + .await + { + Ok(join_flow::JoinExit::Returned(joined)) => { + self.state.active_page = state::ActivePage::Main; + // R-B-5 / D11: bring the new community's session up live + // so the VTC's async receipt is received now, not only + // after a restart. + if let Some(joined) = joined { + super::register_joined_session( + &mut self.session_manager, + &self.didcomm_service, + &self.tdk, + &self.config, + joined, + &mut self.state, + ) + .await; + // What the new community's persona wears, now rather than at the + // next sweep — until then its row could only say it had not + // been read. + super::dispatch_binding_refresh( + &self.config, + self.admin_vta.as_ref(), + &mut self.in_flight, + &self.dispatch_tx, + ); + } + } + Ok(join_flow::JoinExit::AwaitRequirements(awaiting)) => { + self.awaiting_requirements = Some(awaiting); + } + Ok(join_flow::JoinExit::AwaitChallenge(awaiting)) => { + self.awaiting_challenge = Some(awaiting); + } + Ok(join_flow::JoinExit::AwaitWallet(awaiting)) => { + self.awaiting_wallet = Some(awaiting); + } + // F10 on a join page lands here. Like every other exit it + // must tell the UI to stop: the UI leaves its loop only on + // an interrupt, so breaking without one stopped this loop + // and left the screen up reading keys nobody acted on — + // the "join page freezes, even F10 and Esc" report. + Ok(join_flow::JoinExit::Exit(interrupted)) => { + if let Err(e) = self.terminator.terminate(interrupted.clone()) { + debug!("Failed to send terminate signal: {e}"); + } + return Some(interrupted); + } + Err(e) => { + self.state.main_page.log_error("Join flow failed", &e); + self.state.active_page = state::ActivePage::Main; + } + } + } + // Keep the working-context selection valid against any account change + // this iteration applied (join/leave/status transition) before the UI + // re-renders from the broadcast (D10 / R-C-6), then point the runtime + // active identity at the selected community's persona so all + // identity-derived reads scope to the working community. When the + // working persona actually changes (e.g. the active community left and + // the default shifted), refilter the community-scoped panels so the UI + // reflects the new context this frame. + self.state.reconcile_selected_community_among( + &self.config.account, + Some(&self.config.identities.keys().copied().collect()), + ); + let active_persona = self + .state + .selected_community + .as_ref() + .map(|(_, persona)| *persona); + if active_persona != self.config.active_persona { + self.config.set_active_persona(active_persona); + self.state.main_page.sync_from_config(&self.config); + } + let _ = self.handler.state_tx.send(self.state.clone()); + None + } + + /// Everything the loop owns, borrowed for an action handler. + pub(super) fn action_ctx(&mut self) -> runtime_actions::ActionCtx<'_> { + runtime_actions::ActionCtx { + state: &mut self.state, + config: &mut self.config, + save: &mut self.save, + in_flight: &mut self.in_flight, + dispatch_tx: &self.dispatch_tx, + tdk: &self.tdk, + admin_vta: self.admin_vta.as_ref(), + didcomm_service: &self.didcomm_service, + session_manager: &mut self.session_manager, + ping_sent_at: &mut self.ping_sent_at, + state_tx: &self.handler.state_tx, + profile: self.handler.profile.as_str(), + witness_router: &self.witness_router, + } + } + + /// Persist what is left to persist and close the services. + async fn shutdown(mut self) { + // R11: if a backgrounded coalesced save was still running when the loop + // broke, wait for it to complete before the force-flush below. A + // `spawn_blocking` task is NOT cancelled when its `JoinHandle` is dropped, + // so that save is still live; running the shutdown save concurrently would + // mean two `Config::save`s racing the same (non-atomic) file + keyring + // writes. Draining the completion channel serialises shutdown after it. + // After `finish`, `needs_flush()` is only still true if the config was + // dirtied *after* the in-flight save's snapshot — exactly what the + // force-flush must persist. + if self.save.in_flight() + && let Some(result) = self.save_done_rx.recv().await + { + self.save.finish(result.is_ok()); + } + + // R11 force-flush: persist the latest state before tearing down, so + // coalescing never loses the final mutation on Exit/interrupt. Runs a + // direct blocking save (the loop has broken; there is no runtime arm left + // to schedule against). `needs_flush` is true when the config is dirty or + // a background save was still in flight when the loop broke. + if self.save.needs_flush() { + match self.save.snapshot_now(&self.config) { + Ok(pending) => { + if let Err(e) = pending.run() { + self.state + .main_page + .log_error("Failed to save config on exit", &e); + } + } + Err(e) => { + self.state + .main_page + .log_error("Failed to snapshot config on exit", &e); + } + } + } + + // Shut down the DIDComm service gracefully + self.shutdown_token.cancel(); + self.didcomm_service.shutdown().await; + + // Close the always-on admin VTA session. + if let Some(c) = self.admin_vta { + c.shutdown().await; + } + } +} diff --git a/openvtc/src/state_handler/runtime/tests.rs b/openvtc/src/state_handler/runtime/tests.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/openvtc/src/state_handler/runtime/tests.rs @@ -0,0 +1 @@ + diff --git a/openvtc/src/state_handler/runtime/timers.rs b/openvtc/src/state_handler/runtime/timers.rs new file mode 100644 index 00000000..44855b17 --- /dev/null +++ b/openvtc/src/state_handler/runtime/timers.rs @@ -0,0 +1,640 @@ +//! The runtime's periodic sweeps. + +use super::*; +use crate::state_handler::*; + +impl Runtime { + /// Coalesced-save debounce (R11). Fires when the debounce window + /// since the first dirty mark of a burst elapses. Builds an owned + /// snapshot on this (the single mutator) thread and runs the heavy + /// serialize+encrypt+keyring+card I/O on a blocking thread so the + /// loop stays responsive. At most one save is in flight; a mark + /// that lands while a save runs is re-scheduled on completion. + /// When nothing is scheduled the arm parks forever (no busy-wait). + pub(super) async fn on_capabilities_sweep(&mut self) { + let Runtime { + state, + config, + tdk, + admin_vta, + didcomm_service, + save, + in_flight, + dispatch_tx, + session_manager, + witness_router, + ping_sent_at, + join_entry, + awaiting_requirements, + awaiting_challenge, + awaiting_wallet, + handler, + .. + } = self; + // The Repos panel: reply windows, and polling a forge-account + // link no faster than the spec's five seconds. + repos_actions::tick(&mut repos_actions::Loop { + state, + config, + tdk, + dispatch_tx, + in_flight, + }) + .await; + // The Rooms panel: reply windows, and a step left on a busy lane. + rooms_actions::tick(&mut rooms_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + }) + .await; + // The Elections panel: the same. + elections_actions::tick(&mut elections_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + }) + .await; + // Questions to communities have a reply window too (R1.2). + vetting_actions::expire_queries(state, config, chrono::Utc::now()); + // A ticket whose question about its community's mode went unanswered, or + // could not be sent, ends here, saying which. + vetting_actions::settle_pending_tickets(state, config, save, chrono::Utc::now()); + // Hidden-vetting work for the VTA — answers a community or a vetter sent, + // an application that needs its engine — goes as soon as it arises, + // backed off while the VTA cannot be reached (R1.4). + if vetting_actions::pcs_work_due(config, chrono::Utc::now()) { + let mut ctx = runtime_actions::ActionCtx { + state, + config, + save, + in_flight, + dispatch_tx, + tdk, + admin_vta: admin_vta.as_ref(), + didcomm_service, + session_manager, + ping_sent_at, + state_tx: &handler.state_tx, + profile: handler.profile.as_str(), + witness_router, + }; + vetting_actions::start_pcs_work(&mut ctx, Vec::new()); + } + // A request reached the desk: re-read what its community + // requires now, before a session opens on a stale answer. + if std::mem::take(&mut config.private.vetting.vetter_refresh_due) { + let mut ctx = runtime_actions::ActionCtx { + state, + config, + save, + in_flight, + dispatch_tx, + tdk, + admin_vta: admin_vta.as_ref(), + didcomm_service, + session_manager, + ping_sent_at, + state_tx: &handler.state_tx, + profile: handler.profile.as_str(), + witness_router, + }; + vetting_actions::dispatch(&mut ctx, actions::VettingAction::RefreshVetterSide).await; + } else if vetting_actions::desk_open(state) { + // While the desk is open, how each community vets is kept no older + // than its TTL — asked at most once per re-ask interval (R1.4). + let mut ctx = runtime_actions::ActionCtx { + state, + config, + save, + in_flight, + dispatch_tx, + tdk, + admin_vta: admin_vta.as_ref(), + didcomm_service, + session_manager, + ping_sent_at, + state_tx: &handler.state_tx, + profile: handler.profile.as_str(), + witness_router, + }; + vetting_actions::refresh_stale_modes(&mut ctx).await; + } + if awaiting_requirements + .as_ref() + .is_some_and(|a| a.asked_at.elapsed() >= join_flow::REQUIREMENTS_WAIT) + && let Some(awaiting) = awaiting_requirements.take() + { + config.private.vetting.forget_query(&awaiting.document_id); + *join_entry = Some(join_flow::JoinEntry::Resume { + vtc_did: awaiting.vtc_did, + outcome: join_flow::RequirementsOutcome::Unanswered(format!( + "no answer within {} seconds — its service may be offline", + join_flow::REQUIREMENTS_WAIT.as_secs() + )), + }); + } + // R1.2 / R6.4: a challenge that never comes is the community + // not answering — our side sent the question, so it is not + // our mediator, and nothing refused it. + if awaiting_challenge + .as_ref() + .is_some_and(|a| a.asked_at.elapsed() >= join_flow::CHALLENGE_WAIT) + && let Some(awaiting) = awaiting_challenge.take() + { + config.private.vetting.forget_query(&awaiting.document_id); + *join_entry = Some(join_flow::JoinEntry::Challenge { + awaiting, + outcome: join_flow::ChallengeOutcome::Unanswered(format!( + "the request was sent, but no answer came within {} seconds — \ + its service may be offline or unreachable", + join_flow::CHALLENGE_WAIT.as_secs() + )), + }); + } + // A wallet session is polled on this tick, and given up on + // when the community stops answering (R1.2). + if let Some(awaiting) = awaiting_wallet.as_mut() + && let Some(entry) = + join_flow::wallet_tick(awaiting, config, tdk, Some(didcomm_service)).await + { + *awaiting_wallet = None; + *join_entry = Some(entry); + } + // A ping has a reply window too (#584): past it, say so in + // the panel that sent it and expire the outbound task, so + // the Inbox does not keep a ping nobody will answer. + if ping_sent_at.is_some_and(|t| t.elapsed() > PING_REPLY_WINDOW) { + *ping_sent_at = None; + let cutoff = chrono::Utc::now() + - chrono::Duration::from_std(PING_REPLY_WINDOW).unwrap_or_default(); + let before = config.private.tasks.tasks.len(); + config.private.tasks.tasks.retain(|_, t| { + !(matches!(t.type_, openvtc_core::tasks::TaskType::TrustPing { .. }) + && t.created < cutoff) + }); + if config.private.tasks.tasks.len() != before { + save.mark_dirty(); + state.main_page.sync_from_config(config); + } + let line = format!( + "No reply to the ping after {} s — they may be offline, or not \ + listening on that DID.", + PING_REPLY_WINDOW.as_secs() + ); + state.main_page.content_panel.relationships.status_message = + Some(Status::warning(line.clone())); + state.main_page.log_warning(line); + } + // R4.3: a capability query has a defined reply window. The + // send was fire-and-forget; if no correlated reply arrived, + // fail the view closed with a distinct, actionable message. + if let Some(view) = state.main_page.content_panel.capabilities.view.as_mut() + && view.pending_thid.is_some() + && view + .sent_at + .is_some_and(|t| t.elapsed() > std::time::Duration::from_secs(30)) + { + view.pending_thid = None; + view.sent_at = None; + if matches!( + view.phase, + crate::state_handler::main_page::content::CapabilitiesPhase::Loading + ) { + view.phase = crate::state_handler::main_page::content::CapabilitiesPhase::Failed( + "no reply within 30s — the community's governance host may be offline" + .to_string(), + ); + } else { + view.status_message = Some(Status::error( + "no reply to the change within 30s — refresh (r) to re-check".to_string(), + )); + } + } + } + + pub(super) async fn on_pending_expiry(&mut self) { + let Runtime { + state, + config, + didcomm_service, + save, + session_manager, + handler, + .. + } = self; + // The vetting book's own clock: forget cards past their + // retention, move finished desk requests to the record of + // who we vetted, close sessions nobody answered. Fires at + // start-up too, so a card from a previous run does not wait + // an hour to go. + let mut vetting_changed = config.private.vetting.prune(chrono::Utc::now()); + // Applications whose persona this account no longer holds: + // left by a deletion before deletions took them along, and + // unusable — their DID is gone. Judged by the persona + // record, not the loaded identity, which can be missing for + // a run without the persona being gone. + let personas = &config.account.personas; + let applications = &mut config.private.vetting.applications; + let before = applications.len(); + applications.retain(|a| personas.contains_key(&a.persona)); + let orphaned = before - applications.len(); + if orphaned > 0 { + vetting_changed = true; + state.main_page.log(format!( + "Dropped {orphaned} vetting application{} made as an identity that no \ + longer exists.", + if orphaned == 1 { "" } else { "s" } + )); + } + // Applications whose join is done and that hold nothing a + // rejoin could present again (see `retire_joined`). + let members: std::collections::HashSet<(String, openvtc_core::config::account::PersonaId)> = + config + .account + .memberships() + .filter(|m| m.status.is_active()) + .map(|m| (m.vtc_did.clone(), m.persona_ref)) + .collect(); + let retired = config.private.vetting.retire_joined( + |a| members.contains(&(a.community.clone(), a.persona)), + chrono::Utc::now(), + ); + if retired > 0 { + vetting_changed = true; + state.main_page.log(format!( + "Cleared {retired} finished vetting application{} — joined, and its \ + statements have expired.", + if retired == 1 { "" } else { "s" } + )); + } + if vetting_changed { + save.mark_dirty(); + state.main_page.sync_from_config(config); + } + // The vetter side runs on its own schedule — the + // hidden-vetting tick below, at least hourly. + // R-B-7: expire Pending joins unanswered for 7 days — or for + // the `decisionSla` a vetting community publishes — raising + // actions-required, and tear down each one's now-dead session + // (R-S-3). Records are retained read-only (R-S-1). + let vetting = &config.private.vetting; + let default_timeout = + chrono::TimeDelta::days(openvtc_core::config::account::PENDING_TIMEOUT_DAYS); + let timeouts: std::collections::HashMap<_, _> = config + .account + .memberships() + .filter_map(|c| { + vetting + .decision_sla(&c.vtc_did, c.persona_ref) + .map(|sla| ((c.vtc_did.clone(), c.persona_ref), sla)) + }) + .collect(); + let expired = config + .account + .expire_stale_pending_with(chrono::Utc::now(), |c| { + timeouts + .get(&(c.vtc_did.clone(), c.persona_ref)) + .copied() + .unwrap_or(default_timeout) + }); + if !expired.is_empty() { + save.mark_dirty(); + for (vtc, persona) in &expired { + deregister_inactive_community( + session_manager, + didcomm_service, + config, + state, + vtc, + *persona, + ) + .await; + } + state.main_page.sync_from_config(config); + state.main_page.log(format!( + "{} pending join{} expired (no decision within the time allowed).", + expired.len(), + if expired.len() == 1 { "" } else { "s" }, + )); + let _ = handler.state_tx.send(state.clone()); + } + + // The other thing that lapses by the clock: our own `vetter` + // role credential. Nothing arrives to announce it — the first + // sign is an applicant's request refused at their end — so + // this sweep is where it gets noticed. Said once per grant + // per state; see `take_grant_warnings`. + let warnings = config + .private + .vetting + .take_grant_warnings(chrono::Utc::now()); + if !warnings.is_empty() { + for warning in &warnings { + let community = + crate::state_handler::community_label(config, &warning.community, None, 64); + state.main_page.log(if warning.expired { + format!( + "Your vetter credential from {community} expired on {}. \ + Requests to you there are refused until it is reissued — \ + ask for it again from your vetting desk (g).", + warning.valid_until.format("%Y-%m-%d"), + ) + } else { + format!( + "Your vetter credential from {community} expires on {}. \ + Ask for it again from your vetting desk (g).", + warning.valid_until.format("%Y-%m-%d"), + ) + }); + let id = warning.id(); + config.private.tasks.tasks.insert( + id.clone().into(), + openvtc_core::tasks::Task { + id: std::sync::Arc::new(id), + type_: openvtc_core::tasks::TaskType::VetterGrantExpiring { + community: std::sync::Arc::new(community), + expired: warning.expired, + valid_until: warning.valid_until.format("%Y-%m-%d").to_string(), + }, + created: chrono::Utc::now(), + our_persona: Some(warning.persona), + }, + ); + } + save.mark_dirty(); + state.main_page.sync_from_config(config); + let _ = handler.state_tx.send(state.clone()); + } + } + + pub(super) async fn on_join_status_tick(&mut self) { + let Runtime { + state, + config, + tdk, + didcomm_service, + save, + verifier, + restore_backlog, + seen_messages, + join_status_pacer, + .. + } = self; + // Kept messages still waiting for room (the request rate, + // say) are tried again even if no check is running. + if !restore_backlog.is_empty() + && restore_kept( + verifier, + restore_backlog, + config, + state, + tdk, + didcomm_service, + seen_messages, + ) + .await + { + save.mark_dirty(); + } + // Ask each community about a join it has not resolved — + // every Pending join, including one that never got any + // reply, which is asked id-less; see `openvtc_core::join::status_poll`. + // + // Spawned detached rather than through `background_dispatch`: + // there is no outcome to apply on the loop thread (the reply + // arrives on the persona's listener and goes through inbound + // dispatch like any other), and the pacer already prevents + // pile-up by marking a record polled before the send. + let candidates = config.account.pollable_pending(chrono::Utc::now()); + if !candidates.is_empty() + && let Some(atm) = tdk.atm.clone() + { + let due = join_status_pacer.due(candidates, std::time::Instant::now()); + let polls = openvtc_core::join::status_poll::build(config, tdk, due).await; + if !polls.is_empty() { + tokio::spawn(openvtc_core::join::status_poll::send_all(atm, polls)); + } + } + } + + pub(super) async fn on_hidden_vetting_tick(&mut self) { + let Runtime { + state, + config, + tdk, + admin_vta, + didcomm_service, + save, + in_flight, + dispatch_tx, + session_manager, + witness_router, + ping_sent_at, + hidden_vetting_pacer, + handler, + .. + } = self; + let now = chrono::Utc::now(); + // A question left unanswered since the last pass is retried on its + // backoff, not at the next scheduled pass (R1.4). + hidden_vetting_pacer.pull_forward(&config.private.vetting, now); + if hidden_vetting_pacer.due(now) { + let mut ctx = runtime_actions::ActionCtx { + state, + config, + save, + in_flight, + dispatch_tx, + tdk, + admin_vta: admin_vta.as_ref(), + didcomm_service, + session_manager, + ping_sent_at, + state_tx: &handler.state_tx, + profile: handler.profile.as_str(), + witness_router, + }; + vetting_actions::dispatch(&mut ctx, actions::VettingAction::RefreshVetterSide).await; + let next = hidden_vetting_pacer.schedule( + &config.private.vetting, + chrono::Utc::now(), + &mut rand::thread_rng(), + ); + debug!(%next, "next hidden-vetting pass"); + } + } + + pub(super) async fn on_community_profile_tick(&mut self) { + let Runtime { + config, + tdk, + community_profile_pacer, + .. + } = self; + // Ask each Active community, once, for its profile so the + // new-relationship form can default to the community's + // declared identifier form (issue #241). Spawned detached, + // like the status poll: the reply arrives on the persona's + // listener and folds in through inbound dispatch, and the + // pacer marks each community asked before the send so a tick + // never re-asks one. + if let Some(atm) = tdk.atm.clone() { + let mut asks = Vec::new(); + for ask in community_profile_pacer.due(config) { + // Local key lookup; an ask whose key cannot be + // read is dropped rather than sent unsigned. + if let Ok(keys) = config.get_persona_keys_for(ask.persona, tdk).await { + asks.push(ask.with_signer(keys.authentication.secret.clone())); + } + } + if !asks.is_empty() { + tokio::spawn(community_profile_poll::send_all(atm, asks)); + } + } + } + + pub(super) async fn on_agent_name_tick(&mut self) { + let Runtime { + state, + config, + tdk, + admin_vta, + in_flight, + dispatch_tx, + contexts_checked, + grants_swept, + .. + } = self; + // Collect DIDs whose agent name is uncached or stale and, if + // any, resolve them in one background job (read-only I/O). + // Results are folded into the persisted cache on the loop + // thread by `apply_outcome`. The busy-guard drops the tick if + // a prior sweep is still running, so ticks never pile up. + let targets = config.agent_name_refresh_targets(chrono::Utc::now()); + if !targets.is_empty() + && in_flight.try_begin(background_dispatch::DispatchDomain::AgentName) + { + let resolver = tdk.did_resolver().clone(); + background_dispatch::spawn_dispatch( + dispatch_tx.clone(), + background_dispatch::DispatchDomain::AgentName, + async move { + let results = agent_name_refresh::resolve_batch(resolver, targets).await; + background_dispatch::DispatchOutcome::AgentName(results) + }, + ); + } + + // Ask what each persona presents. Shares this tick for the + // same reason as the transport probe — the loop gains no + // extra timer — and re-asks every sweep rather than on a + // TTL, because the answer is the holder's own decision and + // they may have changed it from `pnm` a moment ago. The + // busy-guard keeps a slow agent from stacking sweeps. + dispatch_binding_refresh(config, admin_vta.as_ref(), in_flight, dispatch_tx); + + // Register the contexts memberships name. Joins before + // per-community contexts recorded an id without creating it; + // faces worn there worked only because nothing checked. + let top_context_id = config.account.top_context_id.clone(); + let unchecked: Vec = config + .account + .memberships() + .map(|c| c.sub_context_id.clone()) + .filter(|id| { + openvtc_core::config::community_context::is_sub_context(id, &top_context_id) + && !contexts_checked.contains(id) + }) + .collect::>() + .into_iter() + .collect(); + if !unchecked.is_empty() + && let Some(client) = admin_vta.as_ref() + && in_flight.try_begin(background_dispatch::DispatchDomain::CommunityContexts) + { + contexts_checked.extend(unchecked.iter().cloned()); + let client = client.clone(); + background_dispatch::spawn_dispatch( + dispatch_tx.clone(), + background_dispatch::DispatchDomain::CommunityContexts, + async move { + let mut results = Vec::with_capacity(unchecked.len()); + for context_id in unchecked { + let name = + openvtc_core::config::context_path::parse_sub_context_id(&context_id) + .map_or(context_id.clone(), |(_, slug)| slug.to_string()); + let result = openvtc_core::config::community_context::ensure_context( + &client, + &top_context_id, + &context_id, + &name, + ) + .await + .map_err(|e| e.to_string()); + results.push((context_id, result)); + } + background_dispatch::DispatchOutcome::CommunityContexts(results) + }, + ); + } + + // Read each membership context's device grants, once a run, + // after registration has made sure the contexts exist — a + // grant listing for a context not yet created would read as + // a failure. + if !*grants_swept + && !in_flight.is_busy(background_dispatch::DispatchDomain::CommunityContexts) + && let Some(client) = admin_vta.as_ref() + { + let targets = community_context_actions::sweep_targets(config); + if !targets.is_empty() + && in_flight.try_begin(background_dispatch::DispatchDomain::DeviceGrantSweep) + { + *grants_swept = true; + background_dispatch::spawn_dispatch( + dispatch_tx.clone(), + background_dispatch::DispatchDomain::DeviceGrantSweep, + community_context_actions::sweep(client.clone(), targets), + ); + } + } + + // Probe what transports the VTA advertises, for the VTA + // panel. Runs on this tick rather than its own so the loop + // gains no extra timer. Re-probed only while the answer is + // still missing or failed — once a VTA has answered, its + // advertised services are stable for the session, so a + // healthy setup probes exactly once per launch (R1.4). + let needs_probe = state + .main_page + .content_panel + .vta + .transports + .advertised + .as_ref() + .is_none_or(|a| a.error.is_some()); + if needs_probe + && let openvtc_core::config::KeyBackend::Vta { vta_did, .. } = &config.key_backend + && !vta_did.is_empty() + && in_flight.try_begin(background_dispatch::DispatchDomain::VtaTransports) + { + let vta_did = vta_did.clone(); + background_dispatch::spawn_dispatch( + dispatch_tx.clone(), + background_dispatch::DispatchDomain::VtaTransports, + async move { + background_dispatch::DispatchOutcome::VtaTransports( + vta_transports::probe(vta_did).await, + ) + }, + ); + } + } +} From b221403804996499ad72327fa6e0ff55080ca13f Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 18:45:10 +1000 Subject: [PATCH 02/27] test(runtime): a harness for the runtime loop and its arms (#537) Assembles a real Runtime on an offline TDK, a listener-less messaging runtime and no admin session, and drives single arms (listener and lifecycle events, a failed save, a dispatch outcome, the VTA sync tick, a pong, the reply-window sweep, the pending-expiry sweep) and the whole loop until Exit or an interrupt stops it. Signed-off-by: Glenn Gore --- openvtc/src/state_handler/runtime/tests.rs | 259 +++++++++++++++++++++ 1 file changed, 259 insertions(+) diff --git a/openvtc/src/state_handler/runtime/tests.rs b/openvtc/src/state_handler/runtime/tests.rs index 8b137891..2530d9ca 100644 --- a/openvtc/src/state_handler/runtime/tests.rs +++ b/openvtc/src/state_handler/runtime/tests.rs @@ -1 +1,260 @@ +//! A harness for the runtime loop (#528, #537). +//! +//! The loop used to be one function nothing could call. Every piece of the +//! world it needs builds offline — `TDK` without an environment file, +//! `Messaging` with no listeners, an admin session that is simply absent — so +//! the harness assembles a real [`Runtime`] and drives one arm at a time, or +//! the whole loop until it is told to stop. +use std::time::{Duration, Instant}; + +use tokio::sync::{broadcast, mpsc, watch}; + +use super::*; +use crate::state_handler::{ + StartingMode, + dispatch_util::{test_config, test_tdk}, + main_page::content::{CapabilitiesPhase, CapabilitiesView}, +}; + +/// The ends of the runtime's channels a test plays the other side of. +struct Handles { + action_tx: mpsc::UnboundedSender, + interrupt_tx: broadcast::Sender, + /// Subscribed before the runtime starts, so it hears the terminator. + interrupt_seen: broadcast::Receiver, + state_rx: watch::Receiver, + _event_tx: mpsc::UnboundedSender, + _presence_tx: mpsc::UnboundedSender, +} + +async fn runtime() -> (Runtime, Handles) { + let (handler, state_rx) = StateHandler::new("runtime-test", StartingMode::NotSet); + let (action_tx, action_rx) = mpsc::unbounded_channel(); + let (interrupt_tx, interrupt_rx) = broadcast::channel(4); + let interrupt_seen = interrupt_tx.subscribe(); + let (event_tx, didcomm_event_rx) = mpsc::unbounded_channel(); + let (presence_tx, presence_rx) = mpsc::unbounded_channel(); + let shutdown_token = tokio_util::sync::CancellationToken::new(); + let didcomm_service = didcomm::start_empty_service(event_tx.clone(), shutdown_token.clone()); + let tsp_dirty = didcomm_service.tsp_store().dirty(); + let runtime = Runtime::assemble(Startup { + handler, + terminator: Terminator::new(interrupt_tx.clone()), + action_rx, + interrupt_rx, + state: State::default(), + tdk: test_tdk().await, + config: Box::new(test_config()), + admin_vta: None, + presence_rx, + vta_supervisor: vta_recovery::AdminVtaSupervisor::new( + None, + None, + openvtc_core::vta_receive_leg::ReceiveLegTracker::new(), + ), + didcomm_event_rx, + shutdown_token, + didcomm_service, + tsp_dirty, + }) + .await; + ( + runtime, + Handles { + action_tx, + interrupt_tx, + interrupt_seen, + state_rx, + _event_tx: event_tx, + _presence_tx: presence_tx, + }, + ) +} + +fn logged(state: &State, needle: &str) -> bool { + state + .main_page + .activity_log + .iter() + .any(|e| e.summary.contains(needle)) +} + +fn ago(secs: u64) -> Instant { + Instant::now() + .checked_sub(Duration::from_secs(secs)) + .expect("the clock is past the test's offset") +} + +#[tokio::test] +async fn an_account_with_no_community_assembles_to_no_active_community() { + let (rt, h) = runtime().await; + assert!(matches!( + rt.state.connection.status, + state::MediatorStatus::NoActiveCommunity + )); + assert!(logged(&rt.state, "No community membership yet")); + // Assembling published what it set up, before any event arrived. + assert!(matches!( + h.state_rx.borrow().connection.status, + state::MediatorStatus::NoActiveCommunity + )); +} + +#[tokio::test] +async fn exit_stops_the_loop_and_tells_the_terminator() { + let (rt, mut h) = runtime().await; + h.action_tx.send(Action::Exit).unwrap(); + let outcome = tokio::time::timeout(Duration::from_secs(10), rt.run()) + .await + .expect("the loop stops on Exit") + .expect("the loop returns its outcome"); + assert!(matches!(outcome, Interrupted::UserInt)); + assert!( + matches!(h.interrupt_seen.try_recv(), Ok(Interrupted::UserInt)), + "the UI is told to stop too, or it keeps reading keys nobody acts on" + ); +} + +#[tokio::test] +async fn an_interrupt_stops_the_loop_with_that_interrupt() { + let (rt, h) = runtime().await; + h.interrupt_tx + .send(Interrupted::OsSignal("SIGINT")) + .unwrap(); + let outcome = tokio::time::timeout(Duration::from_secs(10), rt.run()) + .await + .expect("the loop stops on an interrupt") + .expect("the loop returns its outcome"); + assert!(matches!(outcome, Interrupted::OsSignal("SIGINT"))); +} + +#[tokio::test] +async fn the_end_of_an_iteration_broadcasts_the_state() { + let (mut rt, mut h) = runtime().await; + h.state_rx.mark_unchanged(); + rt.state.main_page.log("a line only this iteration wrote"); + assert!(rt.end_of_iteration().await.is_none()); + assert!(h.state_rx.has_changed().unwrap()); + assert!(logged( + &h.state_rx.borrow(), + "a line only this iteration wrote" + )); +} + +#[tokio::test] +async fn a_listener_connecting_is_tracked_and_a_drop_untracked() { + let (mut rt, _h) = runtime().await; + rt.on_listener_event(Ok(didcomm::ListenerStatus::Connected { + listener_id: "listener-a".into(), + })) + .await; + assert!(rt.state.connection.online_listeners.contains("listener-a")); + rt.on_listener_event(Ok(didcomm::ListenerStatus::Disconnected { + listener_id: "listener-a".into(), + error: None, + })) + .await; + assert!(!rt.state.connection.online_listeners.contains("listener-a")); +} + +#[tokio::test] +async fn a_lifecycle_event_reaches_the_activity_log() { + let (mut rt, _h) = runtime().await; + let before = rt.state.main_page.activity_log.len(); + rt.on_lifecycle_log(didcomm::LifecycleLog::Connected { + listener_id: "listener-b".into(), + }) + .await; + assert_eq!(rt.state.main_page.activity_log.len(), before + 1); +} + +#[tokio::test] +async fn a_failed_save_is_said_and_left_dirty() { + let (mut rt, _h) = runtime().await; + rt.on_save_done(Err("disk full".into())).await; + assert!(logged(&rt.state, "Failed to save config")); +} + +#[tokio::test] +async fn a_dispatch_outcome_frees_its_domain() { + let (mut rt, _h) = runtime().await; + let domain = background_dispatch::DispatchDomain::AgentName; + assert!(rt.in_flight.try_begin(domain)); + rt.on_dispatch_outcome(background_dispatch::DispatchOutcome::AgentName(Vec::new())) + .await; + assert!(!rt.in_flight.is_busy(domain)); +} + +#[tokio::test] +async fn a_vta_sync_tick_without_a_session_starts_nothing() { + let (mut rt, _h) = runtime().await; + rt.on_vta_sync_tick().await; + assert!(rt.vta_sync_rx.try_recv().is_err()); +} + +#[tokio::test] +async fn a_pong_is_timed_against_the_ping_and_closes_it() { + let (mut rt, _h) = runtime().await; + rt.ping_sent_at = Some(ago(1)); + rt.on_inbound( + didcomm::DIDCommEvent::TrustPongReceived { + from: Some("did:example:peer".into()), + }, + None, + ) + .await; + assert!(rt.ping_sent_at.is_none()); + let status = rt + .state + .main_page + .content_panel + .relationships + .status_message + .as_ref() + .expect("the pong is reported where the ping was sent"); + assert!(status.text.contains("reachable")); +} + +#[tokio::test] +async fn an_unanswered_ping_runs_out_its_reply_window() { + let (mut rt, _h) = runtime().await; + rt.ping_sent_at = Some(ago(super::super::PING_REPLY_WINDOW.as_secs() + 1)); + rt.on_capabilities_sweep().await; + assert!(rt.ping_sent_at.is_none()); + assert!(logged(&rt.state, "No reply to the ping")); +} + +#[tokio::test] +async fn an_unanswered_capability_query_fails_closed() { + let (mut rt, _h) = runtime().await; + let mut view = CapabilitiesView::new( + "did:example:vtc".into(), + openvtc_core::config::account::PersonaId::new(), + "A community".into(), + ); + view.pending_thid = Some("thid-1".into()); + view.sent_at = Some(ago(31)); + rt.state.main_page.content_panel.capabilities.view = Some(view); + rt.on_capabilities_sweep().await; + let view = rt + .state + .main_page + .content_panel + .capabilities + .view + .as_ref() + .unwrap(); + assert!(view.pending_thid.is_none()); + assert!(matches!(view.phase, CapabilitiesPhase::Failed(_))); +} + +#[tokio::test] +async fn the_pending_expiry_sweep_over_an_empty_account_changes_nothing() { + let (mut rt, _h) = runtime().await; + rt.on_pending_expiry().await; + assert!( + !rt.save.needs_flush(), + "nothing changed, so nothing to save" + ); +} From 5690fb36a4fd0d00af885e1c65b5350c3881a956 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 18:56:05 +1000 Subject: [PATCH 03/27] perf(tui): share the published State as Arc (#556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state channel is now watch::Sender>. Publishing clones the state once, into the Arc (PublishState::publish), and the UI's copies share it: the main page and join flow props hold a Shared projection of the snapshot instead of cloning the main-page model and the join state on every update, and the setup and loading props are refreshed only while their page is on screen. A page that edits its props locally still can: the first write copies the part, as every update used to. Every publish still sends — no dirty tracking, so R23's stale-UI objection does not apply. The two timer arms that can prove they changed nothing (the VTA sync tick that starts no cycle, the session reconcile that changes no session) skip the end-of-iteration work and its broadcast. Signed-off-by: Glenn Gore --- openvtc/src/setup_cmd.rs | 2 +- openvtc/src/state_handler/join_flow.rs | 108 +++++++++--------- openvtc/src/state_handler/mod.rs | 29 ++--- openvtc/src/state_handler/runtime/arms.rs | 16 ++- openvtc/src/state_handler/runtime/mod.rs | 34 ++++-- openvtc/src/state_handler/runtime/tests.rs | 4 +- openvtc/src/state_handler/runtime/timers.rs | 6 +- openvtc/src/state_handler/runtime_actions.rs | 10 +- openvtc/src/state_handler/settings_actions.rs | 14 ++- .../state_handler/setup_sequence/config.rs | 14 ++- .../setup_sequence/openpgp_card.rs | 22 ++-- .../src/state_handler/setup_token_actions.rs | 6 +- .../src/state_handler/setup_vta_actions.rs | 46 ++++---- openvtc/src/state_handler/setup_wizard.rs | 8 +- openvtc/src/state_handler/state.rs | 23 ++++ openvtc/src/ui/component.rs | 6 +- openvtc/src/ui/mod.rs | 3 +- .../src/ui/pages/join_flow/context_choice.rs | 2 +- .../src/ui/pages/join_flow/identity_choice.rs | 2 +- .../ui/pages/join_flow/invitation_choice.rs | 2 +- .../src/ui/pages/join_flow/join_progress.rs | 2 +- openvtc/src/ui/pages/join_flow/mod.rs | 27 ++--- .../ui/pages/join_flow/registry_consent.rs | 5 +- .../pages/join_flow/vetting_requirements.rs | 2 +- .../src/ui/pages/join_flow/vtc_enter_did.rs | 6 +- openvtc/src/ui/pages/loading/mod.rs | 15 ++- openvtc/src/ui/pages/main/mod.rs | 19 +-- openvtc/src/ui/pages/mod.rs | 8 +- openvtc/src/ui/pages/setup_flow/mod.rs | 17 ++- .../pages/setup_flow/vta_acl_instructions.rs | 10 +- .../ui/pages/setup_flow/vta_provisioning.rs | 2 +- openvtc/src/ui/shared.rs | 108 ++++++++++++++++++ 32 files changed, 387 insertions(+), 191 deletions(-) create mode 100644 openvtc/src/ui/shared.rs diff --git a/openvtc/src/setup_cmd.rs b/openvtc/src/setup_cmd.rs index 08bea903..cf82031c 100644 --- a/openvtc/src/setup_cmd.rs +++ b/openvtc/src/setup_cmd.rs @@ -108,7 +108,7 @@ pub async fn run(args: &ArgMatches, profile: &str) -> Result<()> { let mut state = State::default(); state.setup.protection = ConfigProtection::Passcode(Arc::new(key)); // Nothing renders state here; sends to a closed channel are ignored. - let (state_tx, _) = watch::channel(State::default()); + let (state_tx, _) = watch::channel(std::sync::Arc::new(State::default())); // 2. Resolve the VTA and mint the setup DID. step(format!("Resolving VTA {vta_did}")); diff --git a/openvtc/src/state_handler/join_flow.rs b/openvtc/src/state_handler/join_flow.rs index e6634b62..c42096c7 100644 --- a/openvtc/src/state_handler/join_flow.rs +++ b/openvtc/src/state_handler/join_flow.rs @@ -12,6 +12,8 @@ //! is surfaced into the join log as a [`MessageType::Error`] — the loop never //! `?`-bubbles a sequence error in a way that would kill the app. +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; use std::time::Duration; use affinidi_tdk::TDK; @@ -1459,7 +1461,7 @@ impl StateHandler { ) .await { - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); return Ok(exit); } } @@ -1567,19 +1569,19 @@ impl StateHandler { } } } - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); loop { // A launch that stopped to ask a hidden-vetting community for its // challenge leaves the flow here, whichever page or entry started // it: only the caller's loop hears the answer. if let Some(awaiting) = state.join.challenge_wait.take() { - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); return Ok(JoinExit::AwaitChallenge(awaiting)); } // Likewise a wallet session: the runtime loop hears its answers. if let Some(awaiting) = state.join.wallet_wait.take() { - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); return Ok(JoinExit::AwaitWallet(awaiting)); } tokio::select! { @@ -1618,7 +1620,7 @@ impl StateHandler { .invitation_credential .as_ref() .and_then(|vic| foreign_invitation_subject(config, vic)); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); } Action::JoinClipboardFailed(why) => { // Only the failure reaches the loop: `[Ctrl+V]` reads @@ -1631,7 +1633,7 @@ impl StateHandler { this screen instead — that works over SSH, where reading \ the clipboard cannot." ))); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); } Action::JoinClearVic => { // Explicit "proceed without a VIC": drop the loaded @@ -1643,7 +1645,7 @@ impl StateHandler { state.join.invitation_foreign_subject = None; state.join.vic_cleared = true; state.join.messages.clear(); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); } Action::JoinSubmitVtc(vtc_did) => { let vtc_did = match validate_join_input(&vtc_did) { @@ -1653,7 +1655,7 @@ impl StateHandler { // that reads as a frozen screen (issue #29). Err(why) => { state.join.messages.push(MessageType::Error(why)); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); continue; } }; @@ -1671,7 +1673,7 @@ impl StateHandler { state.join.messages.push(MessageType::Info(format!( "Resolving {vtc_did}…" ))); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); match openvtc_core::agent_name::resolve_identifier( tdk.did_resolver(), &vtc_did, @@ -1681,7 +1683,7 @@ impl StateHandler { Ok(did) => did, Err(e) => { state.join.fail(e.to_string()); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); continue; } } @@ -1702,7 +1704,7 @@ impl StateHandler { ) .await { - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); return Ok(exit); } } @@ -1711,12 +1713,12 @@ impl StateHandler { state.join.identity_selected = i.min(state.join.mint_row()); // Moving the highlight dismisses any armed warning. state.join.reuse_confirm = None; - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); } Action::JoinNewPersonaMethodCycle => { state.join.new_persona_did_method = next_did_method(state.join.new_persona_did_method); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); } Action::JoinIdentityChoose => { if state.join.mint_row_selected() { @@ -1749,7 +1751,7 @@ impl StateHandler { { // Arm the cross-community linkage warning (D1). state.join.reuse_confirm = Some(opt.id); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); } } Action::JoinReuseConfirm => { @@ -1763,17 +1765,17 @@ impl StateHandler { } state.join.reuse_confirm = None; open_invitation_choice(state, config, persona_id); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); } Action::JoinReuseCancel => { state.join.reuse_confirm = None; - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); } Action::JoinInvitationSelect(i) => { // Rows: 0..len an invitation, len = paste, len+1 = without. let max = state.join.invitation_without_row(); state.join.invitation_use_selected = i.min(max); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); } Action::JoinInvitationChoose => { let Some(vtc_did) = state.join.pending_vtc.clone() else { @@ -1804,7 +1806,7 @@ impl StateHandler { ))); } } - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); continue; } // A selected invitation row presents that VIC; the @@ -1857,7 +1859,7 @@ impl StateHandler { then join again." .into(), ); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); continue; }; let missing = answers.unanswered(); @@ -1867,7 +1869,7 @@ impl StateHandler { Pick another face, or add it to this one under Identity.", missing.join(", ") )); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); continue; } // What the holder approves is these values, exactly. @@ -2027,7 +2029,7 @@ impl StateHandler { .join .messages .push(MessageType::Error(e.to_string())); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); continue; } } @@ -2074,7 +2076,7 @@ impl StateHandler { "{}: {why}", option.label ))); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); continue; } // A route that starts with the join doing something: @@ -2089,7 +2091,7 @@ impl StateHandler { state.join.messages.clear(); state.join.resume_route = Some(option.route); super::handle_nav_action(state, &Action::StartCreatePersona); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); continue; } if let Some(exit) = self @@ -2250,7 +2252,7 @@ impl StateHandler { && let Ok(awaiting) = ask_requirements(state, config, tdk, messaging, &vtc_did).await { - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); return Ok(JoinExit::AwaitRequirements(awaiting)); } } @@ -2261,7 +2263,7 @@ impl StateHandler { return Ok(JoinExit::Exit(interrupted)); } } - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); } } @@ -2531,11 +2533,11 @@ impl StateHandler { let Some(job) = super::advance_persona_overlay(state, config, tdk, admin_vta, &progress_tx) else { // A phase moved on, or refused and said why on the overlay. - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); return None; }; drop(progress_tx); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); let run = job.run(); tokio::pin!(run); @@ -2547,7 +2549,7 @@ impl StateHandler { && let Some(o) = state.main_page.create_persona.as_mut() { o.messages.push(line); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); } } Ok(interrupted) = interrupt_rx.recv() => return Some(interrupted), @@ -2603,7 +2605,7 @@ impl StateHandler { } } } - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); None } @@ -2664,7 +2666,7 @@ impl StateHandler { state.join.reuse_confirm = None; state.join.page = JoinPage::IdentityChoice; } - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); None } @@ -2698,7 +2700,7 @@ impl StateHandler { .is_some_and(|a| a.approved.is_some()); if !asked.is_empty() && !answered { open_answers(state, admin_vta, asked, (choice, vtc_did, context_id)).await; - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); return None; } @@ -2714,7 +2716,7 @@ impl StateHandler { "{name} published a statement of what its member directory would show about you, and it could not be read ({}). Nothing has been sent: joining without seeing it is not offered. Ask the community to fix it, then try again.", sanitize_display(why, 200) )); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); return None; } @@ -2745,7 +2747,7 @@ impl StateHandler { registry.review = Some(review); registry.directory = directory; } - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); return None; } @@ -2759,7 +2761,7 @@ impl StateHandler { }); state.join.page = JoinPage::Wallet; state.join.processing = false; - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); return None; } @@ -2805,7 +2807,7 @@ impl StateHandler { } Err(reason) => state.join.fail(challenge_failed_words(&name, &reason)), } - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); return None; } @@ -2815,7 +2817,7 @@ impl StateHandler { state.join.completed = Completion::NotFinished; state.join.messages.clear(); state.join.info(format!("Joining {vtc_did}…")); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); // R15: race the multi-step VTA sequence against the interrupt so Ctrl-C / // Exit stay live for its whole (network-bound) duration. On interrupt the @@ -2869,12 +2871,12 @@ impl StateHandler { "Join cancelled. Any partially-minted persona was rolled back; a sub-context may remain at the VTA.", ); state.main_page.log("Join cancelled by user."); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); return Some(interrupted); } state.join.processing = false; - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); None } } @@ -3553,7 +3555,7 @@ async fn run_join_sequence( state .join .info(format!("Community reachable over {peer_preferred}.")); - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); let top_context_id = config.account.top_context_id.clone(); @@ -3574,7 +3576,7 @@ async fn run_join_sequence( return; } } - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); // Resolve the persona to present: reuse an existing account persona (R-B-3) // or mint a fresh, self-contained one (D6). Only a *minted* persona is @@ -3606,7 +3608,7 @@ async fn run_join_sequence( } let did = ident.persona_did().to_string(); state.join.info(format!("Reusing persona {did}…")); - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); (persona_id, did) } None => { @@ -3635,7 +3637,7 @@ async fn run_join_sequence( state .join .info("Creating persona keys (signing, authentication, encryption)…"); - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); match vta::create_persona_keys(admin_vta, Some(&context_id)).await { Ok(keys) => state.setup.did_keys = Some(keys), Err(e) => { @@ -3647,7 +3649,7 @@ async fn run_join_sequence( } // WebVH update keys. state.join.info("Creating DID update keys…"); - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); match vta::create_update_keys(admin_vta, Some(&context_id)).await { Ok((update, next_update)) => { state.setup.vta.update_secret = Some(update); @@ -3663,7 +3665,7 @@ async fn run_join_sequence( // Pick the first WebVH server. Serverless mint is a deliberate follow-up. state.join.info("Finding a DID hosting server…"); - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); let server_id = match vta::list_webvh_servers(admin_vta).await { Ok(servers) => match servers.into_iter().next() { Some(s) => s.id, @@ -3686,7 +3688,7 @@ async fn run_join_sequence( state .join .info(format!("Creating persona DID via {server_id}…")); - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); // How the new persona is presented: the operator's choice on the // identity page, else the account's preference (VTI-VTA-036). let presentation = openvtc_core::config::account::persona_presentation( @@ -3762,7 +3764,7 @@ async fn run_join_sequence( } let persona_did = state.setup.webvh_address.did.clone(); state.join.info(format!("Persona created: {persona_did}")); - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); (persona_id, persona_did) } }; @@ -3786,7 +3788,7 @@ async fn run_join_sequence( // handler lands, the request message id is the correlation handle // stored on the Pending record. state.join.info("Submitting join request…"); - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); if tdk.atm.is_none() { state @@ -3947,7 +3949,7 @@ async fn run_join_sequence( } // (When the operator chose to join without an invitation, the note was // already surfaced above where the VIC was suppressed.) - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); // Subject-linkage (#1b): when the presenting DID differs from the VIC // subject, prove the subject authorized this presenter (signed with the // subject persona's key). On the join-as-subject path (#1a) this is `None`. @@ -3989,7 +3991,7 @@ async fn run_join_sequence( state .join .info("Answering the community's questions from your face…"); - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); let answered = answer_from_face( admin_vta, &sub_context_id, @@ -4095,7 +4097,7 @@ async fn run_join_sequence( .await; if let Some(note) = route.note { state.join.info(note.describe()); - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); } let submit_transport = route.transport(); // Last gate before the request goes out: the socket started above has had @@ -4298,14 +4300,14 @@ async fn start_persona_listener( "Couldn't open this persona's mediator session ({e}) — submitting anyway; the \ community's reply will be collected when it connects." )); - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); return None; } state .join .info("Connecting the new persona to its mediator…"); - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); Some(listener_id) } @@ -4391,7 +4393,7 @@ async fn await_persona_online( false } }; - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); online } diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index 60172ec4..bd591ec3 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -1,4 +1,6 @@ use crate::state_handler::main_page::status::Status; +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; use crate::{ Interrupted, Terminator, state_handler::{ @@ -341,7 +343,7 @@ pub enum StartingMode { } pub struct StateHandler { - state_tx: tokio::sync::watch::Sender, + state_tx: tokio::sync::watch::Sender>, profile: String, starting_mode: StartingMode, /// Invitation credential (VIC) supplied at launch via `--invitation`, seeded @@ -461,8 +463,9 @@ impl StateHandler { pub fn new( profile: &str, starting_mode: StartingMode, - ) -> (Self, tokio::sync::watch::Receiver) { - let (state_tx, state_rx) = tokio::sync::watch::channel(State::default()); + ) -> (Self, tokio::sync::watch::Receiver>) { + let (state_tx, state_rx) = + tokio::sync::watch::channel(std::sync::Arc::new(State::default())); ( StateHandler { @@ -533,7 +536,7 @@ impl StateHandler { state.main_page.sync_from_config(&config); state.connection.status = state::MediatorStatus::Initializing("Loading credentials...".into()); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(&state); // The setup wizard saved the config but the TDK secrets // resolver is empty. Load persona key secrets so the @@ -584,7 +587,7 @@ impl StateHandler { community: String::new(), }; state.connection.status = state::MediatorStatus::Initializing("Starting...".into()); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(&state); // Spawn TDK init + config load as a background task with // hierarchical progress reporting: each event is (major, sub). @@ -693,13 +696,13 @@ impl StateHandler { state::MediatorStatus::Initializing( format!("{major} — {sub}"), ); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(&state); } // Token-touch notifications arrive through the dedicated channel // so that state is mutated only here, inside the StateHandler loop. Some(pending) = token_touch_rx.recv() => { state.token_touch_pending = pending; - self.state_tx.send(state.clone())?; + self.state_tx.publish(&state)?; } result = &mut load_handle => { match result { @@ -715,7 +718,7 @@ impl StateHandler { state.connection.status = state::MediatorStatus::Failed( state::ConnectionFailure::of(&e), ); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(&state); // No config loaded here — join is unavailable. return self .run_degraded_loop_terminal( @@ -741,7 +744,7 @@ impl StateHandler { openvtc_core::errors::ErrorClass::Local, ), ); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(&state); return self .run_degraded_loop_terminal( &mut action_rx, @@ -1050,7 +1053,7 @@ impl StateHandler { // to receive the approval credential. let (tdk, config, admin_vta) = if config.active_identity().is_none() { state.connection.status = state::MediatorStatus::NoActiveCommunity; - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(&state); // No persona yet (State A). Hand the always-on admin session to the // degraded loop so the Communities `j` → join flow (R-A-5 Stage 4) // can reuse it; the loop closes it on exit (or hands it back on a @@ -1326,7 +1329,7 @@ impl StateHandler { } else { "Starting secure messaging…" }); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); break DegradedOutcome::Joined(Box::new( join_ctx .take() @@ -1724,7 +1727,7 @@ impl StateHandler { state .main_page .log("Persona created — starting secure messaging…"); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); break DegradedOutcome::Joined(Box::new( join_ctx .take() @@ -1752,7 +1755,7 @@ impl StateHandler { break DegradedOutcome::Exit(interrupted); } } - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); }; // Close the always-on admin VTA session owned by the join context, if diff --git a/openvtc/src/state_handler/runtime/arms.rs b/openvtc/src/state_handler/runtime/arms.rs index 631b9a20..d36e7bf1 100644 --- a/openvtc/src/state_handler/runtime/arms.rs +++ b/openvtc/src/state_handler/runtime/arms.rs @@ -3,6 +3,8 @@ //! supervisor, listener lifecycle, the TSP store, saves and VTA account sync. use super::*; +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; use crate::state_handler::*; impl Runtime { @@ -278,7 +280,7 @@ impl Runtime { ); } state.live_siblings = siblings; - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); } device_presence::PresenceReport::Failing { what, @@ -409,7 +411,9 @@ impl Runtime { } } - pub(super) async fn on_session_reconcile(&mut self) { + /// The backstop for the edge-triggered listener-status stream. Returns + /// whether a session's status changed. + pub(super) async fn on_session_reconcile(&mut self) -> bool { let Runtime { state, didcomm_service, @@ -418,7 +422,7 @@ impl Runtime { } = self; // The loop tail broadcasts `state`, so a change here reaches // the UI without an explicit send. - reconcile_sessions(session_manager, didcomm_service, state); + reconcile_sessions(session_manager, didcomm_service, state) } /// A background TSP relationship write (an invite recorded by the @@ -495,7 +499,9 @@ impl Runtime { save.finish(result.is_ok()); } - pub(super) async fn on_vta_sync_tick(&mut self) { + /// Start a VTA account-sync cycle when one is due. Returns whether one + /// started. + pub(super) async fn on_vta_sync_tick(&mut self) -> bool { let Runtime { config, admin_vta, @@ -522,7 +528,9 @@ impl Runtime { let ex = openvtc_core::vta_sync::exchange(&client, &context_id, since, out).await; let _ = tx.send(ex); }); + return true; } + false } pub(super) async fn on_vta_sync_exchange(&mut self, ex: openvtc_core::vta_sync::Exchange) { diff --git a/openvtc/src/state_handler/runtime/mod.rs b/openvtc/src/state_handler/runtime/mod.rs index 73e7015b..6be644cd 100644 --- a/openvtc/src/state_handler/runtime/mod.rs +++ b/openvtc/src/state_handler/runtime/mod.rs @@ -22,6 +22,8 @@ //! The single-mutator invariant is unchanged: only these methods touch //! `state` and `config`, and they all run on the loop's task. +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; mod arms; mod inbound_arm; #[cfg(test)] @@ -53,6 +55,11 @@ use crate::{ pub(super) enum Flow { /// Carry on: run the end-of-iteration work and wait for the next event. Continue, + /// The arm provably changed nothing: skip the end-of-iteration work, + /// including the state broadcast (#556). Only for arms that touched + /// neither `state` nor `config` — there is no dirty tracking, so an arm + /// that might have changed something returns [`Flow::Continue`]. + Quiet, /// Stop the loop with this outcome. The arm has already told the /// terminator when that is its job (an `Exit` action does; an interrupt, /// which came *from* the terminator, does not). @@ -211,7 +218,7 @@ impl Runtime { } else { state::MediatorStatus::NoActiveCommunity }; - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(&state); // Install this account's listeners on the runtime started above. Skips // any that already exist, so a State-A join's own persona listener is @@ -324,7 +331,7 @@ impl Runtime { .log("No community membership yet — no community session to connect."), _ => state.main_page.log("Connecting to the mediator…"), } - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(&state); // Background-dispatch plumbing (R13). Network-bound actions whose await // would otherwise park the whole select loop are spawned as background @@ -532,8 +539,11 @@ impl Runtime { Flow::Continue } _ = self.session_reconcile_tick.tick() => { - self.on_session_reconcile().await; - Flow::Continue + if self.on_session_reconcile().await { + Flow::Continue + } else { + Flow::Quiet + } } _ = self.capabilities_sweep.tick() => { self.on_capabilities_sweep().await; @@ -571,9 +581,13 @@ impl Runtime { self.on_save_done(result).await; Flow::Continue } + // Every two seconds, and almost always with nothing to start. _ = self.vta_sync_tick.tick() => { - self.on_vta_sync_tick().await; - Flow::Continue + if self.on_vta_sync_tick().await { + Flow::Continue + } else { + Flow::Quiet + } } Some(ex) = self.vta_sync_rx.recv() => { self.on_vta_sync_exchange(ex).await; @@ -583,8 +597,10 @@ impl Runtime { // Catch and handle interrupt signal to gracefully shutdown Ok(interrupted) = self.interrupt_rx.recv() => Flow::Exit(interrupted), }; - if let Flow::Exit(interrupted) = flow { - break interrupted; + match flow { + Flow::Exit(interrupted) => break interrupted, + Flow::Quiet => continue, + Flow::Continue => {} } if let Some(interrupted) = self.end_of_iteration().await { break interrupted; @@ -693,7 +709,7 @@ impl Runtime { self.config.set_active_persona(active_persona); self.state.main_page.sync_from_config(&self.config); } - let _ = self.handler.state_tx.send(self.state.clone()); + let _ = self.handler.state_tx.publish(&self.state); None } diff --git a/openvtc/src/state_handler/runtime/tests.rs b/openvtc/src/state_handler/runtime/tests.rs index 2530d9ca..d0e92e23 100644 --- a/openvtc/src/state_handler/runtime/tests.rs +++ b/openvtc/src/state_handler/runtime/tests.rs @@ -23,7 +23,7 @@ struct Handles { interrupt_tx: broadcast::Sender, /// Subscribed before the runtime starts, so it hears the terminator. interrupt_seen: broadcast::Receiver, - state_rx: watch::Receiver, + state_rx: watch::Receiver>, _event_tx: mpsc::UnboundedSender, _presence_tx: mpsc::UnboundedSender, } @@ -189,7 +189,7 @@ async fn a_dispatch_outcome_frees_its_domain() { #[tokio::test] async fn a_vta_sync_tick_without_a_session_starts_nothing() { let (mut rt, _h) = runtime().await; - rt.on_vta_sync_tick().await; + assert!(!rt.on_vta_sync_tick().await, "quiet: nothing started"); assert!(rt.vta_sync_rx.try_recv().is_err()); } diff --git a/openvtc/src/state_handler/runtime/timers.rs b/openvtc/src/state_handler/runtime/timers.rs index 44855b17..29c544ac 100644 --- a/openvtc/src/state_handler/runtime/timers.rs +++ b/openvtc/src/state_handler/runtime/timers.rs @@ -1,6 +1,8 @@ //! The runtime's periodic sweeps. use super::*; +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; use crate::state_handler::*; impl Runtime { @@ -323,7 +325,7 @@ impl Runtime { expired.len(), if expired.len() == 1 { "" } else { "s" }, )); - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); } // The other thing that lapses by the clock: our own `vetter` @@ -370,7 +372,7 @@ impl Runtime { } save.mark_dirty(); state.main_page.sync_from_config(config); - let _ = handler.state_tx.send(state.clone()); + let _ = handler.state_tx.publish(state); } } diff --git a/openvtc/src/state_handler/runtime_actions.rs b/openvtc/src/state_handler/runtime_actions.rs index 973429b1..a6693afd 100644 --- a/openvtc/src/state_handler/runtime_actions.rs +++ b/openvtc/src/state_handler/runtime_actions.rs @@ -17,6 +17,8 @@ //! receiver this one is selecting on. use crate::state_handler::main_page::status::Status; +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; use tokio::sync::{mpsc::UnboundedSender, watch}; use crate::state_handler::actions::Action; @@ -57,7 +59,7 @@ pub(crate) struct ActionCtx<'a> { /// state rather than `State`: it is a stopwatch, not something the UI /// renders, and it is read by the inbound arm that stays in the loop. pub(crate) ping_sent_at: &'a mut Option, - pub(crate) state_tx: &'a watch::Sender, + pub(crate) state_tx: &'a watch::Sender>, pub(crate) profile: &'a str, /// Where the inbound arm hands a witness's answers to a witnessing in progress. pub(crate) witness_router: &'a openvtc_core::witness::ReplyRouter, @@ -947,7 +949,7 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha if let Some(overlay) = ctx.state.main_page.create_persona.as_mut() { overlay.copied = copied; } - let _ = ctx.state_tx.send(ctx.state.clone()); + let _ = ctx.state_tx.publish(ctx.state); } } Action::Inbox(ia) => { @@ -1510,7 +1512,7 @@ mod tests { didcomm_service: Messaging, session_manager: SessionManager, ping_sent_at: Option, - state_tx: watch::Sender, + state_tx: watch::Sender>, witness_router: openvtc_core::witness::ReplyRouter, } @@ -1522,7 +1524,7 @@ mod tests { /// thing as one that succeeds. async fn new(with_session: bool) -> Self { let (dispatch_tx, dispatch_rx) = mpsc::unbounded_channel(); - let (state_tx, _state_rx) = watch::channel(State::default()); + let (state_tx, _state_rx) = watch::channel(std::sync::Arc::new(State::default())); let (event_tx, _event_rx) = mpsc::unbounded_channel(); Self { state: State::default(), diff --git a/openvtc/src/state_handler/settings_actions.rs b/openvtc/src/state_handler/settings_actions.rs index 081f4fc9..804f54c3 100644 --- a/openvtc/src/state_handler/settings_actions.rs +++ b/openvtc/src/state_handler/settings_actions.rs @@ -1,6 +1,8 @@ //! Settings action handlers for the TUI. use crate::state_handler::main_page::status::Status; +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; use anyhow::Result; #[cfg(feature = "openpgp-card")] use openvtc_core::config::ConfigProtectionType; @@ -325,7 +327,7 @@ fn handle_submit_edit( async fn handle_export_config_action( config: &mut Box, state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, save: &mut SaveScheduler, profile: &str, path: &str, @@ -338,7 +340,7 @@ async fn handle_export_config_action( // drawing this status (the runtime is no longer pegged by the KDF). state.main_page.content_panel.settings.status_message = Some(Status::progress("Deriving encryption key…".to_string())); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); match export_config(config, path, passphrase).await { Ok(()) => { @@ -432,7 +434,7 @@ fn handle_change_protection(state: &mut State) { async fn handle_set_passphrase( config: &mut Box, state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, save: &mut SaveScheduler, profile: &str, passphrase: &SecretString, @@ -443,7 +445,7 @@ async fn handle_set_passphrase( // `spawn_blocking` derive, during which the render task keeps drawing. state.main_page.content_panel.settings.status_message = Some(Status::progress("Deriving encryption key…".to_string())); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); // R11 force-flush: a protection change re-derives the unlock key and rewrites // the secured config; it MUST be persisted before reporting success (the @@ -991,7 +993,7 @@ pub(crate) async fn dispatch( action: SettingsAction, config: &mut Box, state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, save: &mut SaveScheduler, profile: &str, ) -> SettingsOutcome { @@ -1230,7 +1232,7 @@ mod tests { let mut config = Box::new(crate::state_handler::dispatch_util::test_config()); let mut state = State::default(); let mut save = crate::state_handler::save_coalesce::SaveScheduler::new("test"); - let (state_tx, _rx) = watch::channel(State::default()); + let (state_tx, _rx) = watch::channel(std::sync::Arc::new(State::default())); for idx in [ rows::FRIENDLY_NAME_ROW, diff --git a/openvtc/src/state_handler/setup_sequence/config.rs b/openvtc/src/state_handler/setup_sequence/config.rs index 236b2ddf..c404454a 100644 --- a/openvtc/src/state_handler/setup_sequence/config.rs +++ b/openvtc/src/state_handler/setup_sequence/config.rs @@ -1,5 +1,7 @@ /*! Contains specific Config extensions for the CLI Application. */ +use crate::state_handler::state::PublishState; + use affinidi_tdk::{TDK, messaging::profiles::ATMProfile, secrets_resolver::SecretsResolver}; use anyhow::{Result, bail}; use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD}; @@ -40,7 +42,7 @@ pub trait ConfigExtension { /// profile: Profile name to import the configuration into fn import( state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, import_unlock_passphrase: &SecretString, new_unlock_passphrase: &SecretString, file: &str, @@ -131,7 +133,7 @@ impl ConfigExtension for Config { /// Import previously exported configuration settings from an encrypted file fn import( state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, import_unlock_passphrase: &SecretString, new_unlock_passphrase: &SecretString, file: &str, @@ -147,7 +149,7 @@ impl ConfigExtension for Config { .push(MessageType::Error(format!( "Couldn't read from file ({file}). Reason: {e}" ))); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); bail!("File read error"); } }; @@ -162,7 +164,7 @@ impl ConfigExtension for Config { .push(MessageType::Error(format!( "Couldn't base64 decode file content. Reason: {e}" ))); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); bail!("base64 decoding error"); } }; @@ -187,7 +189,7 @@ impl ConfigExtension for Config { .push(MessageType::Error(format!( "Couldn't deserialize configuration settings. Reason: {e}" ))); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); bail!("deserialization error"); } }; @@ -259,7 +261,7 @@ impl ConfigExtension for Config { .push(MessageType::Info( "Please touch token hardware to unlock keys".to_string(), )); - let _ = state_tx_clone.send(state_mut); + let _ = state_tx_clone.publish(&state_mut); }) .map_err(|e| anyhow::anyhow!("Couldn't save Secured Config: {e}"))?; } diff --git a/openvtc/src/state_handler/setup_sequence/openpgp_card.rs b/openvtc/src/state_handler/setup_sequence/openpgp_card.rs index b51a09f6..431db53d 100644 --- a/openvtc/src/state_handler/setup_sequence/openpgp_card.rs +++ b/openvtc/src/state_handler/setup_sequence/openpgp_card.rs @@ -2,6 +2,8 @@ * Handles writing of data to the OpenPGP Card */ +use crate::state_handler::state::PublishState; + use anyhow::{Result, bail}; use ed25519_dalek_bip32::VerifyingKey; use openpgp_card::{ @@ -29,7 +31,7 @@ use crate::state_handler::{setup_sequence::MessageType, state::State}; /// Writes keys to the card pub fn write_keys_to_card( state: &mut State, - action_tx: &watch::Sender, + action_tx: &watch::Sender>, card: Arc>>, ) -> Result<()> { state @@ -45,7 +47,7 @@ pub fn write_keys_to_card( state.setup.token_reset.messages.push(MessageType::Info( "Unlocking token in admin mode...".to_string(), )); - let _ = action_tx.send(state.clone()); + let _ = action_tx.publish(state); // Try unlocking the card with the admin PIN let mut lock = card .try_lock() @@ -63,13 +65,13 @@ pub fn write_keys_to_card( if let Some(last) = state.setup.token_reset.messages.last_mut() { *last = MessageType::Info("✓ Unlocked token in admin mode".to_string()); } - let _ = action_tx.send(state.clone()); + let _ = action_tx.publish(state); // Create a PGP secret key packet state.setup.token_reset.messages.push(MessageType::Info( "Writing signing key to token...".to_string(), )); - let _ = action_tx.send(state.clone()); + let _ = action_tx.publish(state); let uk = create_pgp_secret_packet(&keys.signing, KeyPurpose::Signing)?; card.import_key(&uk, KeyType::Signing)?; if let Some(last) = state.setup.token_reset.messages.last_mut() { @@ -79,7 +81,7 @@ pub fn write_keys_to_card( state.setup.token_reset.messages.push(MessageType::Info( "Writing authentication key to token...".to_string(), )); - let _ = action_tx.send(state.clone()); + let _ = action_tx.publish(state); let uk = create_pgp_secret_packet(&keys.authentication, KeyPurpose::Authentication)?; card.import_key(&uk, KeyType::Authentication)?; if let Some(last) = state.setup.token_reset.messages.last_mut() { @@ -89,7 +91,7 @@ pub fn write_keys_to_card( state.setup.token_reset.messages.push(MessageType::Info( "Writing decryption key to token...".to_string(), )); - let _ = action_tx.send(state.clone()); + let _ = action_tx.publish(state); let uk = create_pgp_secret_packet(&keys.decryption, KeyPurpose::Encryption)?; card.import_key(&uk, KeyType::Decryption)?; if let Some(last) = state.setup.token_reset.messages.last_mut() { @@ -222,7 +224,7 @@ fn create_pgp_secret_packet(key: &KeyInfo, kp: KeyPurpose) -> Result, + action_tx: &watch::Sender>, card: Arc>>, ) -> Result<()> { let mut lock = card @@ -242,7 +244,7 @@ pub fn set_signing_touch_policy( state.setup.token_set_touch.messages.push(MessageType::Info( "Setting touch policy on signing key...".to_string(), )); - let _ = action_tx.send(state.clone()); + let _ = action_tx.publish(state); card.set_touch_policy(KeyType::Signing, TouchPolicy::On)?; state.setup.token_set_touch.messages.push(MessageType::Info( @@ -256,7 +258,7 @@ pub fn set_signing_touch_policy( /// name: Max length is 39 characters pub fn set_cardholder_name( state: &mut State, - action_tx: &watch::Sender, + action_tx: &watch::Sender>, card: Arc>>, name: &str, ) -> Result<()> { @@ -281,7 +283,7 @@ pub fn set_cardholder_name( .push(MessageType::Info(format!( "Setting cardholder name to ({name})..." ))); - let _ = action_tx.send(state.clone()); + let _ = action_tx.publish(state); card.set_cardholder_name(name)?; state .setup diff --git a/openvtc/src/state_handler/setup_token_actions.rs b/openvtc/src/state_handler/setup_token_actions.rs index a7cd3917..5ba7ce81 100644 --- a/openvtc/src/state_handler/setup_token_actions.rs +++ b/openvtc/src/state_handler/setup_token_actions.rs @@ -93,7 +93,7 @@ pub(crate) async fn handle_factory_reset(state: &mut State, token: Option, + state_tx: &watch::Sender>, token: Option>>>, ) { use crate::state_handler::setup_sequence::openpgp_card::write_keys_to_card; @@ -148,7 +148,7 @@ pub(crate) async fn handle_token_write_keys( #[cfg(feature = "openpgp-card")] pub(crate) fn handle_set_touch_policy( state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, token: Option>>>, ) { use crate::state_handler::setup_sequence::openpgp_card::set_signing_touch_policy; @@ -180,7 +180,7 @@ pub(crate) fn handle_set_touch_policy( #[cfg(feature = "openpgp-card")] pub(crate) fn handle_set_token_name( state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, token: Option>>>, name: &str, ) { diff --git a/openvtc/src/state_handler/setup_vta_actions.rs b/openvtc/src/state_handler/setup_vta_actions.rs index 8a069113..9b5e6ddc 100644 --- a/openvtc/src/state_handler/setup_vta_actions.rs +++ b/openvtc/src/state_handler/setup_vta_actions.rs @@ -1,4 +1,6 @@ use crate::env_overrides::WizardUrlOverride; +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; use crate::state_handler::{ setup_sequence::{Completion, MessageType, RebuildOutcome, SetupPage}, state::State, @@ -100,7 +102,7 @@ fn kept_mediator(connected: Option, advertised_didcomm: Option) /// failure we stay on `VtaEnterDid` so the operator can edit and resubmit. pub(crate) async fn handle_vta_submit_did( state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, vta_did: String, ) -> anyhow::Result<()> { // The transition from StartAsk → VtaEnterDid is a UI-only navigation @@ -132,7 +134,7 @@ pub(crate) async fn handle_vta_submit_did( "DEV OVERRIDE: {VTA_URL_OVERRIDE_ENV} set — using REST endpoint {url} \ (skipping DID resolution)." ))); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); state.setup.vta.vta_url = url; state.setup.vta.advertised = vec![Protocol::Rest]; // The URL is pinned, but the account still needs the mediator its @@ -154,7 +156,7 @@ pub(crate) async fn handle_vta_submit_did( state.setup.vta.messages.push(MessageType::Info( "Resolving VTA service endpoint…".to_string(), )); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); // Use `resolve_vta` (not `resolve_vta_url`) so we get an honest answer: // `rest_url` is `Some` only when the DID document advertises a `#vta-rest` @@ -236,7 +238,7 @@ pub(crate) async fn handle_vta_submit_did( state.setup.vta.setup_key = Some(setup_key); state.setup.vta.completed = Completion::CompletedOK; state.setup.active_page = SetupPage::VtaAclInstructions; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); Ok(()) } @@ -256,7 +258,7 @@ pub(crate) async fn handle_vta_submit_did( /// in-flight responses). Returns `Ok(None)` if provisioning did not complete. pub(crate) async fn handle_vta_start_provision( state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, context_id: String, transport: Option, ) -> anyhow::Result> { @@ -288,7 +290,7 @@ pub(crate) async fn handle_vta_start_provision( state.setup.vta.diagnostics.clear(); state.setup.vta.credential_did.clear(); state.setup.vta.completed = Completion::NotFinished; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); // AdminRotated mints a fresh long-term admin DID on the VTA side; the // ephemeral setup did:key only authenticates the bootstrap call. The reply @@ -327,12 +329,12 @@ pub(crate) async fn handle_vta_start_provision( "URL-direct provisioning failed: {e}" ))); state.setup.vta.completed = Completion::CompletedFail; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); } } } else { state.setup.vta.diagnostics = pending_list(); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); let (tx, mut rx) = mpsc::unbounded_channel::(); let runner_vta_did = vta_did.clone(); @@ -393,13 +395,13 @@ pub(crate) async fn handle_vta_start_provision( .messages .push(MessageType::Error(reason.clone())); state.setup.vta.completed = Completion::CompletedFail; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); } // `VtaEvent` is non-exhaustive (vta-sdk #2196): an event this // build does not know carries nothing the setup page acts on. _ => {} } - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); } } @@ -409,7 +411,7 @@ pub(crate) async fn handle_vta_start_provision( "Provisioning ended without an admin credential.".to_string(), )); state.setup.vta.completed = Completion::CompletedFail; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); } return Ok(None); }; @@ -450,14 +452,14 @@ pub(crate) async fn handle_vta_start_provision( "{label} transport selected but no mediator DID was advertised." ))); state.setup.vta.completed = Completion::CompletedFail; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); return Ok(None); } }; state.setup.vta.messages.push(MessageType::Info(format!( "Opening {label} session as rotated admin DID…" ))); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); let rest_fallback = if state.setup.vta.vta_url.is_empty() { None @@ -497,7 +499,7 @@ pub(crate) async fn handle_vta_start_provision( "{label} session open failed: {e}" ))); state.setup.vta.completed = Completion::CompletedFail; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); return Ok(None); } } @@ -512,7 +514,7 @@ pub(crate) async fn handle_vta_start_provision( .to_string(), )); state.setup.vta.completed = Completion::CompletedFail; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); return Ok(None); } PostBootstrapAuth::Rest => { @@ -521,7 +523,7 @@ pub(crate) async fn handle_vta_start_provision( .vta .messages .push(MessageType::Info("Authenticating with VTA…".to_string())); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); let vta_url = state.setup.vta.vta_url.clone(); match vta::authenticate( @@ -578,7 +580,7 @@ pub(crate) async fn handle_vta_start_provision( .messages .push(MessageType::Error(format!("Authentication failed: {e}"))); state.setup.vta.completed = Completion::CompletedFail; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); return Ok(None); } } @@ -613,7 +615,7 @@ pub(crate) async fn handle_vta_start_provision( // Stay on VtaProvisioning so the operator can see the admin DID rotation // result (ephemeral setup DID → long-term admin DID) before advancing on // Enter. - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); // Hand the live admin session back to the wizard. It is kept open and reused // for the key/DID-creation steps (one mediator connection for the whole @@ -634,7 +636,7 @@ pub(crate) async fn handle_vta_start_provision( /// a listing failed would be a worse outcome than a page that explains itself. pub(crate) async fn handle_recover_plan_context( state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, client: &vta_sdk::client::VtaClient, ) { let context_id = state.setup.vta.context_id.clone().unwrap_or_default(); @@ -642,7 +644,7 @@ pub(crate) async fn handle_recover_plan_context( state.setup.active_page = SetupPage::RecoverConfirm; state.setup.vta.rebuild = None; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); let outcome = match openvtc_core::rebuild::plan(client, &context_id, now).await { Ok(plan) => { @@ -675,7 +677,7 @@ pub(crate) async fn handle_recover_plan_context( Err(e) => { state.setup.vta.rebuild = Some(Err(format!("could not list this context's keys: {e}"))); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); return; } }; @@ -687,7 +689,7 @@ pub(crate) async fn handle_recover_plan_context( }; state.setup.vta.rebuild = Some(outcome); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); } #[cfg(test)] diff --git a/openvtc/src/state_handler/setup_wizard.rs b/openvtc/src/state_handler/setup_wizard.rs index 16c2eb34..a3f15735 100644 --- a/openvtc/src/state_handler/setup_wizard.rs +++ b/openvtc/src/state_handler/setup_wizard.rs @@ -1,5 +1,7 @@ #[cfg(feature = "openpgp-card")] use crate::state_handler::setup_token_actions; +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; use crate::{ Interrupted, state_handler::{ @@ -40,7 +42,7 @@ impl StateHandler { // `?` error out of a handler below — and never leaks (the LeakGuard). let exit: Result = async { Ok(loop { - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); tokio::select! { Some(action) = action_rx.recv() => match action { Action::Exit => { @@ -90,7 +92,7 @@ impl StateHandler { // the blocking pool rather than this loop or the render task. state.setup.active_page = SetupPage::FinalPage; state.setup.final_page.messages.push(MessageType::Info("Deriving the encryption key from your unlock code...".to_string())); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); // A fresh random salt per profile (#566), written beside // the ciphertext by the first save. match UnlockKey::derive_blocking(passphrase).await { @@ -209,7 +211,7 @@ impl StateHandler { state.setup.final_page.messages.push(MessageType::Info( "Your device may prompt for authentication to access OS secure storage.".to_string(), )); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); // R-A-5: setup bootstraps a State-A account (VTA admin credential + // top-level context, no persona/community). A persona is minted later // by the State-B join flow. diff --git a/openvtc/src/state_handler/state.rs b/openvtc/src/state_handler/state.rs index 90565dae..10a7d67d 100644 --- a/openvtc/src/state_handler/state.rs +++ b/openvtc/src/state_handler/state.rs @@ -8,6 +8,29 @@ use secrecy::SecretString; use crate::state_handler::{join::JoinState, main_page::MainPageState, setup_sequence::SetupState}; +/// How the state handler publishes [`State`] to the UI (#556). +/// +/// The channel carries an `Arc`: publishing clones the state once, into the +/// `Arc`, and the UI's copies of the snapshot share it instead of cloning it +/// again. Every publish still sends — there is no dirty tracking, so the UI is +/// never left showing a stale state (the reason R23 was declined). +pub(crate) trait PublishState { + /// Send a snapshot of `state`. Errs only when the UI has gone. + fn publish( + &self, + state: &State, + ) -> Result<(), tokio::sync::watch::error::SendError>>; +} + +impl PublishState for tokio::sync::watch::Sender> { + fn publish( + &self, + state: &State, + ) -> Result<(), tokio::sync::watch::error::SendError>> { + self.send(Arc::new(state.clone())) + } +} + /// State holds the state of the application #[derive(Default, Debug, Clone)] pub struct State { diff --git a/openvtc/src/ui/component.rs b/openvtc/src/ui/component.rs index 59136859..9ff283cb 100644 --- a/openvtc/src/ui/component.rs +++ b/openvtc/src/ui/component.rs @@ -1,13 +1,15 @@ +use std::sync::Arc; + use crate::state_handler::{actions::Action, state::State}; use crossterm::event::KeyEvent; use ratatui::Frame; use tokio::sync::mpsc::UnboundedSender; pub trait Component { - fn new(state: &State, action_tx: UnboundedSender) -> Self + fn new(state: &Arc, action_tx: UnboundedSender) -> Self where Self: Sized; - fn move_with_state(self, state: &State) -> Self + fn move_with_state(self, state: &Arc) -> Self where Self: Sized; diff --git a/openvtc/src/ui/mod.rs b/openvtc/src/ui/mod.rs index bae71b93..23c6060f 100644 --- a/openvtc/src/ui/mod.rs +++ b/openvtc/src/ui/mod.rs @@ -26,6 +26,7 @@ pub mod badges; pub mod component; pub mod journey; pub mod pages; +pub mod shared; pub struct UiManager { action_tx: mpsc::UnboundedSender, @@ -55,7 +56,7 @@ impl UiManager { pub async fn main_loop( self, - mut state_rx: watch::Receiver, + mut state_rx: watch::Receiver>, mut interrupt_rx: broadcast::Receiver, ) -> Result { let Self { diff --git a/openvtc/src/ui/pages/join_flow/context_choice.rs b/openvtc/src/ui/pages/join_flow/context_choice.rs index 826c1981..3e820e2f 100644 --- a/openvtc/src/ui/pages/join_flow/context_choice.rs +++ b/openvtc/src/ui/pages/join_flow/context_choice.rs @@ -212,7 +212,7 @@ mod tests { }, ]; state.join.context_selected = selected; - (JoinFlow::new(&state, tx), rx) + (JoinFlow::new(&std::sync::Arc::new(state.clone()), tx), rx) } fn press(code: KeyCode) -> KeyEvent { diff --git a/openvtc/src/ui/pages/join_flow/identity_choice.rs b/openvtc/src/ui/pages/join_flow/identity_choice.rs index b681c5c1..386bcc55 100644 --- a/openvtc/src/ui/pages/join_flow/identity_choice.rs +++ b/openvtc/src/ui/pages/join_flow/identity_choice.rs @@ -275,7 +275,7 @@ mod tests { }, ]; mutate(&mut state.join); - (JoinFlow::new(&state, tx), rx) + (JoinFlow::new(&std::sync::Arc::new(state.clone()), tx), rx) } fn press(code: KeyCode) -> KeyEvent { diff --git a/openvtc/src/ui/pages/join_flow/invitation_choice.rs b/openvtc/src/ui/pages/join_flow/invitation_choice.rs index 4cbef968..bade4712 100644 --- a/openvtc/src/ui/pages/join_flow/invitation_choice.rs +++ b/openvtc/src/ui/pages/join_flow/invitation_choice.rs @@ -317,7 +317,7 @@ mod tests { state.join.page = JoinPage::InvitationChoice; state.join.invitation_options = vec![vic("urn:uuid:one")]; mutate(&mut state.join); - (JoinFlow::new(&state, tx), rx) + (JoinFlow::new(&std::sync::Arc::new(state.clone()), tx), rx) } fn press(code: KeyCode) -> KeyEvent { diff --git a/openvtc/src/ui/pages/join_flow/join_progress.rs b/openvtc/src/ui/pages/join_flow/join_progress.rs index be60ef0e..aff9198d 100644 --- a/openvtc/src/ui/pages/join_flow/join_progress.rs +++ b/openvtc/src/ui/pages/join_flow/join_progress.rs @@ -275,7 +275,7 @@ mod tests { let mut state = State::default(); state.join.processing = true; state.join.waiting_on_challenge = waiting; - (JoinFlow::new(&state, tx), rx) + (JoinFlow::new(&std::sync::Arc::new(state.clone()), tx), rx) }; let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); diff --git a/openvtc/src/ui/pages/join_flow/mod.rs b/openvtc/src/ui/pages/join_flow/mod.rs index 73838942..3f355b07 100644 --- a/openvtc/src/ui/pages/join_flow/mod.rs +++ b/openvtc/src/ui/pages/join_flow/mod.rs @@ -11,6 +11,7 @@ //! `Input` lives on this component (mirroring how `vta_enter_did` holds its //! input), and persists across re-renders via `move_with_state`. +use crate::ui::shared::Shared; use crate::{ state_handler::{ actions::Action, @@ -81,7 +82,7 @@ pub struct JoinFlow { #[derive(Clone)] pub struct Props { - pub state: JoinState, + pub state: Shared, /// The create-persona overlay, while one is open. /// /// Page-level state shared with the main page rather than a copy of its @@ -90,14 +91,14 @@ pub struct Props { /// entering the community's DID a second time on the way back. The same /// overlay, the same phases, the same keys — floated over whichever page /// asked for it. - pub create_persona: Option, + pub create_persona: Shared>, } -impl From<&State> for Props { - fn from(state: &State) -> Self { +impl From<&std::sync::Arc> for Props { + fn from(state: &std::sync::Arc) -> Self { Props { - state: state.join.clone(), - create_persona: state.main_page.create_persona.clone(), + state: Shared::new(state, |s| &s.join), + create_persona: Shared::new(state, |s| &s.main_page.create_persona), } } } @@ -176,7 +177,7 @@ impl JoinFlow { } impl Component for JoinFlow { - fn new(state: &State, action_tx: UnboundedSender) -> Self + fn new(state: &std::sync::Arc, action_tx: UnboundedSender) -> Self where Self: Sized, { @@ -196,7 +197,7 @@ impl Component for JoinFlow { .move_with_state(state) } - fn move_with_state(self, state: &State) -> Self + fn move_with_state(self, state: &std::sync::Arc) -> Self where Self: Sized, { @@ -304,7 +305,7 @@ mod tests { fn flow() -> JoinFlow { let (tx, _rx) = unbounded_channel(); - JoinFlow::new(&State::default(), tx) + JoinFlow::new(&std::sync::Arc::new(State::default()), tx) } /// Set an issuer on the state and push it through the same path the runtime @@ -312,7 +313,7 @@ mod tests { fn broadcast(flow: JoinFlow, issuer: Option<&str>) -> JoinFlow { let mut state = State::default(); state.join.invitation_issuer = issuer.map(str::to_string); - flow.move_with_state(&state) + flow.move_with_state(&std::sync::Arc::new(state.clone())) } /// Each fresh entry into the flow starts with an empty box; a redraw within @@ -322,11 +323,11 @@ mod tests { let mut state = State::default(); let mut flow = flow(); flow.vtc_did = Input::new("did:webvh:last.join".to_string()); - let mut flow = flow.move_with_state(&state); + let mut flow = flow.move_with_state(&std::sync::Arc::new(state.clone())); assert_eq!(flow.vtc_did.value(), "did:webvh:last.join", "same entry"); state.join.reset(); - flow = flow.move_with_state(&state); + flow = flow.move_with_state(&std::sync::Arc::new(state.clone())); assert_eq!(flow.vtc_did.value(), "", "a new entry starts empty"); } @@ -338,7 +339,7 @@ mod tests { let mut state = State::default(); state.join.reset(); state.join.invitation_issuer = Some(ISSUER.to_string()); - let flow = flow.move_with_state(&state); + let flow = flow.move_with_state(&std::sync::Arc::new(state.clone())); assert_eq!(flow.vtc_did.value(), ISSUER); } diff --git a/openvtc/src/ui/pages/join_flow/registry_consent.rs b/openvtc/src/ui/pages/join_flow/registry_consent.rs index bb236d1e..1e26b6ed 100644 --- a/openvtc/src/ui/pages/join_flow/registry_consent.rs +++ b/openvtc/src/ui/pages/join_flow/registry_consent.rs @@ -422,7 +422,10 @@ mod tests { let (tx, mut rx) = unbounded_channel(); let mut state = crate::state_handler::state::State::default(); state.join.registry = Some(choice(false)); - let mut flow = ::new(&state, tx); + let mut flow = ::new( + &std::sync::Arc::new(state.clone()), + tx, + ); let press = |code| KeyEvent::new(code, KeyModifiers::NONE); for c in ['y', 'Y', 'p', 'x'] { diff --git a/openvtc/src/ui/pages/join_flow/vetting_requirements.rs b/openvtc/src/ui/pages/join_flow/vetting_requirements.rs index b5e37e82..07360b95 100644 --- a/openvtc/src/ui/pages/join_flow/vetting_requirements.rs +++ b/openvtc/src/ui/pages/join_flow/vetting_requirements.rs @@ -663,7 +663,7 @@ mod tests { let mut state = State::default(); state.join.page = JoinPage::Vetting; state.join.vetting = Some(view(phase)); - (JoinFlow::new(&state, tx), rx) + (JoinFlow::new(&std::sync::Arc::new(state.clone()), tx), rx) } fn press(flow: &mut JoinFlow, code: KeyCode) { diff --git a/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs b/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs index 04ec25b8..9e3a8b2c 100644 --- a/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs +++ b/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs @@ -439,7 +439,7 @@ mod tests { use tokio::sync::mpsc::unbounded_channel; let (tx, _rx) = unbounded_channel(); - let mut flow = JoinFlow::new(&State::default(), tx); + let mut flow = JoinFlow::new(&std::sync::Arc::new(State::default()), tx); VtcEnterDid::handle_key_event( &mut flow, KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL), @@ -465,7 +465,7 @@ mod tests { use tokio::sync::mpsc::unbounded_channel; let (tx, mut rx) = unbounded_channel(); - let mut flow = JoinFlow::new(&State::default(), tx); + let mut flow = JoinFlow::new(&std::sync::Arc::new(State::default()), tx); flow.apply_entry_paste(" did:webvh:QmRoot:vtc.example.com "); assert_eq!(flow.vtc_did.value(), "did:webvh:QmRoot:vtc.example.com"); @@ -497,7 +497,7 @@ mod tests { use tokio::sync::mpsc::unbounded_channel; let (tx, mut rx) = unbounded_channel(); - let mut flow = JoinFlow::new(&State::default(), tx); + let mut flow = JoinFlow::new(&std::sync::Arc::new(State::default()), tx); VtcEnterDid::handle_key_event( &mut flow, KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE), diff --git a/openvtc/src/ui/pages/loading/mod.rs b/openvtc/src/ui/pages/loading/mod.rs index 5b9adf9a..7aec174c 100644 --- a/openvtc/src/ui/pages/loading/mod.rs +++ b/openvtc/src/ui/pages/loading/mod.rs @@ -10,6 +10,7 @@ //! struct mapped `From<&State>` carries everything the screen renders, so the //! component itself stays free of business logic. +use crate::state_handler::state::ActivePage; use crate::{ colors::{ COLOR_BORDER, COLOR_DARK_GRAY, COLOR_ORANGE, COLOR_SOFT_PURPLE, COLOR_SUCCESS, @@ -117,24 +118,28 @@ pub struct LoadingScreen { } impl Component for LoadingScreen { - fn new(state: &State, action_tx: UnboundedSender) -> Self + fn new(state: &std::sync::Arc, action_tx: UnboundedSender) -> Self where Self: Sized, { LoadingScreen { action_tx, - props: Props::from(state), + props: Props::from(&**state), scroll: 0, rendered_lines: std::cell::Cell::new(0), } } - fn move_with_state(self, state: &State) -> Self + fn move_with_state(self, state: &std::sync::Arc) -> Self where Self: Sized, { + // Only while the loading screen is up (#556): it is not drawn after. + if !matches!(state.active_page, ActivePage::Loading) { + return self; + } LoadingScreen { - props: Props::from(state), + props: Props::from(&**state), ..self } } @@ -800,7 +805,7 @@ mod tests { fn screen(state: &State) -> LoadingScreen { let (tx, _rx) = unbounded_channel(); - LoadingScreen::new(state, tx) + LoadingScreen::new(&std::sync::Arc::new(state.clone()), tx) } #[test] diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index e44623d9..87f52de0 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -2,6 +2,7 @@ use crate::colors::{ COLOR_BORDER, COLOR_DARK_GRAY, COLOR_ORANGE, COLOR_SOFT_PURPLE, COLOR_SUCCESS, COLOR_TEXT_DEFAULT, COLOR_WARNING_ACCESSIBLE_RED, }; +use crate::ui::shared::Shared; use crate::{ state_handler::{ actions::{Action, SettingsAction}, @@ -80,21 +81,21 @@ pub struct MainPage { const CONTENT_PAGE: u16 = 10; struct Props { - main_page: MainPageState, - connection: ConnectionState, + main_page: Shared, + connection: Shared, } -impl From<&State> for Props { - fn from(state: &State) -> Self { +impl From<&std::sync::Arc> for Props { + fn from(state: &std::sync::Arc) -> Self { Props { - main_page: state.main_page.clone(), - connection: state.connection.clone(), + main_page: Shared::new(state, |s| &s.main_page), + connection: Shared::new(state, |s| &s.connection), } } } impl Component for MainPage { - fn new(state: &State, action_tx: UnboundedSender) -> Self + fn new(state: &std::sync::Arc, action_tx: UnboundedSender) -> Self where Self: Sized, { @@ -117,7 +118,7 @@ impl Component for MainPage { .move_with_state(state) } - fn move_with_state(mut self, state: &State) -> Self + fn move_with_state(mut self, state: &std::sync::Arc) -> Self where Self: Sized, { @@ -1526,7 +1527,7 @@ mod key_handler_tests { // The content panel must be focused for keys to route to its handler. state.main_page.content_panel.selected = true; mutate(&mut state); - let page = MainPage::new(&state, tx); + let page = MainPage::new(&std::sync::Arc::new(state.clone()), tx); (page, rx) } diff --git a/openvtc/src/ui/pages/mod.rs b/openvtc/src/ui/pages/mod.rs index 5e2ca1e2..29b3392c 100644 --- a/openvtc/src/ui/pages/mod.rs +++ b/openvtc/src/ui/pages/mod.rs @@ -57,12 +57,12 @@ impl AppRouter { } impl Component for AppRouter { - fn new(state: &State, action_tx: UnboundedSender) -> Self + fn new(state: &std::sync::Arc, action_tx: UnboundedSender) -> Self where Self: Sized, { AppRouter { - props: Props::from(state), + props: Props::from(&**state), // main_page: MainPage::new(state, action_tx.clone()), setup_flow: SetupFlow::new(state, action_tx.clone()), @@ -72,12 +72,12 @@ impl Component for AppRouter { .move_with_state(state) } - fn move_with_state(self, state: &State) -> Self + fn move_with_state(self, state: &std::sync::Arc) -> Self where Self: Sized, { AppRouter { - props: Props::from(state), + props: Props::from(&**state), // main_page: self.main_page.move_with_state(state), setup_flow: self.setup_flow.move_with_state(state), diff --git a/openvtc/src/ui/pages/setup_flow/mod.rs b/openvtc/src/ui/pages/setup_flow/mod.rs index 0fb15123..eb0db8d1 100644 --- a/openvtc/src/ui/pages/setup_flow/mod.rs +++ b/openvtc/src/ui/pages/setup_flow/mod.rs @@ -102,7 +102,7 @@ impl From<&State> for Props { } impl Component for SetupFlow { - fn new(state: &State, action_tx: UnboundedSender) -> Self + fn new(state: &std::sync::Arc, action_tx: UnboundedSender) -> Self where Self: Sized, { @@ -134,17 +134,26 @@ impl Component for SetupFlow { final_page: FinalPage::default(), // set the props - props: Props::from(state), + props: Props::from(&**state), } .move_with_state(state) } - fn move_with_state(self, state: &State) -> Self + fn move_with_state(self, state: &std::sync::Arc) -> Self where Self: Sized, { + // Copied only while the wizard is on screen (#556): its props are a + // working copy the pages edit, and an update that arrives while + // another page is up has nothing here to refresh. + if !matches!( + state.active_page, + crate::state_handler::state::ActivePage::Setup + ) { + return self; + } SetupFlow { - props: Props::from(state), + props: Props::from(&**state), // propagate the update to the child components ..self } diff --git a/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs b/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs index fe2f50f2..141d1e2c 100644 --- a/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs +++ b/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs @@ -1071,7 +1071,7 @@ mod tests { #[test] fn enter_starts_step_two_and_clears_the_banner() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let mut flow = SetupFlow::new(&State::default(), tx); + let mut flow = SetupFlow::new(&std::sync::Arc::new(State::default()), tx); flow.vta_acl_instructions = back_with(ProvisionFailure::NotAuthorised, None); flow.vta_acl_instructions.context_id = Input::new(" my-ctx ".to_string()); @@ -1095,7 +1095,7 @@ mod tests { #[test] fn tab_picks_an_advertised_transport_and_enter_carries_it() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let mut flow = SetupFlow::new(&State::default(), tx); + let mut flow = SetupFlow::new(&std::sync::Arc::new(State::default()), tx); flow.props.state.vta.advertised = vec![Protocol::Tsp, Protocol::Rest]; press(&mut flow, KeyCode::Tab); @@ -1122,7 +1122,7 @@ mod tests { #[test] fn a_single_transport_offers_no_choice() { let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - let mut flow = SetupFlow::new(&State::default(), tx); + let mut flow = SetupFlow::new(&std::sync::Arc::new(State::default()), tx); flow.props.state.vta.advertised = vec![Protocol::DidComm]; press(&mut flow, KeyCode::Tab); assert_eq!(flow.vta_acl_instructions.transport, None); @@ -1143,7 +1143,7 @@ mod tests { #[test] fn typing_keeps_the_banner() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let mut flow = SetupFlow::new(&State::default(), tx); + let mut flow = SetupFlow::new(&std::sync::Arc::new(State::default()), tx); flow.vta_acl_instructions = back_with(ProvisionFailure::NotAuthorised, None); press(&mut flow, KeyCode::Char('x')); assert!(flow.vta_acl_instructions.retry_reason.is_some()); @@ -1155,7 +1155,7 @@ mod tests { fn esc_walks_back_through_the_wizard_keeping_the_input() { use crate::ui::component::Component as _; let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - let mut flow = SetupFlow::new(&State::default(), tx); + let mut flow = SetupFlow::new(&std::sync::Arc::new(State::default()), tx); flow.props.state.active_page = SetupPage::VtaAclInstructions; flow.vta_acl_instructions.context_id = Input::new("my-ctx".to_string()); let esc = KeyEvent { diff --git a/openvtc/src/ui/pages/setup_flow/vta_provisioning.rs b/openvtc/src/ui/pages/setup_flow/vta_provisioning.rs index 2037dc8b..eb269bcc 100644 --- a/openvtc/src/ui/pages/setup_flow/vta_provisioning.rs +++ b/openvtc/src/ui/pages/setup_flow/vta_provisioning.rs @@ -441,7 +441,7 @@ mod tests { fn flow(state: SetupState) -> (SetupFlow, UnboundedReceiver) { let (tx, rx) = unbounded_channel(); - let mut flow = SetupFlow::new(&State::default(), tx); + let mut flow = SetupFlow::new(&std::sync::Arc::new(State::default()), tx); flow.props.state = state; (flow, rx) } diff --git a/openvtc/src/ui/shared.rs b/openvtc/src/ui/shared.rs new file mode 100644 index 00000000..b10862ee --- /dev/null +++ b/openvtc/src/ui/shared.rs @@ -0,0 +1,108 @@ +//! A page's view of the published [`State`] without copying it (#556). +//! +//! The state handler publishes an `Arc`. A page's props used to clone +//! the part they render out of it on every update — the whole main-page model, +//! the join state — whether or not the page was on screen. [`Shared`] holds the +//! `Arc` instead and derefs to the part, so a props update is a reference-count +//! increment and the page reads its fields exactly as before. +//! +//! A page that edits its props locally (a selection the next update +//! overwrites) still can: the first write copies the part, as every update used +//! to, and only that page's copy changes. + +use std::{ + fmt, + ops::{Deref, DerefMut}, + sync::Arc, +}; + +use crate::state_handler::state::State; + +/// One part of a published [`State`], borrowed from the shared snapshot. +pub struct Shared { + state: Arc, + part: fn(&State) -> &T, + /// A local copy, made by the first write through [`DerefMut`]. + edited: Option>, +} + +impl Shared { + pub fn new(state: &Arc, part: fn(&State) -> &T) -> Self { + Shared { + state: Arc::clone(state), + part, + edited: None, + } + } +} + +impl Deref for Shared { + type Target = T; + + fn deref(&self) -> &T { + match &self.edited { + Some(edited) => edited, + None => (self.part)(&self.state), + } + } +} + +impl DerefMut for Shared { + fn deref_mut(&mut self) -> &mut T { + let part = self.part; + let state = &self.state; + self.edited + .get_or_insert_with(|| Box::new(part(state).clone())) + } +} + +impl Clone for Shared { + fn clone(&self) -> Self { + Shared { + state: Arc::clone(&self.state), + part: self.part, + edited: self.edited.clone(), + } + } +} + +impl fmt::Debug for Shared { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + (**self).fmt(f) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_part_reads_through_to_the_snapshot_without_copying_it() { + let mut state = State::default(); + state.main_page.log("seen through the share"); + let state = Arc::new(state); + let part = Shared::new(&state, |s| &s.main_page); + assert_eq!(Arc::strong_count(&state), 2, "shared, not copied"); + assert!( + part.activity_log + .iter() + .any(|e| e.summary.contains("seen through the share")) + ); + let again = part.clone(); + assert_eq!(Arc::strong_count(&state), 3); + assert!(std::ptr::eq(&*part, &*again)); + } + + #[test] + fn a_local_edit_copies_the_part_and_leaves_the_snapshot_alone() { + let state = Arc::new(State::default()); + let mut part = Shared::new(&state, |s| &s.main_page); + part.log("only this page's copy"); + assert!( + part.activity_log + .iter() + .any(|e| e.summary.contains("only this page's copy")) + ); + assert!(state.main_page.activity_log.is_empty()); + } +} From eecdc8688f62d7cae3ffc88c562de9454f4dad62 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 19:03:42 +1000 Subject: [PATCH 04/27] refactor(core): split the join's network steps into I/O and apply halves (#529) learn_over_http, learn_signing and submit each become an owned I/O half that needs no Config (fetch_over_http, resolve_document, submit_prepared) and a half that takes the Config on the caller's thread (learn_fetched, note_signing, prepare_submit), so the TUI can run the I/O off its runtime loop. The one-call forms remain for community join and are those halves in sequence; no behaviour change. Signed-off-by: Glenn Gore --- openvtc-core/src/join/sequence.rs | 138 ++++++++++++++++++++++++------ 1 file changed, 113 insertions(+), 25 deletions(-) diff --git a/openvtc-core/src/join/sequence.rs b/openvtc-core/src/join/sequence.rs index bda2b9d7..18d4a598 100644 --- a/openvtc-core/src/join/sequence.rs +++ b/openvtc-core/src/join/sequence.rs @@ -4,9 +4,15 @@ //! Each step here touches only [`Config`] and the network; what the person is //! shown about it (the TUI's join page, the headless command's progress lines) //! stays with the caller, which applies the step's result. Moved from the CLI -//! (issue #530). The rest of the sequence — minting a persona, choosing and -//! presenting an invitation, answering a community's questions — still lives -//! in the CLI's `join_flow` and moves with the join-flow rework (#529). +//! (issue #530). +//! +//! Each network step comes in two halves where the TUI needs them apart +//! (#529): an owned I/O half that runs off the runtime loop +//! ([`fetch_over_http`], [`resolve_document`], [`submit_prepared`]) and an +//! apply half that takes `&mut Config` on the loop ([`learn_fetched`], +//! [`note_signing`], [`prepare_submit`]). The one-call forms +//! ([`learn_over_http`], [`learn_signing`], [`submit`]) are those halves in +//! sequence, for callers that own their `Config` outright (`community join`). use std::time::Duration; @@ -77,7 +83,24 @@ pub async fn learn_over_http( tdk: &TDK, vtc_did: &str, ) -> Result<(), NotLearned> { - let resolver = tdk.did_resolver(); + let fetched = fetch_over_http(tdk.did_resolver(), vtc_did).await?; + learn_fetched(config, vtc_did, fetched); + Ok(()) +} + +/// A community's manifest, read from its REST endpoint, with the DID document +/// it was found through — what [`learn_fetched`] records. +pub struct OverHttp { + fetched: discover::FetchedManifest, + document: affinidi_tdk::did_common::Document, +} + +/// The I/O half of [`learn_over_http`]: resolve `vtc_did` and read its +/// manifest. Needs no `Config`, so it can run off the runtime loop. +pub async fn fetch_over_http( + resolver: &affinidi_tdk::did_resolver::DIDCacheClient, + vtc_did: &str, +) -> Result { let resolved = tokio::time::timeout(RESOLVE_TIMEOUT, resolver.resolve(vtc_did)) .await .map_err(|_| { @@ -92,15 +115,28 @@ pub async fn learn_over_http( .map_err(|e| NotLearned::Unresolvable(format!("it could not be resolved ({e})")))?; let doc = serde_json::to_value(&resolved.doc) .map_err(|e| NotLearned::Unanswered(format!("its DID document could not be read ({e})")))?; - let discover::FetchedManifest { - manifest, - raw, - protocol, - meta, - } = discover::fetch_manifest(&doc, vtc_did, resolver, ProbePolicy::PublicOnly) + let fetched = discover::fetch_manifest(&doc, vtc_did, resolver, ProbePolicy::PublicOnly) .await .map_err(|e| NotLearned::Unanswered(e.to_string()))?; + Ok(OverHttp { + fetched, + document: resolved.doc, + }) +} +/// The apply half of [`learn_over_http`]: the book learns the manifest, and any +/// application to this community adopts it. +pub fn learn_fetched(config: &mut Config, vtc_did: &str, fetched: OverHttp) { + let OverHttp { + fetched: + discover::FetchedManifest { + manifest, + raw, + protocol, + meta, + }, + document, + } = fetched; let now = Utc::now(); let before = config.private.vetting.vetter_mode(vtc_did).mode(); config @@ -118,7 +154,7 @@ pub async fn learn_over_http( .learn_mode(vtc_did, &raw, before, now); // The document is in hand already, so how the community signs is free to // learn — and before joining it is the only evidence there is. - note_signing(config, vtc_did, &resolved.doc); + note_signing(config, vtc_did, &document); let book = &mut config.private.vetting; for application in book .applications @@ -131,7 +167,6 @@ pub async fn learn_over_http( warn!(community = %vtc_did, error = %e, "community manifest not adopted"); } } - Ok(()) } /// Record how `vtc_did` signs, from its resolved DID document: whether it lists @@ -157,16 +192,31 @@ pub async fn learn_signing(config: &mut Config, tdk: &TDK, vtc_did: &str) { if config.private.vetting.post_quantum_key(vtc_did).is_some() { return; } - match tokio::time::timeout(RESOLVE_TIMEOUT, tdk.did_resolver().resolve(vtc_did)).await { - Ok(Ok(resolved)) => note_signing(config, vtc_did, &resolved.doc), + if let Some(doc) = resolve_document(tdk.did_resolver(), vtc_did).await { + note_signing(config, vtc_did, &doc); + } +} + +/// The I/O half of [`learn_signing`]: resolve `vtc_did`'s DID document, +/// bounded by [`RESOLVE_TIMEOUT`]. `None` when it did not resolve in time. +pub async fn resolve_document( + resolver: &affinidi_tdk::did_resolver::DIDCacheClient, + vtc_did: &str, +) -> Option { + match tokio::time::timeout(RESOLVE_TIMEOUT, resolver.resolve(vtc_did)).await { + Ok(Ok(resolved)) => Some(resolved.doc), Ok(Err(e)) => { debug!(community = %vtc_did, error = %e, "community DID not resolved; signing not checked"); + None + } + Err(_) => { + debug!( + community = %vtc_did, + timeout_secs = RESOLVE_TIMEOUT.as_secs(), + "community DID resolve timed out; signing not checked" + ); + None } - Err(_) => debug!( - community = %vtc_did, - timeout_secs = RESOLVE_TIMEOUT.as_secs(), - "community DID resolve timed out; signing not checked" - ), } } @@ -362,7 +412,29 @@ pub async fn submit( route: &SubmitRoute, presentation: crate::join::JoinPresentation, ) -> Result { - let atm = tdk.atm.as_ref().ok_or(SubmitError::NoMessaging)?; + let prepared = prepare_submit(config, tdk, persona_id).await?; + submit_prepared(&prepared, vtc_did, route, presentation).await +} + +/// What sending a join request needs from the config, read out of it so the +/// send can run without it: the messaging runtime, the persona's messaging +/// identity, and the key that signs the request. +pub struct PreparedSubmit { + atm: affinidi_tdk::messaging::ATM, + profile: std::sync::Arc, + persona_did: String, + mediator: String, + signer: affinidi_tdk::secrets_resolver::secrets::Secret, +} + +/// The config half of [`submit`]: no network (the key is read from the local +/// secrets the persona was loaded with). +pub async fn prepare_submit( + config: &Config, + tdk: &TDK, + persona_id: PersonaId, +) -> Result { + let atm = tdk.atm.clone().ok_or(SubmitError::NoMessaging)?; let identity = config .identities .get(&persona_id) @@ -377,13 +449,29 @@ pub async fn submit( .authentication .secret .clone(); - let applicant = crate::join::Applicant { + Ok(PreparedSubmit { atm, - profile: &profile, - persona_did: &persona_did, - signer: &signer, + profile, + persona_did, + mediator, + signer, + }) +} + +/// The send half of [`submit`]: sign and send the request along `route`. +pub async fn submit_prepared( + prepared: &PreparedSubmit, + vtc_did: &str, + route: &SubmitRoute, + presentation: crate::join::JoinPresentation, +) -> Result { + let applicant = crate::join::Applicant { + atm: &prepared.atm, + profile: &prepared.profile, + persona_did: &prepared.persona_did, + signer: &prepared.signer, vtc_did, - mediator_did: &mediator, + mediator_did: &prepared.mediator, tsp_mediator_did: route.tsp_mediator.as_deref(), }; crate::join::submit_join_request(&applicant, presentation) From 115ef00349a2e5e9d9a346343d45a0ba78e44245 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 19:04:46 +1000 Subject: [PATCH 05/27] =?UTF-8?q?feat(core):=20JoinSaga=20=E2=80=94=20a=20?= =?UTF-8?q?join=20launch's=20steps=20and=20their=20compensations=20(#529)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records ContextRegistered -> Minted -> ListenerUp -> Submitted -> Persisted as a launch completes them and derives, from that record alone, what undoes the launch where it stopped: remove the listener it installed, forget the persona it minted; nothing once the request is out. Replaces the TUI's out-parameters and hand-placed rollbacks (wired in the next commit). Signed-off-by: Glenn Gore --- openvtc-core/src/join.rs | 1 + openvtc-core/src/join/saga.rs | 201 ++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 openvtc-core/src/join/saga.rs diff --git a/openvtc-core/src/join.rs b/openvtc-core/src/join.rs index 71cc0300..c593d210 100644 --- a/openvtc-core/src/join.rs +++ b/openvtc-core/src/join.rs @@ -31,6 +31,7 @@ use vta_sdk::protocols::join_requests::{ use crate::errors::OpenVTCError; +pub mod saga; pub mod sequence; pub mod status_poll; diff --git a/openvtc-core/src/join/saga.rs b/openvtc-core/src/join/saga.rs new file mode 100644 index 00000000..05d6a8e4 --- /dev/null +++ b/openvtc-core/src/join/saga.rs @@ -0,0 +1,201 @@ +//! What a join launch has done that a failure or a cancel must undo (#529). +//! +//! A join launch creates things before the community has the request: the +//! community's context at the VTA, possibly a freshly minted persona (persisted +//! to the account at once), and the applicant's mediator listener. Until the +//! request is out, each of those is speculative. The TUI used to track two of +//! them in out-parameters and undo them by hand at five call sites, relying on +//! where each `.await` happened to sit when a cancel dropped the future. +//! +//! [`JoinSaga`] records the steps as they complete, in order: +//! +//! `ContextRegistered → Minted → ListenerUp → Submitted → Persisted` +//! +//! and [`JoinSaga::compensations`] says, from that record alone, what undoes +//! the launch at the point it stopped. One compensation per step: +//! +//! | Step | Compensation | +//! |------|--------------| +//! | context registered | none — an empty context at the VTA is harmless and reused by a retry (the cancel message says it may remain) | +//! | persona minted | forget the persona, unless something already references it | +//! | listener up | remove the listener | +//! | submitted / persisted | none — the request is out, and the community may answer it | +//! +//! A *reused* persona is never recorded as minted, so it is never forgotten. + +use crate::config::account::PersonaId; + +/// One thing to undo, newest first. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Compensation { + /// Tear down the applicant's mediator listener this launch installed. + RemoveListener(String), + /// Remove the persona this launch minted from the account (the caller checks + /// it is not referenced, then saves). + ForgetPersona(PersonaId), +} + +/// The steps a join launch has completed. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct JoinSaga { + context: Option, + minted: Option, + listener: Option, + submitted: bool, + persisted: bool, +} + +impl JoinSaga { + /// The community's context exists at the VTA. + pub fn context_registered(&mut self, context_id: impl Into) { + self.context = Some(context_id.into()); + } + + /// A persona was minted for this join and persisted to the account. + pub fn minted(&mut self, persona: PersonaId) { + self.minted = Some(persona); + } + + /// This launch installed the applicant's listener (a listener that already + /// existed — a reused persona's — is not ours and is never recorded). + pub fn listener_up(&mut self, listener_id: impl Into) { + self.listener = Some(listener_id.into()); + } + + /// The listener could not be installed after all: nothing to remove. + pub fn listener_failed(&mut self) { + self.listener = None; + } + + /// The join request left: from here nothing is undone. + pub fn submitted(&mut self) { + self.submitted = true; + } + + /// The pending membership was recorded. + pub fn persisted(&mut self) { + self.persisted = true; + } + + /// The context this launch registered, if it got that far. + #[must_use] + pub fn context(&self) -> Option<&str> { + self.context.as_deref() + } + + /// The persona this launch minted, if any. + #[must_use] + pub fn minted_persona(&self) -> Option { + self.minted + } + + /// The listener this launch installed, if any. + #[must_use] + pub fn listener(&self) -> Option<&str> { + self.listener.as_deref() + } + + /// Whether the request went out. + #[must_use] + pub fn is_submitted(&self) -> bool { + self.submitted + } + + /// Whether the pending membership was recorded. + #[must_use] + pub fn is_persisted(&self) -> bool { + self.persisted + } + + /// What undoes this launch where it stopped, newest step first. Empty once + /// the request is out. + #[must_use] + pub fn compensations(&self) -> Vec { + if self.submitted || self.persisted { + return Vec::new(); + } + let mut undo = Vec::new(); + if let Some(listener) = &self.listener { + undo.push(Compensation::RemoveListener(listener.clone())); + } + if let Some(persona) = self.minted { + undo.push(Compensation::ForgetPersona(persona)); + } + undo + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nothing_done_is_nothing_to_undo() { + assert!(JoinSaga::default().compensations().is_empty()); + } + + #[test] + fn a_registered_context_alone_is_left_in_place() { + let mut saga = JoinSaga::default(); + saga.context_registered("ctx/community"); + assert!(saga.compensations().is_empty()); + assert_eq!(saga.context(), Some("ctx/community")); + } + + #[test] + fn a_failure_after_the_mint_forgets_the_persona() { + let mut saga = JoinSaga::default(); + let persona = PersonaId::new(); + saga.context_registered("ctx"); + saga.minted(persona); + assert_eq!( + saga.compensations(), + vec![Compensation::ForgetPersona(persona)] + ); + } + + #[test] + fn a_failure_after_the_listener_removes_it_before_forgetting_the_persona() { + let mut saga = JoinSaga::default(); + let persona = PersonaId::new(); + saga.minted(persona); + saga.listener_up("persona-listener"); + assert_eq!( + saga.compensations(), + vec![ + Compensation::RemoveListener("persona-listener".into()), + Compensation::ForgetPersona(persona), + ] + ); + } + + #[test] + fn a_reused_persona_is_never_forgotten() { + let mut saga = JoinSaga::default(); + saga.listener_up("persona-listener"); + assert_eq!( + saga.compensations(), + vec![Compensation::RemoveListener("persona-listener".into())] + ); + } + + #[test] + fn a_listener_that_failed_to_install_is_not_removed() { + let mut saga = JoinSaga::default(); + saga.listener_up("persona-listener"); + saga.listener_failed(); + assert!(saga.compensations().is_empty()); + } + + #[test] + fn once_the_request_is_out_nothing_is_undone() { + let mut saga = JoinSaga::default(); + saga.minted(PersonaId::new()); + saga.listener_up("persona-listener"); + saga.submitted(); + assert!(saga.compensations().is_empty()); + saga.persisted(); + assert!(saga.compensations().is_empty()); + assert!(saga.is_submitted() && saga.is_persisted()); + } +} From b8fcde0812943712ecf431c5955f0f9228322175 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 18:36:33 +1000 Subject: [PATCH 06/27] perf(core): jitter the VTA retry back-off (R1.4) Equal jitter over the existing exponential step: each retry waits between half and all of 0.5s * 2^(n-1), so clients that failed together do not retry in lockstep. Refs #565. Signed-off-by: Glenn Gore --- openvtc-core/src/vta_task.rs | 40 ++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/openvtc-core/src/vta_task.rs b/openvtc-core/src/vta_task.rs index 10e712f7..ff0fac67 100644 --- a/openvtc-core/src/vta_task.rs +++ b/openvtc-core/src/vta_task.rs @@ -38,11 +38,25 @@ pub fn acl_subject_absent(err: &VtaError) -> bool { /// Max attempts for a single VTA round-trip before giving up (1 try + 2 retries). pub const MAX_ATTEMPTS: usize = 3; -/// Base back-off between retries; doubles each attempt (0.5s, then 1s). Short, -/// because the dominant retryable fault is a stale DIDComm socket that ATM -/// re-establishes almost immediately — we just need a beat before re-sending. +/// Base back-off between retries; doubles each attempt (0.5s, then 1s, each +/// jittered down by up to half — see [`backoff_delay`]). Short, because the +/// dominant retryable fault is a stale DIDComm socket that ATM re-establishes +/// almost immediately — we just need a beat before re-sending. const RETRY_BASE: std::time::Duration = std::time::Duration::from_millis(500); +/// The back-off before retry number `attempt` (1-based): "equal jitter" over the +/// exponential step `RETRY_BASE * 2^(attempt-1)` — half of the step is fixed, the +/// other half is scaled by `unit`, a sample in `[0, 1]`. The fixed half keeps the +/// beat a stale socket needs; the random half spreads clients that failed together +/// (a VTA or mediator restart) so their retries do not land in lockstep (R1.4). +/// `unit` is clamped, so the result is always within `[step/2, step]`. +#[must_use] +fn backoff_delay(attempt: usize, unit: f64) -> std::time::Duration { + let step = RETRY_BASE * (1u32 << (attempt.saturating_sub(1)).min(16)); + let half = step / 2; + half + half.mul_f64(unit.clamp(0.0, 1.0)) +} + /// True for errors a retry might clear: transport/timeout faults where the /// request most likely never reached the VTA (or its reply never came back). /// @@ -77,7 +91,7 @@ where match op().await { Ok(v) => return Ok(v), Err(e) if attempt < MAX_ATTEMPTS && retryable(&e) => { - let backoff = RETRY_BASE * (1 << (attempt - 1)); + let backoff = backoff_delay(attempt, rand::random::()); tracing::warn!( "VTA '{label}' failed (attempt {attempt}/{MAX_ATTEMPTS}): {e} — \ retrying in {backoff:?}" @@ -110,6 +124,24 @@ mod tests { assert!(!retryable(&VtaError::Auth("expired".into()))); } + #[test] + fn backoff_delay_stays_within_jitter_bounds() { + for attempt in 1..MAX_ATTEMPTS { + let step = RETRY_BASE * (1 << (attempt - 1)); + // The extremes of the sample, and out-of-range samples, are clamped. + assert_eq!(backoff_delay(attempt, 0.0), step / 2); + assert_eq!(backoff_delay(attempt, 1.0), step); + assert_eq!(backoff_delay(attempt, -3.0), step / 2); + assert_eq!(backoff_delay(attempt, 7.0), step); + for _ in 0..200 { + let d = backoff_delay(attempt, rand::random::()); + assert!(d >= step / 2 && d <= step, "attempt {attempt}: {d:?}"); + } + } + // A runaway attempt count cannot overflow the shift. + assert!(backoff_delay(usize::MAX, 1.0) <= RETRY_BASE * (1 << 16)); + } + #[tokio::test(start_paused = true)] async fn returns_immediately_on_success() { let calls = Cell::new(0u32); From 9ca2e4c68e72fc7d81e66def46bef3010e0598e1 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 18:36:34 +1000 Subject: [PATCH 07/27] perf(tui): rotate OPENVTC_DEBUG_LOG once it passes 50 MiB At open, a log already over the cap is moved to .1 (replacing any earlier rotation) and a fresh file is started, so long debug sessions keep at most two files. Refs #565. Signed-off-by: Glenn Gore --- docs/openvtc-tool-commands.md | 2 +- openvtc/README.md | 3 ++ openvtc/src/main.rs | 67 ++++++++++++++++++++++++++++++++--- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/docs/openvtc-tool-commands.md b/docs/openvtc-tool-commands.md index 883eb8c6..97abe1fe 100644 --- a/docs/openvtc-tool-commands.md +++ b/docs/openvtc-tool-commands.md @@ -61,7 +61,7 @@ None of these appear in `--help`. | `OPENVTC_THEME` | Theme for one session. | | `OPENVTC_NVIM` | Path to Neovim for `theme import nvim:`, if `nvim` is not on `PATH`. | | `NO_COLOR` | Draw without colour. | -| `OPENVTC_DEBUG_LOG` | Write debug logs to this file. | +| `OPENVTC_DEBUG_LOG` | Write debug logs to this file (appended; moved to `.1` at startup once over 50 MiB). | | `OPENVTC_FRIENDLY_NAME` | Display name override. | | `OPENVTC_VTA_URL`, `OPENVTC_VTA_DID`, `OPENVTC_MEDIATOR_DID` | Honoured only by builds with the `dev-overrides` feature. Release builds ignore them. | diff --git a/openvtc/README.md b/openvtc/README.md index e1927524..ea9df654 100644 --- a/openvtc/README.md +++ b/openvtc/README.md @@ -188,6 +188,9 @@ OPENVTC_DEBUG_LOG=/tmp/openvtc.log openvtc ``` This writes timestamped tracing output at `debug` level to the specified file. +The file is appended to across runs. When it is already larger than 50 MiB at +startup it is moved to `.1` (replacing any earlier `.1`) and a fresh file +is started, so at most two files are kept. For finer control, combine with `RUST_LOG`: ```bash diff --git a/openvtc/src/main.rs b/openvtc/src/main.rs index 551ed42b..053e08cf 100644 --- a/openvtc/src/main.rs +++ b/openvtc/src/main.rs @@ -326,6 +326,16 @@ fn redact_paths(msg: &str) -> String { } } +/// The size at which `OPENVTC_DEBUG_LOG` is rotated when it is opened: 50 MiB. +const DEBUG_LOG_MAX_BYTES: u64 = 50 * 1024 * 1024; + +/// The one rotated generation of the debug log: `.1`. +fn rotated_debug_log_path(path: &std::path::Path) -> std::path::PathBuf { + let mut name = path.as_os_str().to_os_string(); + name.push(".1"); + std::path::PathBuf::from(name) +} + /// Open the `OPENVTC_DEBUG_LOG` file for appending, owner-only on unix. /// /// Append, never truncate. `File::create` truncated on every launch, so the @@ -343,7 +353,12 @@ fn redact_paths(msg: &str) -> String { /// * **A symlink is refused.** Creating through a link planted in a /// world-writable directory would append this process's log to a file of the /// planter's choosing, so a symlink at `path` is an error, not a target. -fn open_debug_log(path: &std::path::Path) -> std::io::Result { +/// +/// And one bound: a file already larger than `max_bytes` is moved to `.1` +/// (replacing any earlier rotated file) and a fresh one started, so a long run of +/// debug sessions keeps at most two files rather than growing without limit. The +/// check is made at open only; one run can still write past the cap. +fn open_debug_log(path: &std::path::Path, max_bytes: u64) -> std::io::Result { // `symlink_metadata` does not follow the final component, so this sees the // link itself rather than what it points at. A missing path is the normal // case: it is about to be created. @@ -354,6 +369,12 @@ fn open_debug_log(path: &std::path::Path) -> std::io::Result { "refusing to write the debug log through a symlink", )); } + // Over the cap: keep it as the one rotated generation. `rename` replaces + // the destination entry itself (a link planted at `.1` is replaced, + // not followed), and the previous runs' evidence survives in `.1`. + Ok(meta) if meta.is_file() && meta.len() > max_bytes => { + std::fs::rename(path, rotated_debug_log_path(path))?; + } Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e), @@ -448,7 +469,7 @@ async fn main() -> Result<()> { // Log level defaults to this workspace's own crates but can be overridden // with RUST_LOG. if let Ok(log_path) = env::var("OPENVTC_DEBUG_LOG") { - match open_debug_log(std::path::Path::new(&log_path)) { + match open_debug_log(std::path::Path::new(&log_path), DEBUG_LOG_MAX_BYTES) { Ok(log_file) => { // Default to this workspace's own spans. The previous default // was a bare "debug", which also turned on every dependency — @@ -1263,7 +1284,7 @@ mod tests { let dir = temp_dir("mode"); let path = dir.join("openvtc.log"); - let file = open_debug_log(&path).expect("a fresh path opens"); + let file = open_debug_log(&path, DEBUG_LOG_MAX_BYTES).expect("a fresh path opens"); drop(file); let mode = std::fs::metadata(&path) @@ -1290,7 +1311,7 @@ mod tests { std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) .expect("loosen the log"); - let file = open_debug_log(&path).expect("an existing path opens"); + let file = open_debug_log(&path, DEBUG_LOG_MAX_BYTES).expect("an existing path opens"); drop(file); let mode = std::fs::metadata(&path) @@ -1312,6 +1333,41 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn debug_log_rotates_once_over_the_cap() { + let dir = temp_dir("rotate"); + let path = dir.join("openvtc.log"); + let rotated = rotated_debug_log_path(&path); + let _ = std::fs::remove_file(&rotated); + + // Under the cap: appended to, nothing rotated. + std::fs::write(&path, b"small\n").expect("seed the log"); + drop(open_debug_log(&path, 16).expect("opens under the cap")); + assert_eq!(std::fs::read(&path).expect("read"), b"small\n"); + assert!(!rotated.exists(), "no rotation under the cap"); + + // Over the cap: moved to `.1`, a fresh empty file started. + std::fs::write(&path, b"run one, which is over the cap\n").expect("grow the log"); + drop(open_debug_log(&path, 16).expect("opens over the cap")); + assert_eq!(std::fs::read(&path).expect("read").len(), 0); + assert_eq!( + std::fs::read(&rotated).expect("read rotated"), + b"run one, which is over the cap\n" + ); + + // Over again: the previous rotated generation is replaced, not kept. + std::fs::write(&path, b"run two, also over the cap\n").expect("grow again"); + drop(open_debug_log(&path, 16).expect("opens over the cap again")); + assert_eq!( + std::fs::read(&rotated).expect("read rotated"), + b"run two, also over the cap\n", + "only one rotated file is kept" + ); + assert!(!dir.join("openvtc.log.2").exists()); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] #[cfg(unix)] fn debug_log_refuses_a_symlink() { @@ -1322,7 +1378,8 @@ mod tests { let _ = std::fs::remove_file(&link); std::os::unix::fs::symlink(&target, &link).expect("create the symlink"); - let err = open_debug_log(&link).expect_err("a symlink must be refused"); + let err = + open_debug_log(&link, DEBUG_LOG_MAX_BYTES).expect_err("a symlink must be refused"); assert_eq!( err.kind(), std::io::ErrorKind::InvalidInput, From 85088a9fb3dd9894788de3d06465236ced978c58 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 18:40:10 +1000 Subject: [PATCH 08/27] perf(core): look up relationship keys by id prefix instead of scanning key_info generate_profiles and repair_key_info_ids scanned the whole key_info map per relationship with starts_with. Sort the ids once and range-query them: exactly the same matches, O((keys + relationships) log keys). Refs #565. Signed-off-by: Glenn Gore --- openvtc-core/src/relationships.rs | 105 ++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 11 deletions(-) diff --git a/openvtc-core/src/relationships.rs b/openvtc-core/src/relationships.rs index 38e6cae6..32445a5d 100644 --- a/openvtc-core/src/relationships.rs +++ b/openvtc-core/src/relationships.rs @@ -27,8 +27,9 @@ use secrecy::ExposeSecret; use serde::{Deserialize, Serialize}; use serde_json::json; use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, fmt::Display, + ops::Bound, sync::Arc, time::SystemTime, }; @@ -44,6 +45,34 @@ use uuid::Uuid; /// How many relationship keys are fetched from the VTA at once at startup. const VTA_KEY_FETCH_CONCURRENCY: usize = 8; +/// The entries of an id-sorted map whose id starts with `prefix`. +/// +/// Every string that starts with `prefix` sorts at or after it, and they are +/// contiguous, so this is exactly `map.iter().filter(|(k, _)| k.starts_with(prefix))` +/// at O(log n + matches) rather than a scan of the whole map. Sorting `key_info` +/// once and querying it per relationship keeps the startup passes +/// O((keys + relationships) log keys) instead of O(keys × relationships). +fn entries_with_prefix<'a, K, V>( + sorted: &'a BTreeMap, + prefix: &'a str, +) -> impl Iterator + 'a +where + K: std::borrow::Borrow + Ord, +{ + sorted + .range::((Bound::Included(prefix), Bound::Unbounded)) + .take_while(move |(k, _)| (*k).borrow().starts_with(prefix)) +} + +/// Whether any id in the sorted set starts with `prefix` — exactly +/// `ids.iter().any(|k| k.starts_with(prefix))`, by the argument on +/// [`entries_with_prefix`]: the first id at or after `prefix` decides it. +fn any_with_prefix(ids: &BTreeSet, prefix: &str) -> bool { + ids.range::((Bound::Included(prefix), Bound::Unbounded)) + .next() + .is_some_and(|k| k.starts_with(prefix)) +} + /// State machine for the lifecycle of a relationship between two parties. #[derive(Clone, Debug, Hash, Serialize, Deserialize, PartialEq, Eq)] pub enum RelationshipState { @@ -412,6 +441,10 @@ impl Relationships { let mut all_secrets: Vec = Vec::new(); let mut vta_fetches: Vec = Vec::new(); + // `key_info` by id, sorted once, so each R-DID's keys are one range. + let key_info_sorted: BTreeMap<&str, &KeyInfoConfig> = + key_info.iter().map(|(k, v)| (k.as_str(), v)).collect(); + for our_did in &r_did_entries { // Skip any R-DID that has no canonical key-agreement (encryption) // entry in `key_info`. Registering a messaging profile for such a @@ -422,10 +455,8 @@ impl Relationships { // older build's id bug); flag it for the user to re-establish // rather than spin. Recoverable R-DIDs were re-keyed to // `{r_did}#key-2` by the repair pass and pass this check. - let has_key_agreement = key_info.iter().any(|(k, v)| { - k.starts_with(our_did.as_str()) - && matches!(v.purpose, KeyTypes::RelationshipEncryption) - }); + let has_key_agreement = entries_with_prefix(&key_info_sorted, our_did.as_str()) + .any(|(_, v)| matches!(v.purpose, KeyTypes::RelationshipEncryption)); if !has_key_agreement { warn!( r_did = %our_did, @@ -442,10 +473,8 @@ impl Relationships { profiles.insert(our_did.clone(), atm.profile_add(&profile, false).await?); // Collect secrets for this DID - for (k, v) in key_info.iter() { - if !k.starts_with(our_did.as_str()) { - continue; - } + for (k, v) in entries_with_prefix(&key_info_sorted, our_did.as_str()) { + let k = (*k).to_string(); let kp = match v.purpose { KeyTypes::RelationshipVerification => KeyPurpose::Signing, KeyTypes::RelationshipEncryption => KeyPurpose::Encryption, @@ -590,11 +619,14 @@ impl Relationships { let path_pointer = self.path_pointer; // remote_p_dids to flag once the immutable snapshot above is consumed. let mut to_flag: Vec> = Vec::new(); + // The `key_info` ids, sorted, kept in step with every insert/remove below + // so the "already canonical" check is a range lookup, not a full scan. + let mut key_ids: BTreeSet = key_info.keys().cloned().collect(); for (remote_p_did, r_did) in targets { // Already canonical (created post-fix or previously repaired): a - // cheap string scan keeps the whole pass idempotent. - if key_info.keys().any(|k| k.starts_with(r_did.as_str())) { + // cheap prefix lookup keeps the whole pass idempotent. + if any_with_prefix(&key_ids, r_did.as_str()) { continue; } @@ -653,7 +685,9 @@ impl Relationships { && old != new_id { key_info.remove(&old); + key_ids.remove(&old); } + key_ids.insert(new_id.clone()); key_info.insert(new_id, new_ki); report.repaired += 1; } @@ -1106,6 +1140,55 @@ mod tests { use ed25519_dalek_bip32::ExtendedSigningKey; use secrecy::SecretString; + #[test] + fn prefix_lookups_match_a_starts_with_scan() { + // Ids shaped like `key_info`: `{did}#key-N`, plus the awkward cases a + // `#`-split grouping would get wrong — a DID that is a prefix of another + // DID, an id equal to the DID, and ids that are not DID-shaped at all. + let ids = [ + "did:peer:2.Vabc#key-1", + "did:peer:2.Vabc#key-2", + "did:peer:2.Vabcd#key-1", + "did:peer:2.Vab", + "did:peer:2.Vb#key-1", + "did:webvh:Q:example.com#key-0", + "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "x25519", + "", + ]; + let sorted: BTreeMap<&str, usize> = ids.iter().enumerate().map(|(i, k)| (*k, i)).collect(); + let set: BTreeSet = ids.iter().map(|k| (*k).to_string()).collect(); + let prefixes = [ + "did:peer:2.Vabc", + "did:peer:2.Vab", + "did:peer:2.Vabcd#key-1", + "did:peer:2.Vz", + "did:webvh:Q:example.com", + "did:", + "z6Mk", + "x25519", + "zzz", + "", + ]; + for prefix in prefixes { + let mut expected: Vec<&str> = ids + .iter() + .copied() + .filter(|k| k.starts_with(prefix)) + .collect(); + expected.sort_unstable(); + let got: Vec<&str> = entries_with_prefix(&sorted, prefix) + .map(|(k, _)| *k) + .collect(); + assert_eq!(got, expected, "entries_with_prefix({prefix:?})"); + assert_eq!( + any_with_prefix(&set, prefix), + !expected.is_empty(), + "any_with_prefix({prefix:?})" + ); + } + } + fn make_relationship( task_id: &str, our_did: &str, From c6ebe5b415bebf43c2bc6fe12db7f202d08d9148 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 18:43:49 +1000 Subject: [PATCH 09/27] perf(tui): check each VRC for post-quantum proofs once, not on every sync finish_credential_rows serialised every VRC to read its proofs on each sync_from_config. Remember the answer by credential id (a VRC's id is its proof value) across syncs; each sync keeps only the ids it shows, so the memo is bounded by the credentials on display. Refs #555. Signed-off-by: Glenn Gore --- .../src/state_handler/main_page/content.rs | 3 +- openvtc/src/state_handler/main_page/mod.rs | 130 +++++++++++++++++- 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index 621ab248..42167ea1 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -54,7 +54,8 @@ impl RawCredential { /// Whether the credential carries a post-quantum proof (ML-DSA or /// SLH-DSA, alone or in a proof set). Serializes a VRC, so call it when a - /// list is built, not per frame. + /// list is built, not per frame — and the sync asks through a memo keyed by + /// credential id, so a VRC is serialised for it once, not on every sync. #[must_use] pub fn signed_post_quantum(&self) -> bool { match self { diff --git a/openvtc/src/state_handler/main_page/mod.rs b/openvtc/src/state_handler/main_page/mod.rs index a1f70fee..334dd9bc 100644 --- a/openvtc/src/state_handler/main_page/mod.rs +++ b/openvtc/src/state_handler/main_page/mod.rs @@ -96,6 +96,68 @@ pub struct MainPageState { /// trust-anchor override (see `crate::env_overrides`). Rendered at the front /// of the bottom bar, so the run cannot be mistaken for a normal one. pub dev_override: Option, + + /// Whether each VRC row is post-quantum signed, remembered across syncs so a + /// credential is serialised for the check once rather than on every sync. + post_quantum: PostQuantumMemo, +} + +/// The post-quantum flag of every VRC row, by credential id, kept from one +/// [`MainPageState::sync_from_config`] to the next. +/// +/// Answering it for a VRC means serialising the credential, and the sync runs on +/// every config mutation — once per inbound message in a redelivery burst. A VRC's +/// id is its proof value, so the id fixes the proofs the flag is read from: an id +/// seen before has the same answer. Bounded by the credentials on display: each +/// sync keeps only the ids it showed ([`Self::finish_sync`]). +/// +/// JSON-held credentials (membership, role, vetting) are not remembered: their +/// check is a walk of a value already in memory, with nothing to serialise. +#[derive(Debug, Default)] +struct PostQuantumMemo { + /// What the previous sync answered. + previous: std::collections::HashMap, + /// What this sync has answered so far. + current: std::collections::HashMap, +} + +/// A cache, so a clone starts empty: `MainPageState` is cloned to the UI on every +/// change, and the UI's copy never syncs. +impl Clone for PostQuantumMemo { + fn clone(&self) -> Self { + Self::default() + } +} + +impl PostQuantumMemo { + /// Whether the credential `id` carries a post-quantum proof. + fn post_quantum(&mut self, id: &str, raw: &content::RawCredential) -> bool { + self.post_quantum_with(id, raw, content::RawCredential::signed_post_quantum) + } + + /// [`Self::post_quantum`], answering a miss with `check`. + fn post_quantum_with( + &mut self, + id: &str, + raw: &content::RawCredential, + check: impl FnOnce(&content::RawCredential) -> bool, + ) -> bool { + if matches!(raw, content::RawCredential::Value(_)) { + return check(raw); + } + if let Some(&known) = self.current.get(id) { + return known; + } + let answer = self.previous.remove(id).unwrap_or_else(|| check(raw)); + self.current.insert(id.to_string(), answer); + answer + } + + /// End a sync: what it answered is what the next one may reuse, and ids it + /// did not show are forgotten. + fn finish_sync(&mut self) { + self.previous = std::mem::take(&mut self.current); + } } impl MainPageState { @@ -478,8 +540,10 @@ impl MainPageState { .collect(); // Sync credentials (scoped to the working community's persona) + let memo = &mut self.post_quantum; self.content_panel.credentials.received = finish_credential_rows( config, + memo, collect_vrcs( &config.private.vrcs_received, config, @@ -490,6 +554,7 @@ impl MainPageState { .into(); self.content_panel.credentials.issued = finish_credential_rows( config, + memo, collect_vrcs( &config.private.vrcs_issued, config, @@ -499,9 +564,10 @@ impl MainPageState { ) .into(); self.content_panel.credentials.membership = - finish_credential_rows(config, collect_membership_creds(config)).into(); + finish_credential_rows(config, memo, collect_membership_creds(config)).into(); self.content_panel.credentials.vetting = - finish_credential_rows(config, collect_vetting_statements(config)).into(); + finish_credential_rows(config, memo, collect_vetting_statements(config)).into(); + memo.finish_sync(); self.content_panel.credentials.retired_vrcs = config.private.vrcs_received.retired() + config.private.vrcs_issued.retired() + config @@ -1066,9 +1132,13 @@ fn collect_vrcs( /// What every credential row needs that its collector does not compute: the /// persona it was issued to, by name, and whether it is post-quantum signed. /// One pass here rather than in each collector, so no list can miss it. -fn finish_credential_rows(config: &Config, mut rows: Vec) -> Vec { +fn finish_credential_rows( + config: &Config, + post_quantum: &mut PostQuantumMemo, + mut rows: Vec, +) -> Vec { for row in &mut rows { - row.post_quantum = row.raw_json.signed_post_quantum(); + row.post_quantum = post_quantum.post_quantum(&row.vrc_id, &row.raw_json); if row.subject_is_self { row.subject_label = config .account @@ -3221,6 +3291,58 @@ mod tests { assert_eq!(lazy, eager, "lazy VRC JSON must match the old eager output"); } + /// A VRC is checked for post-quantum proofs once, then answered from the + /// memo on later syncs; an id a sync no longer shows is forgotten; JSON-held + /// credentials are always checked (there is nothing to serialise). + #[test] + fn post_quantum_memo_checks_a_vrc_once_across_syncs() { + use chrono::{TimeZone, Utc}; + use dtg_credentials::DTGCredential; + use std::cell::Cell; + + let vrc = content::RawCredential::Vrc(Arc::new(DTGCredential::new_vrc( + "did:test:issuer".to_string(), + dtg_credentials::IssuerScope::Pairwise, + "did:test:subject".to_string(), + Utc.with_ymd_and_hms(2024, 1, 2, 3, 4, 5).unwrap(), + None, + ))); + let value = content::RawCredential::Value(Arc::new(serde_json::json!({}))); + let checks = Cell::new(0u32); + let check = |answer: bool| { + let checks = &checks; + move |_: &content::RawCredential| { + checks.set(checks.get() + 1); + answer + } + }; + + let mut memo = PostQuantumMemo::default(); + // First sync: the check runs once, even when the row appears twice. + assert!(memo.post_quantum_with("vrc-1", &vrc, check(true))); + assert!(memo.post_quantum_with("vrc-1", &vrc, check(false))); + memo.finish_sync(); + assert_eq!(checks.get(), 1); + + // Second sync: answered from the memo, not re-checked. + assert!(memo.post_quantum_with("vrc-1", &vrc, check(false))); + memo.finish_sync(); + assert_eq!(checks.get(), 1); + + // A sync without it forgets it, so the memo stays bounded by what is shown. + memo.finish_sync(); + assert!(!memo.post_quantum_with("vrc-1", &vrc, check(false))); + assert_eq!(checks.get(), 2); + + // JSON-held credentials are always asked. + memo.post_quantum_with("vc-1", &value, check(true)); + memo.post_quantum_with("vc-1", &value, check(true)); + assert_eq!(checks.get(), 4); + + // The UI's clone of the state does not carry the cache. + assert!(memo.clone().previous.is_empty() && memo.clone().current.is_empty()); + } + /// The lazy `RawCredential::Value` path (membership/role credentials) must /// pretty-print byte-identically to the previous eager /// `serde_json::to_string_pretty(vc)`. From d9d8181df8e5b799d938148f695aa8a70411a781 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 18:44:51 +1000 Subject: [PATCH 10/27] perf(tui): look up persona labels and membership counts by map in sync finish_credential_rows scanned the personas for every self-subject row, and the persona list counted memberships with a filter per persona. Build both maps once per sync_from_config. Refs #565. Signed-off-by: Glenn Gore --- openvtc/src/state_handler/main_page/mod.rs | 98 ++++++++++++++++++---- 1 file changed, 82 insertions(+), 16 deletions(-) diff --git a/openvtc/src/state_handler/main_page/mod.rs b/openvtc/src/state_handler/main_page/mod.rs index 334dd9bc..45e40524 100644 --- a/openvtc/src/state_handler/main_page/mod.rs +++ b/openvtc/src/state_handler/main_page/mod.rs @@ -541,8 +541,9 @@ impl MainPageState { // Sync credentials (scoped to the working community's persona) let memo = &mut self.post_quantum; + let persona_labels = persona_labels_by_did(config); self.content_panel.credentials.received = finish_credential_rows( - config, + &persona_labels, memo, collect_vrcs( &config.private.vrcs_received, @@ -553,7 +554,7 @@ impl MainPageState { ) .into(); self.content_panel.credentials.issued = finish_credential_rows( - config, + &persona_labels, memo, collect_vrcs( &config.private.vrcs_issued, @@ -564,9 +565,10 @@ impl MainPageState { ) .into(); self.content_panel.credentials.membership = - finish_credential_rows(config, memo, collect_membership_creds(config)).into(); + finish_credential_rows(&persona_labels, memo, collect_membership_creds(config)).into(); self.content_panel.credentials.vetting = - finish_credential_rows(config, memo, collect_vetting_statements(config)).into(); + finish_credential_rows(&persona_labels, memo, collect_vetting_statements(config)) + .into(); memo.finish_sync(); self.content_panel.credentials.retired_vrcs = config.private.vrcs_received.retired() + config.private.vrcs_issued.retired() @@ -700,6 +702,12 @@ impl MainPageState { // DID is the holder's identity, not a property of the agent that // happens to host its keys, and the pane that manages it is the pane // that should list it. + // Memberships per persona, counted in one pass over the memberships. + let mut bound_communities: std::collections::HashMap = + std::collections::HashMap::new(); + for c in config.account.memberships() { + *bound_communities.entry(c.persona_ref).or_default() += 1; + } let mut personas: Vec = config .account .personas @@ -711,11 +719,7 @@ impl MainPageState { did: p.did.clone(), did_locator: p.did_locator.clone(), label: p.label.clone().unwrap_or_default(), - bound_communities: config - .account - .memberships() - .filter(|c| c.persona_ref == p.persona_id) - .count(), + bound_communities: bound_communities.get(&p.persona_id).copied().unwrap_or(0), is_active: p.did.as_str() == persona_did, }) .collect(); @@ -1133,25 +1137,37 @@ fn collect_vrcs( /// persona it was issued to, by name, and whether it is post-quantum signed. /// One pass here rather than in each collector, so no list can miss it. fn finish_credential_rows( - config: &Config, + persona_labels: &std::collections::HashMap<&str, Option<&str>>, post_quantum: &mut PostQuantumMemo, mut rows: Vec, ) -> Vec { for row in &mut rows { row.post_quantum = post_quantum.post_quantum(&row.vrc_id, &row.raw_json); if row.subject_is_self { - row.subject_label = config - .account - .personas - .values() - .find(|p| p.did == row.subject) - .and_then(|p| p.label.as_deref()) + row.subject_label = persona_labels + .get(row.subject.as_str()) + .copied() + .flatten() .map(|l| sanitize_display(l, 64)); } } rows } +/// Each persona DID's own label (`None` when it has none), built once per sync +/// so a credential row finds its subject's label by lookup rather than a scan +/// of the personas. Where two personas share a DID the first in iteration order +/// wins, as the scan's `find` did. +fn persona_labels_by_did(config: &Config) -> std::collections::HashMap<&str, Option<&str>> { + let mut labels = std::collections::HashMap::with_capacity(config.account.personas.len()); + for p in config.account.personas.values() { + labels + .entry(p.did.as_str()) + .or_insert_with(|| p.label.as_deref()); + } + labels +} + fn collect_membership_creds(config: &Config) -> Vec { let mut result = Vec::new(); for c in config.account.memberships() { @@ -3291,6 +3307,56 @@ mod tests { assert_eq!(lazy, eager, "lazy VRC JSON must match the old eager output"); } + /// The per-sync lookup answers what the per-row scan did: a persona's label + /// by its DID, `None` for a persona without one or a DID that is not ours. + #[test] + fn persona_labels_by_did_matches_the_scan() { + use openvtc_core::config::account::PersonaRecord; + let mut config = crate::state_handler::dispatch_util::test_config(); + for (did, label) in [ + ("did:webvh:scid:example.com:a", Some("Alice")), + ("did:webvh:scid:example.com:b", None), + ] { + let id = PersonaId::new(); + config.account.personas.insert( + id, + PersonaRecord { + extra: serde_json::Map::new(), + persona_id: id, + did: did.into(), + did_document: None, + did_locator: None, + key_refs: vec![], + mediator_did: None, + origin_context_id: "ctx".into(), + created_at: chrono::Utc::now(), + label: label.map(Into::into), + }, + ); + } + let labels = persona_labels_by_did(&config); + for did in [ + "did:webvh:scid:example.com:a", + "did:webvh:scid:example.com:b", + "did:webvh:scid:example.com:other", + ] { + let scan = config + .account + .personas + .values() + .find(|p| p.did == did) + .and_then(|p| p.label.as_deref()); + assert_eq!(labels.get(did).copied().flatten(), scan, "{did}"); + } + assert_eq!( + labels + .get("did:webvh:scid:example.com:a") + .copied() + .flatten(), + Some("Alice") + ); + } + /// A VRC is checked for post-quantum proofs once, then answered from the /// memo on later syncs; an id a sync no longer shows is forgotten; JSON-held /// credentials are always checked (there is nothing to serialise). From a6da9ed3c402588a38483a40855ba8c8b8f3b6c8 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 18:48:14 +1000 Subject: [PATCH 11/27] perf(tui): detect the did-git-sign install off the loop, not in every sync sync_from_config read did-git-sign's SigningConfig and allowed_signers on every config change. Detection now runs on a blocking thread as a DidGitSign dispatch and is kept in SettingsState with the persona DID it was read for: a sync for another persona hides it, and a refresh is due on a persona switch, after a Repos signing change, or when Help / Status or Settings is opened. The Repos tick checks for a due refresh; a check is a few comparisons, no I/O. Refs #555. Signed-off-by: Glenn Gore --- .../src/state_handler/background_dispatch.rs | 25 +- .../src/state_handler/main_page/content.rs | 15 ++ openvtc/src/state_handler/main_page/mod.rs | 9 +- openvtc/src/state_handler/repos_actions.rs | 4 + openvtc/src/state_handler/settings_actions.rs | 243 +++++++++++++++++- 5 files changed, 292 insertions(+), 4 deletions(-) diff --git a/openvtc/src/state_handler/background_dispatch.rs b/openvtc/src/state_handler/background_dispatch.rs index 47e4d8fc..1b522f04 100644 --- a/openvtc/src/state_handler/background_dispatch.rs +++ b/openvtc/src/state_handler/background_dispatch.rs @@ -111,6 +111,12 @@ pub(crate) enum DispatchDomain { /// only. Its own domain so a periodic read never makes a member's key /// press wait. GitWorkspaceProbe, + /// Detecting the did-git-sign install for the persona, for Help / Status + /// and Settings: two small file reads on a blocking thread. Never claimed + /// by a key press, so a refresh asked for while one runs is queued + /// ([`SettingsState::did_git_sign_wanted`](crate::state_handler::main_page::content::SettingsState::did_git_sign_wanted)) + /// rather than reported as busy. + DidGitSign, /// A `vtc/rooms/*` request sent to a community from the Rooms panel. The answer /// arrives on the inbound channel; this serialises the sends. Rooms, @@ -189,6 +195,7 @@ impl DispatchDomain { DispatchDomain::GitNs => "Repository request", DispatchDomain::GitWorkspace => "Checkout or signing change", DispatchDomain::GitWorkspaceProbe => "Checkout check", + DispatchDomain::DidGitSign => "Signing install check", DispatchDomain::Rooms => "Data room request", DispatchDomain::RoomsVta => "Data room keys at your VTA", DispatchDomain::Elections => "Election request", @@ -307,6 +314,13 @@ pub(crate) enum DispatchOutcome { Workspace(crate::state_handler::repos_workspace::WorkspaceOutcome), /// A Repos-panel checkout and signing read finished. WorkspaceProbe(crate::state_handler::repos_workspace::ProbeOutcome), + /// The did-git-sign install was read for `persona_did`. Display-only. + DidGitSign { + /// The persona DID the detection was for. + persona_did: String, + /// What it found; `None` when did-git-sign is not set up for it. + info: Option, + }, /// A `vtc/rooms/*` request was sent to a community (or failed to send). Rooms(crate::state_handler::rooms_actions::RoomsOutcome), /// The member's VTA answered the Rooms panel. @@ -387,6 +401,7 @@ impl DispatchOutcome { DispatchOutcome::Repos(_) => DispatchDomain::GitNs, DispatchOutcome::Workspace(_) => DispatchDomain::GitWorkspace, DispatchOutcome::WorkspaceProbe(_) => DispatchDomain::GitWorkspaceProbe, + DispatchOutcome::DidGitSign { .. } => DispatchDomain::DidGitSign, DispatchOutcome::Rooms(_) => DispatchDomain::Rooms, DispatchOutcome::RoomsVta(_) => DispatchDomain::RoomsVta, DispatchOutcome::Elections(_) => DispatchDomain::Elections, @@ -615,8 +630,16 @@ pub(crate) fn apply_outcome( DispatchOutcome::Credential(outcome) => outcome.apply(state, config, save), DispatchOutcome::Capabilities(outcome) => outcome.apply(state), DispatchOutcome::Repos(outcome) => outcome.apply(state), - DispatchOutcome::Workspace(outcome) => outcome.apply(state), + DispatchOutcome::Workspace(outcome) => { + outcome.apply(state); + // A clone, a signing set-up or a removal may have changed the + // did-git-sign install Help / Status shows: read it again. + state.main_page.content_panel.settings.did_git_sign_wanted = true; + } DispatchOutcome::WorkspaceProbe(outcome) => outcome.apply(state), + DispatchOutcome::DidGitSign { persona_did, info } => { + crate::state_handler::settings_actions::apply_did_git_sign(state, persona_did, info); + } DispatchOutcome::Rooms(outcome) => outcome.apply(state), DispatchOutcome::RoomsVta(outcome) => outcome.apply(state), DispatchOutcome::Elections(outcome) => outcome.apply(state), diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index 42167ea1..a39a5dd4 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -4124,7 +4124,22 @@ pub struct SettingsState { /// git commit signing. Surfaced on the Help/Status panel so the operator /// can copy the SSH public key into their git host's signing-key /// settings. + /// + /// Detecting it reads files, so it is not done in `sync_from_config`: it is + /// refreshed off the loop (`settings_actions::refresh_did_git_sign`) and kept + /// here between refreshes. pub did_git_sign: Option, + /// The persona DID [`did_git_sign`](Self::did_git_sign) was detected for; + /// `None` before the first detection. When it differs from + /// [`persona_did`](Self::persona_did) — a persona switch — the sync hides the + /// old persona's info and a refresh is due. + pub did_git_sign_for: Option, + /// A refresh was asked for — an install changed, or a refresh was wanted + /// while one was already running — and has not started yet. + pub did_git_sign_wanted: bool, + /// Whether Help / Status or Settings was on screen at the last refresh + /// check, so opening either refreshes once rather than on every check. + pub did_git_sign_viewing: bool, } /// Snapshot of the local did-git-sign install for this persona. diff --git a/openvtc/src/state_handler/main_page/mod.rs b/openvtc/src/state_handler/main_page/mod.rs index 45e40524..dba325e2 100644 --- a/openvtc/src/state_handler/main_page/mod.rs +++ b/openvtc/src/state_handler/main_page/mod.rs @@ -585,7 +585,11 @@ impl MainPageState { self.content_panel.settings.persona_agent_name = config .agent_name_for(config.persona_did()) .map(str::to_owned); - self.content_panel.settings.did_git_sign = detect_did_git_sign_info(config.persona_did()); + // did-git-sign is detected off the loop (it reads files); the sync only + // makes sure info detected for another persona is never shown for this one. + if self.content_panel.settings.did_git_sign_for.as_deref() != Some(config.persona_did()) { + self.content_panel.settings.did_git_sign = None; + } self.content_panel.settings.context_id = config.account.top_context_id.clone(); // Sync VTA info self.content_panel.vta.persona_did = config.persona_did().to_string(); @@ -1711,7 +1715,8 @@ pub fn sanitize_display(input: &str, max_len: usize) -> String { /// `None` if did-git-sign is not configured for this persona, or if the /// state on disk is malformed. /// -/// Reads files synchronously and is cheap (single small file open + read). +/// Reads files synchronously, so it runs on a blocking thread +/// (`settings_actions::refresh_did_git_sign`), never in `sync_from_config`. /// Sourced from disk rather than re-derived from runtime key material so /// the help screen reflects what `did-git-sign` itself would actually use /// — i.e. if the config was hand-edited, the help view stays consistent diff --git a/openvtc/src/state_handler/repos_actions.rs b/openvtc/src/state_handler/repos_actions.rs index 6c895f36..c57a1cde 100644 --- a/openvtc/src/state_handler/repos_actions.rs +++ b/openvtc/src/state_handler/repos_actions.rs @@ -1580,6 +1580,10 @@ pub(crate) async fn tick(lp: &mut Loop<'_>) { .await; } super::repos_workspace::probe_if_due(lp.state, lp.dispatch_tx, lp.in_flight); + // The did-git-sign install as Help / Status and Settings show it: read off + // the loop when it is due (a persona switch, a signing change made here, + // either view opened), never in `sync_from_config`. + super::settings_actions::maybe_refresh_did_git_sign(lp.state, lp.in_flight, lp.dispatch_tx); } #[cfg(test)] diff --git a/openvtc/src/state_handler/settings_actions.rs b/openvtc/src/state_handler/settings_actions.rs index 804f54c3..52522f55 100644 --- a/openvtc/src/state_handler/settings_actions.rs +++ b/openvtc/src/state_handler/settings_actions.rs @@ -1,5 +1,6 @@ //! Settings action handlers for the TUI. +use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; use crate::state_handler::main_page::status::Status; #[allow(unused_imports)] use crate::state_handler::state::PublishState; @@ -145,7 +146,7 @@ use crate::state_handler::{ state::{self, State}, }; use crate::ui::pages::main::components::settings_panel as rows; -use tokio::sync::watch; +use tokio::sync::{mpsc::UnboundedSender, watch}; fn handle_select(state: &mut State, index: usize) { #[cfg(feature = "openpgp-card")] @@ -591,6 +592,108 @@ fn handle_wipe_confirm(state: &mut State, config: &Config, profile: &str) -> boo true } +/// Detect the did-git-sign install for the persona shown in Settings, off the +/// loop: the read is two small files, but on a slow or network home directory +/// that is the stall, and `sync_from_config` used to do it on every config +/// change. The answer arrives as [`DispatchOutcome::DidGitSign`] and is stored by +/// [`apply_did_git_sign`]. +/// +/// Call it at startup, and whenever the install may have changed. When a +/// detection is already running this one is queued +/// ([`SettingsState::did_git_sign_wanted`]) and started by the next +/// [`maybe_refresh_did_git_sign`], so the answer reflects the latest change. +/// +/// [`SettingsState::did_git_sign_wanted`]: crate::state_handler::main_page::content::SettingsState::did_git_sign_wanted +pub(crate) fn refresh_did_git_sign( + state: &mut State, + in_flight: &mut InFlight, + dispatch_tx: &UnboundedSender, +) { + let settings = &mut state.main_page.content_panel.settings; + let persona_did = settings.persona_did.clone(); + // No persona (a zero-persona account, or before the first sync): nothing + // can be configured for it, and there is nothing to read. + if persona_did.is_empty() { + settings.did_git_sign = None; + settings.did_git_sign_for = Some(persona_did); + settings.did_git_sign_wanted = false; + return; + } + if !in_flight.try_begin(DispatchDomain::DidGitSign) { + settings.did_git_sign_wanted = true; + return; + } + settings.did_git_sign_wanted = false; + background_dispatch::spawn_dispatch( + dispatch_tx.clone(), + DispatchDomain::DidGitSign, + async move { + let info = detect_did_git_sign_off_loop(persona_did.clone()).await; + DispatchOutcome::DidGitSign { persona_did, info } + }, + ); +} + +/// [`detect_did_git_sign_info`](crate::state_handler::main_page::detect_did_git_sign_info) +/// on a blocking thread. A detection that panics reads as "not set up". +pub(crate) async fn detect_did_git_sign_off_loop( + persona_did: String, +) -> Option { + crate::state_handler::repos_workspace::blocking(move || { + crate::state_handler::main_page::detect_did_git_sign_info(&persona_did) + }) + .await + .ok() + .flatten() +} + +/// Whether the did-git-sign info should be read again, and note what is on +/// screen: due when it was never read for the current persona (startup, a +/// persona switch), when a refresh was asked for, or when Help / Status or +/// Settings has just been opened. Cheap — no I/O — so it can run on every pass +/// of the loop. +fn did_git_sign_refresh_due(state: &mut State) -> bool { + let viewing = matches!( + state.main_page.menu_panel.selected_menu, + crate::state_handler::main_page::menu::MainMenu::Help + | crate::state_handler::main_page::menu::MainMenu::Settings + ); + let settings = &mut state.main_page.content_panel.settings; + let opened = viewing && !settings.did_git_sign_viewing; + settings.did_git_sign_viewing = viewing; + opened + || settings.did_git_sign_wanted + || settings.did_git_sign_for.as_deref() != Some(settings.persona_did.as_str()) +} + +/// [`refresh_did_git_sign`] when one is due (see [`did_git_sign_refresh_due`]). +/// Meant for the loop: after an action or on a tick. Starts nothing while a +/// detection is running. +pub(crate) fn maybe_refresh_did_git_sign( + state: &mut State, + in_flight: &mut InFlight, + dispatch_tx: &UnboundedSender, +) { + if did_git_sign_refresh_due(state) && !in_flight.is_busy(DispatchDomain::DidGitSign) { + refresh_did_git_sign(state, in_flight, dispatch_tx); + } +} + +/// Store a finished detection — unless the persona changed while it ran, in +/// which case it describes a persona no longer shown and is dropped; the +/// current persona is still unread, so the next check refreshes it. +pub(crate) fn apply_did_git_sign( + state: &mut State, + persona_did: String, + info: Option, +) { + let settings = &mut state.main_page.content_panel.settings; + if settings.persona_did == persona_did { + settings.did_git_sign = info; + settings.did_git_sign_for = Some(persona_did); + } +} + /// The did-git-sign identities this profile's personas sign with: each /// persona's assertion method, and the default identity the settings panel /// detected (an install from before per-persona profiles). @@ -1101,6 +1204,144 @@ pub(crate) async fn dispatch( mod tests { use super::*; + fn git_sign_info(did_key_id: &str) -> crate::state_handler::main_page::content::DidGitSignInfo { + crate::state_handler::main_page::content::DidGitSignInfo { + did_key_id: did_key_id.into(), + ssh_public_key: String::new(), + config_path: String::new(), + } + } + + /// A detection that finished after the persona changed is dropped, so one + /// persona's signing key is never shown as another's; a sync for another + /// persona hides what was detected for the last one. + #[test] + fn did_git_sign_info_is_only_shown_for_the_persona_it_was_read_for() { + let mut state = State::default(); + state.main_page.content_panel.settings.persona_did = "did:example:b".into(); + + apply_did_git_sign( + &mut state, + "did:example:a".into(), + Some(git_sign_info("did:example:a#key-0")), + ); + let settings = &state.main_page.content_panel.settings; + assert!(settings.did_git_sign.is_none()); + assert_eq!(settings.did_git_sign_for, None); + + apply_did_git_sign( + &mut state, + "did:example:b".into(), + Some(git_sign_info("did:example:b#key-0")), + ); + let settings = &state.main_page.content_panel.settings; + assert!(settings.did_git_sign.is_some()); + assert_eq!(settings.did_git_sign_for.as_deref(), Some("did:example:b")); + + // A sync for a persona the info was not read for hides it. + let config = crate::state_handler::dispatch_util::test_config(); + state.main_page.sync_from_config(&config); + let settings = &state.main_page.content_panel.settings; + if config.persona_did() != "did:example:b" { + assert!(settings.did_git_sign.is_none()); + } + } + + /// When a refresh is due: never read for this persona, asked for, or Help / + /// Settings just opened — and not again while it stays open. + #[test] + fn did_git_sign_refresh_is_due_on_switch_request_and_open() { + use crate::state_handler::main_page::menu::MainMenu; + let mut state = State::default(); + state.main_page.content_panel.settings.persona_did = "did:example:a".into(); + assert!(did_git_sign_refresh_due(&mut state), "never read"); + + apply_did_git_sign(&mut state, "did:example:a".into(), None); + assert!( + !did_git_sign_refresh_due(&mut state), + "read, nothing changed" + ); + + state.main_page.content_panel.settings.did_git_sign_wanted = true; + assert!(did_git_sign_refresh_due(&mut state), "asked for"); + state.main_page.content_panel.settings.did_git_sign_wanted = false; + + state.main_page.menu_panel.selected_menu = MainMenu::Help; + assert!(did_git_sign_refresh_due(&mut state), "Help opened"); + assert!(!did_git_sign_refresh_due(&mut state), "still open"); + state.main_page.menu_panel.selected_menu = MainMenu::Settings; + assert!(!did_git_sign_refresh_due(&mut state), "Help to Settings"); + state.main_page.menu_panel.selected_menu = MainMenu::Logs; + assert!(!did_git_sign_refresh_due(&mut state), "closed"); + state.main_page.menu_panel.selected_menu = MainMenu::Settings; + assert!(did_git_sign_refresh_due(&mut state), "Settings reopened"); + + state.main_page.content_panel.settings.persona_did = "did:example:b".into(); + assert!(did_git_sign_refresh_due(&mut state), "persona switched"); + } + + /// A refresh runs off the loop and comes back as an outcome for the persona + /// it was started for; one asked for while it runs is queued, not dropped. + #[tokio::test] + async fn did_git_sign_refresh_runs_as_a_dispatch_and_queues_a_second() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let mut in_flight = InFlight::default(); + let mut state = State::default(); + state.main_page.content_panel.settings.persona_did = "did:example:none".into(); + + maybe_refresh_did_git_sign(&mut state, &mut in_flight, &tx); + assert!(in_flight.is_busy(DispatchDomain::DidGitSign)); + refresh_did_git_sign(&mut state, &mut in_flight, &tx); + assert!(state.main_page.content_panel.settings.did_git_sign_wanted); + + let outcome = rx.recv().await.expect("the detection reports"); + assert_eq!(outcome.domain(), DispatchDomain::DidGitSign); + let DispatchOutcome::DidGitSign { persona_did, .. } = &outcome else { + panic!("a did-git-sign outcome"); + }; + assert_eq!(persona_did, "did:example:none"); + let mut config = crate::state_handler::dispatch_util::test_config(); + let mut save = crate::state_handler::save_coalesce::SaveScheduler::new("test"); + background_dispatch::apply_outcome( + &mut state, + &mut config, + &mut save, + &mut in_flight, + outcome, + ); + assert!(!in_flight.is_busy(DispatchDomain::DidGitSign)); + assert_eq!( + state + .main_page + .content_panel + .settings + .did_git_sign_for + .as_deref(), + Some("did:example:none") + ); + + // The queued one starts on the next check. + maybe_refresh_did_git_sign(&mut state, &mut in_flight, &tx); + assert!(in_flight.is_busy(DispatchDomain::DidGitSign)); + assert!(!state.main_page.content_panel.settings.did_git_sign_wanted); + assert!(rx.recv().await.is_some()); + + // No persona: answered without a read. + let mut in_flight = InFlight::default(); + state.main_page.content_panel.settings.persona_did.clear(); + refresh_did_git_sign(&mut state, &mut in_flight, &tx); + assert!(!in_flight.is_busy(DispatchDomain::DidGitSign)); + assert_eq!( + state + .main_page + .content_panel + .settings + .did_git_sign_for + .as_deref(), + Some("") + ); + } + /// The picker previews as it moves, keeps what Enter chose, and puts back /// what was in use when cancelled. One test, because the active theme is /// process-wide. From d7cf10f544862e01124b1ab79d1c70d282af63e8 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 18:52:14 +1000 Subject: [PATCH 12/27] refactor(core): regenerate_persona_keys takes &TDK It only reads the TDK (the secrets resolver is shared state), so a shared borrow lets personas be hydrated concurrently. Refs #557. Signed-off-by: Glenn Gore --- openvtc-core/src/config/keys.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openvtc-core/src/config/keys.rs b/openvtc-core/src/config/keys.rs index d79f5995..dce45308 100644 --- a/openvtc-core/src/config/keys.rs +++ b/openvtc-core/src/config/keys.rs @@ -320,7 +320,7 @@ impl Config { /// them apart. Previously both were `OpenVTCError::Config` and either one /// failed the entire profile load. pub(crate) async fn regenerate_persona_keys( - tdk: &mut TDK, + tdk: &TDK, sc: &SecuredConfig, key_backend: &KeyBackend, doc: &Document, From 45502702469d91d08664259d1bde92f2b2383851 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 18:52:14 +1000 Subject: [PATCH 13/27] perf(core): hydrate personas three at a time at startup Each persona's document resolve, key fetch and profile registration ran one persona after another. They share only read-only handles, so run them with a bound of three and record the results in account order: the degraded report and identities are the same whichever finishes first, and a fault no persona can work around still fails the load. Refs #557. Signed-off-by: Glenn Gore --- openvtc-core/src/config/loading.rs | 435 ++++++++++++++++++----------- 1 file changed, 273 insertions(+), 162 deletions(-) diff --git a/openvtc-core/src/config/loading.rs b/openvtc-core/src/config/loading.rs index 4f8d3421..17e0c885 100644 --- a/openvtc-core/src/config/loading.rs +++ b/openvtc-core/src/config/loading.rs @@ -14,6 +14,7 @@ use crate::{ use affinidi_tdk::{TDK, messaging::profiles::ATMProfile}; use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD}; use ed25519_dalek_bip32::ExtendedSigningKey; +use futures_util::StreamExt; use secrecy::{ExposeSecret, SecretBox, SecretString}; use std::collections::BTreeMap; use tracing::{debug, info, warn}; @@ -125,6 +126,206 @@ fn record_degraded( }); } +/// How many personas are hydrated at once at startup. Each is a few VTA +/// round-trips (and a DID resolve when uncached); a small bound keeps a +/// many-persona account from bursting the VTA (R1.4). +const PERSONA_HYDRATE_CONCURRENCY: usize = 3; + +/// Run `f` over `items` at most `limit` at a time, and return the results in +/// the order of `items` — not the order they finished in — so whatever is done +/// with them afterwards is deterministic. +async fn map_bounded_in_order(items: I, limit: usize, mut f: F) -> Vec +where + I: IntoIterator, + F: FnMut(I::Item) -> Fut, + Fut: std::future::Future, +{ + let mut results: Vec<(usize, T)> = + futures_util::stream::iter(items.into_iter().enumerate().map(|(index, item)| { + let fut = f(item); + async move { (index, fut.await) } + })) + .buffer_unordered(limit.max(1)) + .collect() + .await; + results.sort_by_key(|(index, _)| *index); + results.into_iter().map(|(_, result)| result).collect() +} + +/// One persona as the account records it, for hydration: id, DID, mediator, +/// cached DID document, and a did:scid persona's DID URL with `src`. +type PersonaSeed = ( + PersonaId, + std::sync::Arc, + String, + Option, + Option, +); + +/// What every persona's hydration shares. All shared references: nothing here +/// is changed per persona, so several can run at once. +struct HydrateContext<'a> { + tdk: &'a TDK, + sc: &'a SecuredConfig, + key_backend: &'a KeyBackend, + vta_client: Option<&'a vta_sdk::client::VtaClient>, + on_progress: Option>, +} + +impl HydrateContext<'_> { + fn progress(&self, major: &str, sub: &str) { + if let Some(f) = self.on_progress { + f(major, sub); + } + } +} + +/// What hydrating one persona came to. +enum PersonaHydration { + /// Keys loaded and a messaging profile registered. + Loaded(Box), + /// This persona cannot be brought up this session; the others are not + /// affected. Recorded by the caller, in account order. + Degraded(DegradedReason), +} + +/// Bring one persona up: its DID document (cached, else resolved), its keys +/// into the TDK, and its messaging profile registered with ATM. A fault in the +/// persona is [`PersonaHydration::Degraded`]; `Err` is only for a fault no +/// persona can work around (the ATM service missing), which fails the load. +async fn hydrate_persona( + ctx: &HydrateContext<'_>, + seed: &PersonaSeed, + profile_label: String, +) -> Result { + let (persona_id, persona_did, persona_mediator, cached_document, did_locator) = seed; + let tdk = ctx.tdk; + // A did:scid persona's bare DID resolves only through a source + // the resolver holds, and the resolver forgets its sources on + // restart: seed it with the one recorded at mint (VTI-KEY-162). + // It is also the DID the persona sends as, so a peer meeting it + // for the first time can resolve it (VTI-KEY-163). + if let Some(locator) = did_locator { + tdk.did_resolver().scid_sources().add_source(locator); + } + let presented_did: &str = did_locator.as_deref().unwrap_or(persona_did.as_str()); + + // PERF #3: prefer the persisted persona DID document over a fresh + // network resolve (~1s). did:webvh docs change rarely between + // launches; a stale doc only matters if the persona rotated keys + // out-of-band — rare, and recoverable on the next mint/resolve. + let document = if let Some(doc) = cached_document.clone() { + ctx.progress("Identity", "Loading DID document (cached)"); + doc + } else { + ctx.progress("Identity", "Resolving DID document"); + match tdk.did_resolver().resolve(presented_did).await { + Ok(resolved) => resolved.doc, + // Isolated, not fatal: this persona has no usable + // document, but the others may. + Err(e) => { + return Ok(PersonaHydration::Degraded( + DegradedReason::DocumentUnresolvable { + detail: e.to_string(), + }, + )); + } + } + }; + + // Final mediator resolution. If neither the persona record nor + // the account carried a mediator, recover it from the DID + // document itself — the persona DID was minted with a + // DIDCommMessaging service whose endpoint IS the mediator, so it + // is always authoritative. Without this the persona listener + // fails with "No Mediator is configured" and reconnect-loops. + let persona_mediator = if persona_mediator.is_empty() { + match super::did::mediator_from_document(&document) { + Some(m) => { + warn!( + persona = %persona_did, + mediator = %m, + "recovered persona mediator from its DID document" + ); + m + } + None => { + warn!( + persona = %persona_did, + "persona has no mediator anywhere (record, account, \ + or DID document) — its listener will not connect" + ); + persona_mediator.clone() + } + } + } else { + persona_mediator.clone() + }; + + ctx.progress("Identity", "Fetching persona keys"); + // THE isolation point. A persona recorded in the account with no + // `key_info` — the signature of a save that wrote the config file + // and then died before the credential store — used to return here + // and fail the ENTIRE profile: every other persona, every + // community, every relationship. Now it costs exactly the persona + // that has the fault. + if let Err(reason) = + Config::regenerate_persona_keys(tdk, ctx.sc, ctx.key_backend, &document, ctx.vta_client) + .await + { + return Ok(PersonaHydration::Degraded(reason)); + } + + ctx.progress("Identity", "Building messaging profiles"); + // The ATM service itself is account-wide, not per persona: if it + // is missing, no persona can work and degrading each in turn + // would report the same fault N times as though N things broke. + let atm = tdk + .atm + .clone() + .ok_or_else(|| OpenVTCError::Config("TDK ATM service not initialized".to_string()))?; + let persona_profile = match ATMProfile::new( + &atm, + Some(profile_label), + presented_did.to_string(), + Some(persona_mediator.clone()), + ) + .await + { + Ok(profile) => profile, + Err(e) => { + return Ok(PersonaHydration::Degraded( + DegradedReason::MessagingUnavailable { + detail: e.to_string(), + }, + )); + } + }; + + // Register the persona profile with the TDK ATM Service but do + // NOT open a WebSocket — the DIDComm service owns connections. + let persona_profile = match atm.profile_add(&persona_profile, false).await { + Ok(profile) => profile, + Err(e) => { + return Ok(PersonaHydration::Degraded( + DegradedReason::MessagingUnavailable { + detail: e.to_string(), + }, + )); + } + }; + + Ok(PersonaHydration::Loaded(Box::new( + crate::identity::IdentityContext { + persona_id: *persona_id, + did: persona_did.to_string(), + document, + profile: persona_profile, + mediator_did: Some(persona_mediator), + }, + ))) +} + /// Cross-check the account against what actually loaded. /// /// Two mismatches matter, and both are the same interrupted write seen from @@ -425,15 +626,8 @@ impl Config { // Resolve a runtime identity for EVERY persona in the account, not just // the first: each persona gets its own DIDComm listener at runtime, so // all of them must be hydrated here (keys regenerated + an ATM messaging - // profile registered). A State-A account has zero personas, so the loop - // below never runs and `identities` stays empty. - type PersonaSeed = ( - crate::config::account::PersonaId, - std::sync::Arc, - String, - Option, - Option, - ); + // profile registered). A State-A account has zero personas, so nothing + // is hydrated below and `identities` stays empty. let personas: Vec = account .personas .values() @@ -509,168 +703,47 @@ impl Config { let mut integrity = LoadIntegrity::default(); let hydrate: Result<(), OpenVTCError> = async { - for (persona_id, persona_did, persona_mediator, cached_document, did_locator) in - &personas - { - // A did:scid persona's bare DID resolves only through a source - // the resolver holds, and the resolver forgets its sources on - // restart: seed it with the one recorded at mint (VTI-KEY-162). - // It is also the DID the persona sends as, so a peer meeting it - // for the first time can resolve it (VTI-KEY-163). - if let Some(locator) = did_locator { - tdk.did_resolver().scid_sources().add_source(locator); - } - let presented_did: &str = did_locator.as_deref().unwrap_or(persona_did.as_str()); - // Name the persona's messaging profile after its community AND - // its own DID, so two personas in one community are still - // distinguishable. Shares one helper - // with the runtime label (`Config::persona_profile_label`) — - // this used to be a second copy of the expression, which is how - // the two could drift. + // Personas are independent until they are recorded: each resolves its + // own document, fetches its own keys and registers its own profile, + // through shared handles (`&TDK`, the one VTA session — whose replies + // vta-sdk demuxes by `thid`). So they are hydrated a few at a time, + // then recorded in account order, so the degraded report reads the + // same whichever finished first. + let shared_tdk: &TDK = tdk; + let ctx = HydrateContext { + tdk: shared_tdk, + sc: &sc, + key_backend: &key_backend, + vta_client: vta_client.as_ref(), + on_progress, + }; + let hydrated = map_bounded_in_order(&personas, PERSONA_HYDRATE_CONCURRENCY, |seed| { + let (persona_id, persona_did, _, _, _) = seed; + // Name the persona's messaging profile after its community + // AND its own DID, so two personas in one community are still + // distinguishable. Shares one helper with the runtime label + // (`Config::persona_profile_label`) — this used to be a second + // copy of the expression, which is how the two could drift. // - // No `Config` exists yet here, so the verified-name lookup goes - // through `private_cfg` directly. + // No `Config` exists yet here, so the verified-name lookup + // goes through `private_cfg` directly. let profile_label = crate::config::membership_profile_label( account.memberships().find(|c| c.persona_ref == *persona_id), persona_did, |did| private_cfg.cached_agent_name(did).map(ToString::to_string), ); - - // PERF #3: prefer the persisted persona DID document over a fresh - // network resolve (~1s). did:webvh docs change rarely between - // launches; a stale doc only matters if the persona rotated keys - // out-of-band — rare, and recoverable on the next mint/resolve. - let document = if let Some(doc) = cached_document.clone() { - report_progress(&on_progress, "Identity", "Loading DID document (cached)"); - doc - } else { - report_progress(&on_progress, "Identity", "Resolving DID document"); - match tdk.did_resolver().resolve(presented_did).await { - Ok(resolved) => resolved.doc, - Err(e) => { - // Isolated, not fatal: this persona has no usable - // document, but the others may. - record_degraded( - &mut integrity, - &account, - *persona_id, - persona_did, - DegradedReason::DocumentUnresolvable { - detail: e.to_string(), - }, - ); - continue; - } + hydrate_persona(&ctx, seed, profile_label) + }) + .await; + for ((persona_id, persona_did, _, _, _), outcome) in personas.iter().zip(hydrated) { + match outcome? { + PersonaHydration::Loaded(identity) => { + identities.insert(*persona_id, *identity); } - }; - - // Final mediator resolution. If neither the persona record nor - // the account carried a mediator, recover it from the DID - // document itself — the persona DID was minted with a - // DIDCommMessaging service whose endpoint IS the mediator, so it - // is always authoritative. Without this the persona listener - // fails with "No Mediator is configured" and reconnect-loops. - let persona_mediator = if persona_mediator.is_empty() { - match super::did::mediator_from_document(&document) { - Some(m) => { - warn!( - persona = %persona_did, - mediator = %m, - "recovered persona mediator from its DID document" - ); - m - } - None => { - warn!( - persona = %persona_did, - "persona has no mediator anywhere (record, account, \ - or DID document) — its listener will not connect" - ); - persona_mediator.clone() - } + PersonaHydration::Degraded(reason) => { + record_degraded(&mut integrity, &account, *persona_id, persona_did, reason); } - } else { - persona_mediator.clone() - }; - - report_progress(&on_progress, "Identity", "Fetching persona keys"); - // THE isolation point. A persona recorded in the account with no - // `key_info` — the signature of a save that wrote the config file - // and then died before the credential store — used to return here - // and fail the ENTIRE profile: every other persona, every - // community, every relationship. Now it costs exactly the persona - // that has the fault. - if let Err(reason) = Config::regenerate_persona_keys( - tdk, - &sc, - &key_backend, - &document, - vta_client.as_ref(), - ) - .await - { - record_degraded(&mut integrity, &account, *persona_id, persona_did, reason); - continue; } - - report_progress(&on_progress, "Identity", "Building messaging profiles"); - // The ATM service itself is account-wide, not per persona: if it - // is missing, no persona can work and degrading each in turn - // would report the same fault N times as though N things broke. - let atm = tdk.atm.clone().ok_or_else(|| { - OpenVTCError::Config("TDK ATM service not initialized".to_string()) - })?; - let persona_profile = match ATMProfile::new( - &atm, - Some(profile_label.clone()), - presented_did.to_string(), - Some(persona_mediator.clone()), - ) - .await - { - Ok(profile) => profile, - Err(e) => { - record_degraded( - &mut integrity, - &account, - *persona_id, - persona_did, - DegradedReason::MessagingUnavailable { - detail: e.to_string(), - }, - ); - continue; - } - }; - - // Register the persona profile with the TDK ATM Service but do - // NOT open a WebSocket — the DIDComm service owns connections. - let persona_profile = match atm.profile_add(&persona_profile, false).await { - Ok(profile) => profile, - Err(e) => { - record_degraded( - &mut integrity, - &account, - *persona_id, - persona_did, - DegradedReason::MessagingUnavailable { - detail: e.to_string(), - }, - ); - continue; - } - }; - - identities.insert( - *persona_id, - crate::identity::IdentityContext { - persona_id: *persona_id, - did: persona_did.to_string(), - document, - profile: persona_profile, - mediator_did: Some(persona_mediator.clone()), - }, - ); } // Register relationship (R-DID) profiles ONCE, excluding ALL persona @@ -1269,3 +1342,41 @@ mod tests { ); } } + +#[cfg(test)] +mod hydrate_concurrency_tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + /// Personas are hydrated a few at a time, never more than the bound, and + /// the results come back in account order whichever finished first — so + /// the degraded report and the identities are recorded deterministically. + #[tokio::test(start_paused = true)] + async fn map_bounded_in_order_bounds_concurrency_and_keeps_input_order() { + let running = AtomicUsize::new(0); + let peak = AtomicUsize::new(0); + let items: Vec = (0..10).collect(); + let out = map_bounded_in_order(&items, 3, |n| { + let (running, peak) = (&running, &peak); + async move { + let now = running.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(now, Ordering::SeqCst); + // Later items finish first. + tokio::time::sleep(Duration::from_millis(100 - n * 10)).await; + running.fetch_sub(1, Ordering::SeqCst); + *n * 2 + } + }) + .await; + assert_eq!(out, (0..10).map(|n| n * 2).collect::>()); + assert_eq!(peak.load(Ordering::SeqCst), 3, "at most the bound at once"); + } + + /// A zero bound is treated as one rather than hanging. + #[tokio::test] + async fn map_bounded_in_order_with_zero_limit_still_runs() { + let out = map_bounded_in_order([1, 2, 3], 0, |n| async move { n + 1 }).await; + assert_eq!(out, vec![2, 3, 4]); + } +} From 312acafe5ee2fa71f05f398900c99c9e6967b0bb Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 19:05:26 +1000 Subject: [PATCH 14/27] perf(tui): read did-git-sign's install at startup and when a view shows it (#555) Wires the off-loop did-git-sign refresh into the runtime: once when the loop starts, and after each action when a view that shows it has just opened (a cheap check, no I/O). The 5-second sweep still catches anything else. Signed-off-by: Glenn Gore --- openvtc/src/state_handler/runtime/mod.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/openvtc/src/state_handler/runtime/mod.rs b/openvtc/src/state_handler/runtime/mod.rs index 6be644cd..9fe45d75 100644 --- a/openvtc/src/state_handler/runtime/mod.rs +++ b/openvtc/src/state_handler/runtime/mod.rs @@ -505,9 +505,26 @@ impl Runtime { /// Run the loop until an arm asks it to stop, then shut down. pub(super) async fn run(mut self) -> Result { + // did-git-sign's install is read off the loop (#555), once now and + // again when a view that shows it opens. + crate::state_handler::settings_actions::refresh_did_git_sign( + &mut self.state, + &mut self.in_flight, + &self.dispatch_tx, + ); let result = loop { let flow = tokio::select! { - Some(action) = self.action_rx.recv() => self.on_action(action).await, + Some(action) = self.action_rx.recv() => { + let flow = self.on_action(action).await; + // A view that shows did-git-sign may just have opened: no + // I/O here, only whether a refresh is due. + crate::state_handler::settings_actions::maybe_refresh_did_git_sign( + &mut self.state, + &mut self.in_flight, + &self.dispatch_tx, + ); + flow + } // DIDComm inbound message events — as they arrive, or handed // back with the result of their off-loop check. Some((event, pre)) = super::next_inbound(&mut self.didcomm_event_rx, &mut self.verifier) => { From 48c30990639717d34c8fa20c1cc8c7ba867ff410 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 19:16:00 +1000 Subject: [PATCH 15/27] =?UTF-8?q?refactor(core):=20SubjectSigner=20?= =?UTF-8?q?=E2=80=94=20read=20a=20subject-linkage=20signer=20off=20the=20c?= =?UTF-8?q?onfig,=20sign=20without=20it=20(#529)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Glenn Gore --- openvtc-core/src/config/keys.rs | 127 +++++++++++++++++++++++++------- 1 file changed, 102 insertions(+), 25 deletions(-) diff --git a/openvtc-core/src/config/keys.rs b/openvtc-core/src/config/keys.rs index dce45308..5929837f 100644 --- a/openvtc-core/src/config/keys.rs +++ b/openvtc-core/src/config/keys.rs @@ -137,6 +137,9 @@ impl Config { /// (via the TDK Ed25519 routine), under its assertionMethod verification /// method. Used when joining under a different/fresh DID than the invited one. /// + /// [`Self::subject_signer`] then [`SubjectSigner::sign`], for a caller that + /// holds the config across the VTA round trip. + /// /// Errors if `subject_did` is not one of our personas (we can't prove /// control of a key we don't hold). pub async fn build_subject_linkage( @@ -146,6 +149,20 @@ impl Config { vic_id: &str, presenter_did: &str, ) -> Result { + self.subject_signer(subject_did)? + .sign(vta_client, vic_id, presenter_did) + .await + } + + /// Read what signing a subject-linkage proof as `subject_did` needs out of + /// the config: its assertionMethod verification method and where its + /// signing key comes from. No I/O — a key derived or imported locally is + /// resolved here; a VTA-managed one is fetched by [`SubjectSigner::sign`], + /// which needs no config and so can run off the caller's loop (#529). + /// + /// Errors if `subject_did` is not one of our personas, has no cached DID + /// document or assertionMethod, or has no signing key this config can reach. + pub fn subject_signer(&self, subject_did: &str) -> Result { // The subject persona's assertionMethod VM id (the VTC resolves it to // verify the signature). let persona = self @@ -169,24 +186,21 @@ impl Config { OpenVTCError::Config("subject persona has no assertionMethod".to_string()) })? .to_string(); - - let seed = self.persona_signing_seed(subject_did, vta_client).await?; - crate::join::sign_subject_linkage(&seed, vm, vic_id, presenter_did) + let key = self.persona_signing_key(subject_did)?; + Ok(SubjectSigner { vm, key }) } - /// Resolve a persona's Ed25519 signing-key seed (32 bytes) by DID, from + /// Where a persona's Ed25519 signing key comes from, found by DID, from /// whichever backing the key uses (BIP32-derived / imported / VTA-managed). - /// Mirrors the per-key resolution in [`Self::load_persona_secrets`]. - async fn persona_signing_seed( - &self, - persona_did: &str, - vta_client: Option<&vta_sdk::client::VtaClient>, - ) -> Result<[u8; 32], OpenVTCError> { + /// A local key is resolved here; a VTA-managed one is named, for + /// [`SubjectSigner::sign`] to fetch. Mirrors the per-key resolution in + /// [`Self::load_persona_secrets`]. + fn persona_signing_key(&self, persona_did: &str) -> Result { for (key_id, ki) in &self.key_info { if !key_id.starts_with(persona_did) || !matches!(ki.purpose, KeyTypes::PersonaSigning) { continue; } - let secret = match &ki.path { + return match &ki.path { KeySourceMaterial::Derived { path } => { let KeyBackend::Bip32 { root, .. } = &self.key_backend else { return Err(OpenVTCError::Config( @@ -194,29 +208,20 @@ impl Config { )); }; root.get_secret_from_path(path, KeyPurpose::Signing) + .map(SigningKey::Local) .map_err(|e| { OpenVTCError::Secret(format!("derive subject signing key: {e}")) - })? + }) } KeySourceMaterial::Imported { seed } => { Secret::from_multibase(seed.expose_secret(), None) - .map_err(|e| OpenVTCError::Secret(format!("imported signing key: {e}")))? + .map(SigningKey::Local) + .map_err(|e| OpenVTCError::Secret(format!("imported signing key: {e}"))) } KeySourceMaterial::VtaManaged { key_id: vta_key_id } => { - let client = vta_client.ok_or_else(|| { - OpenVTCError::Config( - "VTA-managed signing key requires a VTA client".to_string(), - ) - })?; - let resp = client.get_key_secret(vta_key_id).await.map_err(|e| { - OpenVTCError::Config(format!("fetch subject signing key: {e}")) - })?; - secret_from_vta_response(&resp, KeyPurpose::Signing)? + Ok(SigningKey::Vta(vta_key_id.clone())) } }; - return secret.get_private_bytes().try_into().map_err(|_| { - OpenVTCError::Secret("signing key is not a 32-byte Ed25519 seed".to_string()) - }); } Err(OpenVTCError::Config(format!( "no signing key found for persona {persona_did}" @@ -441,6 +446,57 @@ async fn secret_for_vm( Ok(Some(secret)) } +/// Everything signing a subject-linkage proof needs, read out of a [`Config`] +/// by [`Config::subject_signer`] so the signing — and, for a VTA-managed key, +/// the round trip that fetches it — can run without the config. +pub struct SubjectSigner { + /// The subject persona's assertionMethod verification method. + vm: String, + key: SigningKey, +} + +/// Where a subject's signing key comes from. +enum SigningKey { + /// Derived or imported: already in hand. + Local(Secret), + /// Held by the VTA under this key id. + Vta(String), +} + +impl SubjectSigner { + /// Sign the subject-linkage proof authorizing `presenter_did` to redeem + /// the invitation `vic_id`. A VTA-managed key is fetched through + /// `vta_client`, which it then requires. + pub async fn sign( + &self, + vta_client: Option<&vta_sdk::client::VtaClient>, + vic_id: &str, + presenter_did: &str, + ) -> Result { + let fetched; + let secret = match &self.key { + SigningKey::Local(secret) => secret, + SigningKey::Vta(vta_key_id) => { + let client = vta_client.ok_or_else(|| { + OpenVTCError::Config( + "VTA-managed signing key requires a VTA client".to_string(), + ) + })?; + let resp = client + .get_key_secret(vta_key_id) + .await + .map_err(|e| OpenVTCError::Config(format!("fetch subject signing key: {e}")))?; + fetched = secret_from_vta_response(&resp, KeyPurpose::Signing)?; + &fetched + } + }; + let seed: [u8; 32] = secret.get_private_bytes().try_into().map_err(|_| { + OpenVTCError::Secret("signing key is not a 32-byte Ed25519 seed".to_string()) + })?; + crate::join::sign_subject_linkage(&seed, self.vm.clone(), vic_id, presenter_did) + } +} + /// Converts a VTA `GetKeySecretResponse` into a TDK `Secret`. /// /// Supports Ed25519 (signing/authentication) and X25519 (encryption) key types. @@ -484,3 +540,24 @@ pub(crate) fn secret_from_vta_response( ))), } } + +#[cfg(test)] +mod tests { + use crate::test_support::test_config; + + /// The split read refuses exactly what the one-call form refused, before + /// any signing: a subject that is none of our personas. + #[tokio::test] + async fn a_stranger_subject_is_refused_by_both_forms() { + let config = test_config(); + let Err(split) = config.subject_signer("did:example:stranger") else { + panic!("a stranger has no signer"); + }; + let joined = config + .build_subject_linkage("did:example:stranger", None, "urn:vic:1", "did:example:me") + .await + .expect_err("a stranger cannot be signed for"); + assert_eq!(split.to_string(), joined.to_string()); + assert!(split.to_string().contains("is not one of your personas")); + } +} From 1baf66450f30c310783ebe3fb3314eb5daa1c13b Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 19:45:29 +1000 Subject: [PATCH 16/27] refactor(join): drive the join from the loop that owns it, as a saga, with no nested event loop (#529) The join flow was a nested select! loop the runtime awaited inline, parking every other arm while the join screen was up. It is now a JoinDriver state machine both the runtime loop and the State-A loop drive: page logic runs on the loop, network work runs as off-loop jobs on DispatchDomain::Join (matched by generation, aborted on Esc/F10), join keys pressed during a job are queued and replayed, and a launch is a JoinSaga whose failures, cancels and interrupts undo it from its compensations. A reused persona is no longer forgotten on a failed submit. The old StateHandler::join_flow, run_join_sequence and race_against_interrupt are removed. Signed-off-by: Glenn Gore --- .../src/state_handler/background_dispatch.rs | 24 + openvtc/src/state_handler/create_persona.rs | 2 +- openvtc/src/state_handler/join_cmd.rs | 6 +- openvtc/src/state_handler/join_flow.rs | 2756 +------------- openvtc/src/state_handler/join_flow/driver.rs | 3207 +++++++++++++++++ openvtc/src/state_handler/mod.rs | 394 +- openvtc/src/state_handler/runtime/arms.rs | 74 +- openvtc/src/state_handler/runtime/mod.rs | 185 +- openvtc/src/state_handler/runtime_actions.rs | 9 +- .../src/ui/pages/join_flow/vtc_enter_did.rs | 4 +- 10 files changed, 3754 insertions(+), 2907 deletions(-) create mode 100644 openvtc/src/state_handler/join_flow/driver.rs diff --git a/openvtc/src/state_handler/background_dispatch.rs b/openvtc/src/state_handler/background_dispatch.rs index 1b522f04..a4d7a366 100644 --- a/openvtc/src/state_handler/background_dispatch.rs +++ b/openvtc/src/state_handler/background_dispatch.rs @@ -175,6 +175,13 @@ pub(crate) enum DispatchDomain { /// to the witness, each waited on for up to a minute. Its own domain so the wait never /// holds up a ping or a VRC request on the same relationship. Witness, + /// The join flow's network work: resolving an agent name, reading a + /// community, and each stage of a launch (#529). One job at a time — the + /// join driver serialises them and queues the page's keys behind one — and + /// never claimed by a key press directly. Its outcomes are the driver's, + /// matched by generation, and an aborted job's domain is freed by the + /// driver that aborted it. + Join, } impl DispatchDomain { @@ -211,6 +218,7 @@ impl DispatchDomain { DispatchDomain::VettingStatus => "Vetter grant check", DispatchDomain::HiddenVetting => "Hidden-vetting work at your VTA", DispatchDomain::Witness => "Witnessing", + DispatchDomain::Join => "Joining a community", } } } @@ -378,6 +386,11 @@ pub(crate) enum DispatchOutcome { ), /// A witnessing finished (or failed). Witness(crate::state_handler::witness_actions::WitnessOutcome), + /// A join-flow job finished. The owning loop hands it to its + /// [`JoinDriver`](crate::state_handler::join_flow::JoinDriver) before + /// [`apply_outcome`] sees it; reaching `apply_outcome` it only frees the + /// domain. + Join(Box), } impl DispatchOutcome { @@ -418,6 +431,7 @@ impl DispatchOutcome { DispatchOutcome::CommunityContext(_) => DispatchDomain::CommunityAccess, DispatchOutcome::DeviceGrantSweep(_) => DispatchDomain::DeviceGrantSweep, DispatchOutcome::Witness(_) => DispatchDomain::Witness, + DispatchOutcome::Join(_) => DispatchDomain::Join, DispatchOutcome::Panicked(domain) => *domain, } } @@ -431,6 +445,9 @@ pub(crate) enum ProgressUpdate { PersonaMint(String), /// A step of a witnessing, for the relationships panel's status line. Witness(String), + /// A line from a running join job, for the join page — said only while + /// that job (`generation`) is still the one the join is waiting on. + Join { generation: u64, line: String }, } impl ProgressUpdate { @@ -440,6 +457,7 @@ impl ProgressUpdate { match self { ProgressUpdate::PersonaMint(_) => DispatchDomain::Persona, ProgressUpdate::Witness(_) => DispatchDomain::Witness, + ProgressUpdate::Join { .. } => DispatchDomain::Join, } } } @@ -542,6 +560,9 @@ pub(crate) fn apply_outcome( state.main_page.content_panel.relationships.status_message = Some(Status::progress(step)); } + // The join driver's, intercepted by the owning loop; a line that + // reaches here belongs to no open join. + ProgressUpdate::Join { .. } => {} } return AfterApply::Nothing; } @@ -691,6 +712,9 @@ pub(crate) fn apply_outcome( DispatchOutcome::Vic(outcome) => outcome.apply(state, config), DispatchOutcome::VicMutation(outcome) => outcome.apply(state), DispatchOutcome::Witness(outcome) => outcome.apply(state, config, save), + // The owning loop's join driver applies these; one that reaches here + // has no open join to apply to, so its domain is only freed. + DispatchOutcome::Join(_) => {} // Handled above, before the domain is finished. Listed because the // match must stay exhaustive. DispatchOutcome::Progress(_) => unreachable!("progress returns before this match"), diff --git a/openvtc/src/state_handler/create_persona.rs b/openvtc/src/state_handler/create_persona.rs index c33b1695..91ec51c3 100644 --- a/openvtc/src/state_handler/create_persona.rs +++ b/openvtc/src/state_handler/create_persona.rs @@ -6,7 +6,7 @@ //! that VIC on the clean join-as-subject path (the VIC subject is one of our own //! personas, so no subject-linkage proof is needed). //! -//! This is the join flow's mint sub-sequence (`join_flow::run_join_sequence`) +//! This is the join launch's mint stage (`join_flow::driver`) //! minus the community/submit parts: pick a WebVH server, mint the DID via the //! VTA, then persist through the shared [`ConfigExtension::mint_persona_into`]. //! The minted persona is an orphan (no community) until a join reuses it, and diff --git a/openvtc/src/state_handler/join_cmd.rs b/openvtc/src/state_handler/join_cmd.rs index 6a5e7ad4..889e0b28 100644 --- a/openvtc/src/state_handler/join_cmd.rs +++ b/openvtc/src/state_handler/join_cmd.rs @@ -2,7 +2,7 @@ //! //! Covers the two ways in that need no questions answered: presenting an //! invitation, or sending an open request. The submit mirrors -//! `join_flow::run_join_sequence` for an existing persona; the wait reuses +//! the TUI's join launch (`join_flow::driver`) for an existing persona; the wait reuses //! core's inbound dispatch and join status poll, so a verdict is applied exactly //! as the runtime loop would apply it. @@ -221,7 +221,7 @@ impl Job<'_> { .await; if let Err(e) = submitted { // A persona minted for this join and left unreferenced by a - // membership is taken back out, as `rollback_minted_persona` does. + // membership is taken back out, as the TUI launch's saga does. if let Some((keys, prior_friendly_name)) = minted && config.account.membership(vtc_did, persona_id).is_none() { @@ -411,7 +411,7 @@ impl Job<'_> { } } - /// `run_join_sequence` for an existing persona: context, listener, + /// The TUI's join launch for an existing persona: context, listener, /// presentation, transport, submit, pending record. async fn submit( &self, diff --git a/openvtc/src/state_handler/join_flow.rs b/openvtc/src/state_handler/join_flow.rs index c42096c7..3b07c3ff 100644 --- a/openvtc/src/state_handler/join_flow.rs +++ b/openvtc/src/state_handler/join_flow.rs @@ -1,23 +1,29 @@ //! State-B "join a community" orchestration (R-A-5 Stage 4). //! -//! [`StateHandler::join_flow`] is a nested `tokio::select!` loop modelled on -//! [`setup_wizard`](crate::state_handler::setup_wizard): it owns the screen -//! while [`ActivePage::Join`] is -//! active, processes the join actions, renders via `state_tx`, and returns to -//! the main page when the user cancels or the sequence finishes. +//! The join is driven by the loop that owns it — the runtime loop, or the +//! State-A loop — through [`driver::JoinDriver`], a state machine with no event +//! loop of its own (#529). Its long network work runs off the loop as jobs on +//! `DispatchDomain::Join`; the launch is a saga whose steps a failure or a +//! cancel undoes (`openvtc_core::join::saga`). See [`driver`] for the shape. //! -//! The actual work runs in [`run_join_sequence`]: mint a fresh persona (reusing -//! the setup VTA helpers), derive + register the per-community sub-context, -//! submit the join, and persist a `Pending` [`CommunityRecord`]. Every failure -//! is surfaced into the join log as a [`MessageType::Error`] — the loop never -//! `?`-bubbles a sequence error in a way that would kill the app. +//! This module holds what the driver decides with: the pure page logic (which +//! routes a community offers, how a vetting row reads, which contexts a persona +//! can join from), the bounded sends a join makes while it waits on a +//! community (its requirements, a challenge, a wallet session), and the hand-off +//! types the runtime loop uses to hear those answers ([`AwaitingRequirements`], +//! [`AwaitingChallenge`], [`AwaitingWallet`]). Every failure is surfaced into +//! the join log as a [`MessageType::Error`] — nothing `?`-bubbles a join error +//! in a way that would kill the app. + +pub(crate) mod driver; + +pub(crate) use driver::{JoinCtx, JoinDriver, JoinOutcome, JoinStep}; #[allow(unused_imports)] use crate::state_handler::state::PublishState; use std::time::Duration; use affinidi_tdk::TDK; -use anyhow::Result; use chrono::Utc; use openvtc_core::config::{ Config, @@ -34,7 +40,6 @@ use openvtc_core::vetting::disclosure::{Describe, DirectoryStatement}; use openvtc_core::vetting::guide::describe_requirements; use openvtc_core::vetting::queries::{CommunityAnswer, CommunityQuery, QueryKind}; use openvtc_core::vetting::wire; -use tokio::sync::{broadcast, mpsc::UnboundedReceiver}; use tracing::{debug, warn}; use vta_sdk::{ client::VtaClient, @@ -45,7 +50,6 @@ use vta_sdk::{ use crate::{ Interrupted, state_handler::{ - StateHandler, actions::Action, join::{ Applicant, ApplyAs, AvailableVic, FirstStepKind, IdentityPick, JoinAnswers, @@ -64,21 +68,21 @@ use crate::{ vetting_actions, }, }; +use driver::JoinIo; /// How the join flow is entered. +/// +/// Whether the flow can ask a community for its requirements and hand the +/// wait for the answer to the calling loop is the [`JoinDriver`]'s: only a +/// loop that reads inbound messages can. pub(crate) enum JoinEntry { - /// From the Communities panel. `hears_replies` says whether the calling - /// loop reads inbound messages: only then can the flow ask a community for - /// its requirements and hand the wait for the answer to that loop. - Fresh { hears_replies: bool }, + /// From the Communities panel. + Fresh, /// For a community already chosen elsewhere — `j` on a vetting application, /// which is the join that application was made for. The DID entry page is /// skipped: an application names its community, and making someone find and /// retype that DID is what turned "go and get vetted" into a dead end. - ForCommunity { - vtc_did: String, - hears_replies: bool, - }, + ForCommunity { vtc_did: String }, /// Back from waiting for a community's requirements. Resume { vtc_did: String, @@ -143,11 +147,11 @@ pub(crate) enum RequirementsOutcome { Skipped, } -/// A join waiting for a community's manifest. The join flow cannot wait -/// itself: its loop selects only on actions and interrupts, and the answer -/// arrives on the runtime loop's inbound arm. So the flow asks, returns this, -/// and the runtime loop keeps the join screen up, hears the answer — or gives -/// up after [`REQUIREMENTS_WAIT`] — and enters the flow again. +/// A join waiting for a community's manifest. The answer arrives on the +/// runtime loop's inbound arm, not as a join action, so the flow asks, returns +/// this ([`JoinStep::AwaitRequirements`]), and the runtime loop keeps the join +/// screen up, hears the answer — or gives up after [`REQUIREMENTS_WAIT`] — and +/// enters the flow again. pub(crate) struct AwaitingRequirements { pub vtc_did: String, /// The manifest request's document id. @@ -164,8 +168,8 @@ pub(crate) const REQUIREMENTS_WAIT: Duration = Duration::from_secs(15); /// A community that hides its vetters accepts a proof only over a challenge it /// issued itself, once (`vtc/vetting/pcs-challenge/0.1`). The launch asks for /// one and then, like [`AwaitingRequirements`], hands the wait to the runtime -/// loop, which is the only loop that hears the answer: the join flow's own -/// `select!` reads actions and interrupts, nothing inbound. When the answer +/// loop, which is the only loop that hears the answer: the join driver takes +/// actions and its own jobs' results, nothing inbound. When the answer /// arrives — or [`CHALLENGE_WAIT`] passes without one — that loop enters the /// flow again with [`JoinEntry::Challenge`], and the launch carries on from /// where it stopped rather than sending the person off to ask for it themselves. @@ -283,7 +287,7 @@ pub(crate) fn challenge_resume_for( enum ChallengeStep { /// Go on: nothing hidden to prove, a challenge was just issued for this /// launch, or this loop cannot hear an answer anyway (and - /// [`hidden_proof_refusal`] says what to do when none is held). + /// [`hidden_proof_precheck`] says what to do when none is held). Proceed, /// Ask the community for a fresh challenge, and wait for it. Ask, @@ -322,7 +326,7 @@ fn challenge_step( /// The vetting application a join as `pick` to `vtc_did` presents: the one /// made under that persona, and only while it was made for that persona's DID /// — the statements and proof it holds name the DID they were gathered for. -/// The same match `run_join_sequence` makes when it attaches them. +/// The same match the launch makes when it attaches them. fn presented_application<'a>( config: &'a Config, vtc_did: &str, @@ -1061,7 +1065,7 @@ pub(crate) fn vetting_view( let evaluation = app.checklist(now); // The statements name the DID the application was made for, and // the join attaches them only when that is the DID joining - // (`run_join_sequence`). A persona whose DID has moved on since + // (the launch's presenting stage). A persona whose DID has moved on since // cannot present them, so the page must not call them ready. let current_did = personas .iter() @@ -1274,10 +1278,15 @@ fn known_vetting(state: &mut State) -> Option<&mut KnownVetting> { /// The persona is the row's own, passed in: what this does follows from /// whether *that* persona has an application here, never from whether any /// other persona does. -fn apply_for_vetting( +/// +/// A new application is saved at once (`io.save`, a flush of the loop's +/// scheduler): the Vetting page it opens is where the person goes next, and the +/// application must survive whatever they do there. +async fn apply_for_vetting( state: &mut State, config: &mut Config, - profile: &str, + io: &dyn JoinIo, + save: &mut crate::state_handler::save_coalesce::SaveScheduler, vtc_did: &str, persona: PersonaId, ) -> Result<(), String> { @@ -1351,7 +1360,9 @@ fn apply_for_vetting( .find(|a| a.id == id) .map(|a| vetting_actions::next_step_words(&a.next_step(now))) .unwrap_or_default(); - save_config(config, profile).map_err(|e| format!("Could not save the application: {e}"))?; + io.save(save, config) + .await + .map_err(|e| format!("Could not save the application: {e}"))?; vetting_actions::focus_application( state, config, @@ -1385,1502 +1396,6 @@ fn joined_session(js: &JoinState) -> Option { }) } -impl StateHandler { - /// Run the join flow until the user cancels or the sequence finishes. - /// - /// Mirrors `setup_wizard`'s loop shape. `admin_vta` is the always-on admin - /// VTA session (threaded in from the caller); `config` is mutated in place - /// and persisted by the sequence on success. - /// - /// `messaging` is the live DIDComm service when there is one, so the - /// sequence can bring the applicant persona's mediator socket up *before* - /// it submits (see [`start_persona_listener`]). The State-A degraded loop has - /// no service and passes `None`; the join still works there, it just cannot - /// receive the community's reply until the process restarts into the full - /// pipeline — which is what that path does anyway. - #[allow(clippy::too_many_arguments)] - pub(crate) async fn join_flow( - &self, - action_rx: &mut UnboundedReceiver, - interrupt_rx: &mut broadcast::Receiver, - state: &mut State, - tdk: &TDK, - config: &mut Config, - admin_vta: Option<&VtaClient>, - profile: &str, - messaging: Option<&Messaging>, - entry: JoinEntry, - ) -> Result { - // Enter the flow on a fresh EnterDid page — unless this is a launch - // coming back with its challenge, which must find everything the person - // chose on the way to it exactly as they left it. - // Whichever way a parked wait ends, it has ended: Esc is no longer - // the cheap way out once the launch carries on. - state.join.waiting_on_challenge = false; - if !matches!( - entry, - JoinEntry::Challenge { .. } | JoinEntry::Wallet { .. } - ) { - state.join.reset(); - // Surface the launch-supplied invitation on the entry page (reset - // clears the transient join sub-state, so mirror the flag back in - // afterwards). - state.join.has_invitation = state.invitation_credential.is_some(); - state.join.invitation_foreign_subject = state - .invitation_credential - .as_ref() - .and_then(|vic| foreign_invitation_subject(config, vic)); - } - state.active_page = ActivePage::Join; - let hears_replies = match &entry { - JoinEntry::Fresh { hears_replies } | JoinEntry::ForCommunity { hears_replies, .. } => { - *hears_replies - } - // Only the runtime loop waits on an answer, so only it resumes. - JoinEntry::Resume { .. } | JoinEntry::Challenge { .. } | JoinEntry::Wallet { .. } => { - true - } - }; - state.join.hears_replies = hears_replies; - match entry { - JoinEntry::Fresh { .. } => {} - // The community is already chosen, so the flow opens where the DID - // entry page would have led. - JoinEntry::ForCommunity { vtc_did, .. } => { - if let Some(exit) = self - .enter_community( - vtc_did, - hears_replies, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - let _ = self.state_tx.publish(state); - return Ok(exit); - } - } - JoinEntry::Resume { vtc_did, outcome } => { - // `reset` above cleared what the asking pass collected, and the - // routes cannot be drawn without it. - ensure_invitations(state, admin_vta, tdk.did_resolver(), &vtc_did).await; - match outcome { - RequirementsOutcome::Unanswered(reason) => { - // We got here by sending the question, so the DID - // resolved: it is the answer that never came. - show_unknown(state, config, &vtc_did, reason, true, true); - } - outcome => { - let learned = matches!(outcome, RequirementsOutcome::Learned); - if learned { - // The manifest came over DIDComm, which never - // resolved the community's DID document. - sequence::learn_signing(config, tdk, &vtc_did).await; - } - let shown = learned && show_vetting(state, config, &vtc_did); - if !shown - && let Some(interrupted) = self - .continue_join( - vtc_did, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - } - } - JoinEntry::Challenge { awaiting, outcome } => match outcome { - ChallengeOutcome::Issued => { - // The stored challenge is the one this launch asked for; - // the launch takes the flag, so it serves exactly one submit. - state.join.challenge_fresh = true; - if let Some(interrupted) = self - .launch_join_sequence( - awaiting.choice, - awaiting.vtc_did, - awaiting.context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - ChallengeOutcome::Unanswered(reason) => { - let name = vetting_actions::community_display(config, &awaiting.vtc_did); - state.join.page = JoinPage::Progress; - state.join.fail(challenge_failed_words(&name, &reason)); - } - }, - JoinEntry::Wallet { end } => { - state.join.processing = false; - match end { - WalletEnd::Presented => { - let parked = state.join.wallet.as_mut().and_then(|w| { - state.join.wallet_outcome = - Some(WalletOutcome::Presented(w.requirements_digest.clone())); - w.parked.take() - }); - state.join.wallet = None; - if let Some((pick, vtc_did, context_id)) = parked - && let Some(interrupted) = self - .launch_join_sequence( - pick, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - WalletEnd::Unfinished(why) => { - state.join.page = JoinPage::Wallet; - if let Some(wallet) = state.join.wallet.as_mut() { - wallet.phase = WalletPhase::Offered { note: Some(why) }; - } - } - } - } - } - let _ = self.state_tx.publish(state); - - loop { - // A launch that stopped to ask a hidden-vetting community for its - // challenge leaves the flow here, whichever page or entry started - // it: only the caller's loop hears the answer. - if let Some(awaiting) = state.join.challenge_wait.take() { - let _ = self.state_tx.publish(state); - return Ok(JoinExit::AwaitChallenge(awaiting)); - } - // Likewise a wallet session: the runtime loop hears its answers. - if let Some(awaiting) = state.join.wallet_wait.take() { - let _ = self.state_tx.publish(state); - return Ok(JoinExit::AwaitWallet(awaiting)); - } - tokio::select! { - maybe_action = action_rx.recv() => { - let Some(action) = maybe_action else { - // Channel closed — treat as a user-initiated exit. - return Ok(JoinExit::Exit(Interrupted::UserInt)); - }; - match action { - Action::Exit => return Ok(JoinExit::Exit(Interrupted::UserInt)), - Action::UXError(interrupted) => { - return Ok(JoinExit::Exit(interrupted)); - } - Action::JoinCancel => { - // Leave the flow; the caller restores the main page. - // Hand back the joined session (if any) so the runtime - // loop can bring its live session up (R-B-5). - state.active_page = ActivePage::Main; - return Ok(JoinExit::Returned(joined_session(&state.join))); - } - Action::JoinPasteVic(text) => { - // #3: a pasted invitation credential — validate it is a - // VIC and stash it so the join presents it (mirrors the - // `--invitation ` launch flag). On the entry page - // the community isn't chosen yet, so only the credential - // shape is checked; on the invitation step it is, so the - // paste is also matched against it. - let vtc = if state.join.page == JoinPage::InvitationChoice { - state.join.pending_vtc.clone() - } else { - None - }; - state.join.messages.clear(); - load_pasted_vic(state, tdk.did_resolver(), &text, vtc.as_deref()).await; - state.join.invitation_foreign_subject = state - .invitation_credential - .as_ref() - .and_then(|vic| foreign_invitation_subject(config, vic)); - let _ = self.state_tx.publish(state); - } - Action::JoinClipboardFailed(why) => { - // Only the failure reaches the loop: `[Ctrl+V]` reads - // and applies the text where the input lives, so a - // pasted DID never takes a trip through here to be - // mistaken for an invitation. - state.join.messages.clear(); - state.join.messages.push(MessageType::Error(format!( - "Could not read the clipboard ({why}). Paste directly into \ - this screen instead — that works over SSH, where reading \ - the clipboard cannot." - ))); - let _ = self.state_tx.publish(state); - } - Action::JoinClearVic => { - // Explicit "proceed without a VIC": drop the loaded - // invitation so it isn't presented, and flag the clear so - // the entry page shows "joining without an invitation". - state.invitation_credential = None; - state.join.has_invitation = false; - state.join.invitation_issuer = None; - state.join.invitation_foreign_subject = None; - state.join.vic_cleared = true; - state.join.messages.clear(); - let _ = self.state_tx.publish(state); - } - Action::JoinSubmitVtc(vtc_did) => { - let vtc_did = match validate_join_input(&vtc_did) { - Ok(did) => did, - // Say why nothing happened. A keypress that - // cannot proceed must not be a silent no-op — - // that reads as a frozen screen (issue #29). - Err(why) => { - state.join.messages.push(MessageType::Error(why)); - let _ = self.state_tx.publish(state); - continue; - } - }; - // Accept an agent name (`example.com/@acme`) in place - // of the VTC DID. Only resolve when the input actually - // looks like a name, so pasting a DID keeps its - // existing (no-extra-round-trip) path; the resolved - // DID is what everything downstream persists. - let vtc_did = if openvtc_core::agent_name::looks_like_agent_name( - &vtc_did, - ) { - // Say what the wait is for: a name costs a - // redirect, a resolve and a check (bounded by - // IDENTIFIER_sequence::RESOLVE_TIMEOUT). - state.join.messages.push(MessageType::Info(format!( - "Resolving {vtc_did}…" - ))); - let _ = self.state_tx.publish(state); - match openvtc_core::agent_name::resolve_identifier( - tdk.did_resolver(), - &vtc_did, - ) - .await - { - Ok(did) => did, - Err(e) => { - state.join.fail(e.to_string()); - let _ = self.state_tx.publish(state); - continue; - } - } - } else { - vtc_did - }; - if let Some(exit) = self - .enter_community( - vtc_did, - hears_replies, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - let _ = self.state_tx.publish(state); - return Ok(exit); - } - } - Action::JoinIdentitySelect(i) => { - // Clamp to the reuse rows plus the trailing "mint" row. - state.join.identity_selected = i.min(state.join.mint_row()); - // Moving the highlight dismisses any armed warning. - state.join.reuse_confirm = None; - let _ = self.state_tx.publish(state); - } - Action::JoinNewPersonaMethodCycle => { - state.join.new_persona_did_method = - next_did_method(state.join.new_persona_did_method); - let _ = self.state_tx.publish(state); - } - Action::JoinIdentityChoose => { - if state.join.mint_row_selected() { - let Some(vtc_did) = state.join.pending_vtc.clone() else { - continue; - }; - state.join.present_invitation = - mint_can_present(config, state, &vtc_did); - if let Some(context_id) = - offer_contexts(state, config, IdentityPick::Mint, &vtc_did) - && let Some(interrupted) = self - .launch_join_sequence( - IdentityPick::Mint, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } else if let Some(opt) = - state.join.persona_options.get(state.join.identity_selected) - { - // Arm the cross-community linkage warning (D1). - state.join.reuse_confirm = Some(opt.id); - let _ = self.state_tx.publish(state); - } - } - Action::JoinReuseConfirm => { - let Some(persona_id) = state.join.reuse_confirm else { - continue; - }; - // The community DID stays parked in `pending_vtc`; the - // invitation step is what launches the join now. - if state.join.pending_vtc.is_none() { - continue; - } - state.join.reuse_confirm = None; - open_invitation_choice(state, config, persona_id); - let _ = self.state_tx.publish(state); - } - Action::JoinReuseCancel => { - state.join.reuse_confirm = None; - let _ = self.state_tx.publish(state); - } - Action::JoinInvitationSelect(i) => { - // Rows: 0..len an invitation, len = paste, len+1 = without. - let max = state.join.invitation_without_row(); - state.join.invitation_use_selected = i.min(max); - let _ = self.state_tx.publish(state); - } - Action::JoinInvitationChoose => { - let Some(vtc_did) = state.join.pending_vtc.clone() else { - continue; - }; - let Some(persona_id) = state.join.invitation_for_persona else { - continue; - }; - // The paste row loads a VIC instead of launching: it is - // the answer to "I have one, it just isn't in the - // vault". Read the OS clipboard as a convenience; the - // portable path is the terminal's own bracketed paste, - // which arrives as `JoinPasteVic` from anywhere on this - // page. Either way we stay on the step so the operator - // sees the loaded invitation before committing to it. - if state.join.invitation_use_selected - == state.join.invitation_paste_row() - { - state.join.messages.clear(); - match crate::clipboard::read_clipboard() { - Ok(text) => { - load_pasted_vic(state, tdk.did_resolver(), &text, Some(&vtc_did)).await; - } - Err(why) => { - state.join.messages.push(MessageType::Error(format!( - "Could not read the clipboard ({why}). Paste the \ - invitation JSON directly into this screen instead." - ))); - } - } - let _ = self.state_tx.publish(state); - continue; - } - // A selected invitation row presents that VIC; the - // trailing row joins without one. - let sel = state.join.invitation_use_selected; - if let Some(av) = state.join.invitation_options.get(sel) { - state.invitation_credential = Some(av.body.clone()); - state.join.present_invitation = true; - } else { - state.invitation_credential = None; - state.join.present_invitation = false; - } - let pick = IdentityPick::Reuse(persona_id); - if let Some(context_id) = offer_contexts(state, config, pick, &vtc_did) - && let Some(interrupted) = self - .launch_join_sequence( - pick, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - Action::JoinAnswersSelect(i) => { - if let (Some(answers), Some(client)) = - (state.join.answers.as_mut(), admin_vta) - { - answers.selected = i.min(answers.faces.len().saturating_sub(1)); - load_shown(answers, client).await; - } - } - Action::JoinAnswersChoose => { - let Some(answers) = state.join.answers.as_mut() else { - continue; - }; - let Some((face_id, face_name)) = - answers.faces.get(answers.selected).cloned() - else { - answers.error = Some( - "You have no face to answer with. Make one under Identity, \ - then join again." - .into(), - ); - let _ = self.state_tx.publish(state); - continue; - }; - let missing = answers.unanswered(); - if !missing.is_empty() { - answers.error = Some(format!( - "{face_name} does not show {} — the community requires it. \ - Pick another face, or add it to this one under Identity.", - missing.join(", ") - )); - let _ = self.state_tx.publish(state); - continue; - } - // What the holder approves is these values, exactly. - let approved = answers - .shown - .iter() - .filter_map(|(t, v)| v.clone().map(|v| (t.clone(), v))) - .collect(); - answers.approved = Some((face_id, approved)); - let Some((pick, vtc_did, context_id)) = answers.parked.take() else { - continue; - }; - if let Some(interrupted) = self - .launch_join_sequence( - pick, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - Action::JoinWalletStart => { - let Some(wallet) = state.join.wallet.as_ref() else { - continue; - }; - if !matches!(wallet.phase, WalletPhase::Offered { .. }) { - continue; - } - let Some((IdentityPick::Reuse(persona), vtc_did, _)) = - wallet.parked.clone() - else { - continue; - }; - let digest = wallet.requirements_digest.clone(); - match ask_wallet_start(config, tdk, messaging, &vtc_did, persona, &digest) - .await - { - Ok(awaiting) => { - if let Some(wallet) = state.join.wallet.as_mut() { - wallet.phase = WalletPhase::Opening; - } - state.join.wallet_wait = Some(awaiting); - } - Err(why) => { - if let Some(wallet) = state.join.wallet.as_mut() { - wallet.phase = WalletPhase::Offered { note: Some(why) }; - } - } - } - } - Action::JoinWalletSkip => { - let parked = state.join.wallet.as_mut().and_then(|w| { - matches!(w.phase, WalletPhase::Offered { .. }) - .then(|| w.parked.take()) - .flatten() - }); - let Some((pick, vtc_did, context_id)) = parked else { - continue; - }; - state.join.wallet = None; - state.join.wallet_outcome = Some(WalletOutcome::Skipped); - if let Some(interrupted) = self - .launch_join_sequence( - pick, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - Action::JoinRegistryToggle => { - if let Some(registry) = state.join.registry.as_mut() { - registry.publish = !registry.publish; - } - } - Action::JoinRegistryConfirm => { - // Either answer is an answer. What is sent is the box - // as it stands now — read again by the sequence via - // `RegistryChoice::consent_for`, never cached apart - // from the page that showed it. - let Some(registry) = state.join.registry.as_mut() else { - continue; - }; - registry.confirmed = true; - let Some((pick, vtc_did, context_id)) = registry.parked.take() else { - continue; - }; - if let Some(interrupted) = self - .launch_join_sequence( - pick, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - Action::JoinContextSelect(i) => { - let last = state.join.context_options.len().saturating_sub(1); - state.join.context_selected = i.min(last); - state.join.messages.clear(); - } - Action::JoinContextSlug(slug) => { - state.join.context_slug = slug; - state.join.messages.clear(); - } - Action::JoinContextChoose => { - let (Some(vtc_did), Some(pick)) = - (state.join.pending_vtc.clone(), state.join.picked_identity) - else { - continue; - }; - let Some(option) = state - .join - .context_options - .get(state.join.context_selected) - .cloned() - else { - continue; - }; - let context_id = if option.kind == ContextKind::New { - match community_context::new_context_id( - &config.account.top_context_id, - &state.join.context_slug, - |id| sequence::context_taken(config, id), - ) { - Ok(id) => id, - Err(e) => { - state.join.messages.clear(); - state - .join - .messages - .push(MessageType::Error(e.to_string())); - let _ = self.state_tx.publish(state); - continue; - } - } - } else { - option.context_id - }; - if let Some(interrupted) = self - .launch_join_sequence( - pick, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - Action::JoinVettingRow(forward) => { - if let Some(known) = known_vetting(state) { - known.step(forward); - } - } - Action::JoinVettingTake => { - // Enter on the routes list. A route the page drew as - // blocked must say why rather than do nothing — a - // keypress that cannot proceed reads as a frozen - // screen (issue #29). - let Some(option) = known_vetting(state) - .and_then(|known| known.selected_route().cloned()) - else { - continue; - }; - if !option.available() { - let why = option.blocked().unwrap_or_default(); - state.join.messages.clear(); - state.join.messages.push(MessageType::Error(format!( - "{}: {why}", - option.label - ))); - let _ = self.state_tx.publish(state); - continue; - } - // A route that starts with the join doing something: - // do it, and remember what it was on the way to. - // That is the difference between being guided and - // being told to go and configure something - // elsewhere. - // - // A step that is merely the next page needs none of - // this — taking the route reaches it. - if option.first_step_kind() == Some(FirstStepKind::CreatePersona) { - state.join.messages.clear(); - state.join.resume_route = Some(option.route); - super::handle_nav_action(state, &Action::StartCreatePersona); - let _ = self.state_tx.publish(state); - continue; - } - if let Some(exit) = self - .take_route( - option.route, - option.applicant, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(exit); - } - } - Action::JoinVettingApply => { - // `a`: read the highlighted row's own application. - let Some(vtc_did) = state.join.pending_vtc.clone() else { - continue; - }; - let Some(persona) = - known_vetting(state).and_then(|k| k.selected_persona()) - else { - continue; - }; - state.join.messages.clear(); - match apply_for_vetting(state, config, profile, &vtc_did, persona) { - Ok(()) => { - state.active_page = ActivePage::Main; - return Ok(JoinExit::Returned(None)); - } - Err(why) => state.join.messages.push(MessageType::Error(why)), - } - } - Action::JoinVettingJoin => { - // `j`, offered only while the community is being - // asked or could not be: there are no rows yet, so - // no persona has been chosen and none of its - // statements are presumed. The identity step asks, - // and the submit attaches the chosen persona's own. - let Some(vtc_did) = state.join.pending_vtc.clone() else { - continue; - }; - state.join.messages.clear(); - if let Some(interrupted) = self - .continue_join( - vtc_did, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - // The create-persona overlay, hosted here rather than - // on the main page. Everything except the mint is the - // shared pure reducer, so the phases and keys cannot - // drift from the ones the main page shows. - action if super::handle_nav_action(state, &action) => { - // A persona minted behind this page changes what the - // routes say, so they are re-derived once the overlay - // is gone. Cheap, idempotent, and it means the row - // that was blocked on having no persona unblocks - // itself rather than waiting for a re-entry. - if state.join.page == JoinPage::Vetting - && state.main_page.create_persona.is_none() - && let Some(vtc_did) = state.join.pending_vtc.clone() - { - show_vetting(state, config, &vtc_did); - // And carry on where the step interrupted — as - // the persona the step made, on that persona's - // own row. Only when the step actually produced - // one: an overlay closed with Esc leaves the - // page as it was, with the "new persona" row - // still saying what it starts with. - let minted = state.join.minted_persona; - let resumable = state.join.resume_route.and_then(|route| { - let known = known_vetting(state)?; - let row = match route { - JoinRoute::Vetting => { - known.row_for(Applicant::Persona(minted?))? - } - other => known.row_of(other)?, - }; - let option = known.routes.get(row)?; - (option.first_step().is_none() && option.available()) - .then_some((row, option.route, option.applicant)) - }); - if let Some((row, route, applicant)) = resumable { - state.join.resume_route = None; - if let Some(known) = known_vetting(state) { - known.row = row; - } - if let Some(exit) = self - .take_route( - route, - applicant, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(exit); - } - } - } - } - Action::CreatePersonaSubmit => { - if let Some(interrupted) = self - .mint_persona_inline( - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - Action::CreatePersonaCopy => { - if let Some(did) = state - .main_page - .create_persona - .as_ref() - .and_then(|o| o.did.clone()) - { - let copied = - crate::clipboard::copy_to_clipboard(&did).is_ok(); - if let Some(o) = state.main_page.create_persona.as_mut() { - o.copied = copied; - } - } - } - Action::JoinVettingAskAgain => { - let Some(vtc_did) = state.join.pending_vtc.clone() else { - continue; - }; - if hears_replies - && let Ok(awaiting) = - ask_requirements(state, config, tdk, messaging, &vtc_did).await - { - let _ = self.state_tx.publish(state); - return Ok(JoinExit::AwaitRequirements(awaiting)); - } - } - _ => {} - } - } - Ok(interrupted) = interrupt_rx.recv() => { - return Ok(JoinExit::Exit(interrupted)); - } - } - let _ = self.state_tx.publish(state); - } - } - - /// Enter `vtc_did` into the flow: learn what it asks of the people who join, - /// and put up the page that answers it. - /// - /// `Some` means leave the flow with that exit; `None` means a page is now up - /// and the loop carries on. Shared by the DID entry page and by a join - /// started for a community already chosen (a vetting application's), so both - /// get the same discovery rather than the entry page's alone. - #[allow(clippy::too_many_arguments)] - async fn enter_community( - &self, - vtc_did: String, - hears_replies: bool, - interrupt_rx: &mut broadcast::Receiver, - state: &mut State, - tdk: &TDK, - config: &mut Config, - admin_vta: Option<&VtaClient>, - profile: &str, - messaging: Option<&Messaging>, - ) -> Option { - // The invitations before the routes: whether presenting one is a way in - // to this community is a fact about this account, and the vetting page - // cannot say what the options are without it. - ensure_invitations(state, admin_vta, tdk.did_resolver(), &vtc_did).await; - // Peer identity vetting: a community that vets says what it requires - // before anything about the applicant is sent (vetting-process.md §6.1). - // What the book already knows decides at once. Otherwise the community - // is asked, and the wait happens in the caller's loop — the one that - // hears the answer — which enters this flow again. - let known = |config: &Config| match config.private.vetting.knowledge(&vtc_did) { - Knowledge::Vetting(_) => Some(true), - Knowledge::NoVetting => Some(false), - Knowledge::Unknown => None, - }; - let mut knowledge = known(config); - // Known before is not known now. A community changes what it asks — - // turns on hidden vetting, raises a count, starts vetting at all — and - // a join decided on the last visit's copy shows the person a community - // that no longer exists: vetters "named" where they are now hidden, or - // no vetting where it is now required. So the manifest is read again on - // every join, over the endpoint that needs nothing of us (bounded, R1.2), - // which also carries the change into any application already under way. - // Only when the community cannot be reached does the stored copy stand - // in, and the log says the page is showing an older answer. - if knowledge.is_some() { - match sequence::learn_over_http(config, tdk, &vtc_did).await { - Ok(()) => knowledge = known(config), - Err(why) => { - debug!(community = %vtc_did, reason = %why.reason(), "manifest not re-read"); - state.main_page.log(format!( - "Could not read {}'s current requirements ({}); showing what it said \ - last time.", - vetting_actions::community_display(config, &vtc_did), - why.reason() - )); - } - } - } - match knowledge { - Some(true) => { - // The re-read above records how it signs; if it could not be - // made, the DID document may still resolve on its own. - sequence::learn_signing(config, tdk, &vtc_did).await; - show_vetting(state, config, &vtc_did); - None - } - None => { - // The community's own endpoint first. It needs no mediator, no - // persona and no loop that can hear a reply, so it answers on a - // first join — where the DIDComm question cannot be asked at - // all — and it answers in one round trip everywhere else. - match sequence::learn_over_http(config, tdk, &vtc_did).await { - Ok(()) => { - if show_vetting(state, config, &vtc_did) { - return None; - } - // It answered, and the answer is that it does not vet. - self.continue_join( - vtc_did, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - .map(JoinExit::Exit) - } - // Nothing resolved, so there is nobody to ask by any - // route: a DIDComm question needs an endpoint the document - // would have named. Stop here rather than send a request - // into the dark. - Err(why) if !why.resolvable() => { - show_unknown( - state, - config, - &vtc_did, - why.reason().to_string(), - false, - false, - ); - None - } - Err(why) if hears_replies => { - debug!(community = %vtc_did, reason = %why.reason(), "manifest not read over REST"); - match ask_requirements(state, config, tdk, messaging, &vtc_did).await { - Ok(awaiting) => Some(JoinExit::AwaitRequirements(awaiting)), - // The page now says why the question could not be sent. - Err(()) => None, - } - } - Err(why) => { - // Both ways of asking are shut: its endpoint did not - // answer, and this loop has no inbound arm to hear a - // message on. Say so rather than sending an open request - // on the quiet — a community that vets refers that - // request to its moderators, and the applicant would - // never learn there was a way in they could have taken. - show_unknown( - state, - config, - &vtc_did, - why.reason().to_string(), - false, - true, - ); - None - } - } - } - Some(false) => self - .continue_join( - vtc_did, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - .map(JoinExit::Exit), - } - } - - /// Take one of the community's ways in, as `applicant` when it is a - /// vetting row. - /// - /// Split out of the keypress so the join can also take a route it had to - /// interrupt: a route whose first step was creating a persona resumes here - /// once that persona exists, rather than putting the person back on the - /// list to press the same key again. - /// - /// Which persona presents statements is decided here and nowhere else on - /// this page, and only from the row's own persona: its application, ready - /// or not. The invitation and open-request rows name no persona, so they go - /// to the identity step, and the submit attaches whatever the persona - /// chosen there gathered for this community — its own, matched on its DID - /// (`run_join_sequence`), which the review page shows before it is sent. - #[allow(clippy::too_many_arguments)] - async fn take_route( - &self, - route: JoinRoute, - applicant: Option, - interrupt_rx: &mut broadcast::Receiver, - state: &mut State, - tdk: &TDK, - config: &mut Config, - admin_vta: Option<&VtaClient>, - profile: &str, - messaging: Option<&Messaging>, - ) -> Option { - let vtc_did = state.join.pending_vtc.clone()?; - state.join.messages.clear(); - match route { - // The invitation is chosen further on, once the persona it is bound - // to is known: the invitation step lists this community's VICs for - // that persona and is where one is picked. - JoinRoute::Invitation | JoinRoute::OpenRequest => self - .continue_join( - vtc_did, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - .map(JoinExit::Exit), - JoinRoute::Vetting => { - let ready = known_vetting(state).and_then(|k| k.ready_as(applicant)); - match (ready, applicant) { - // This persona's own statements meet the requirements: - // joining is what presents them, and they fix the persona - // — so the identity step has nothing left to ask and the - // flow goes straight to the invitation choice. - (Some(persona), _) => { - ensure_invitations(state, admin_vta, tdk.did_resolver(), &vtc_did).await; - open_invitation_choice(state, config, persona); - None - } - // Anything else is taken by working on this persona's own - // application — carrying it on, or starting it. - (None, Some(Applicant::Persona(persona))) => { - match apply_for_vetting(state, config, profile, &vtc_did, persona) { - Ok(()) => { - state.active_page = ActivePage::Main; - Some(JoinExit::Returned(None)) - } - Err(why) => { - state.join.messages.push(MessageType::Error(why)); - None - } - } - } - // A new persona's row starts by making it (its first step - // is taken before this is reached) and comes back on that - // persona's own row. Reaching here without one is a page - // out of step with itself; say so rather than guess. - (None, Some(Applicant::NewPersona) | None) => { - state.join.messages.push(MessageType::Error( - "Applying as a new persona starts by making it — press Enter on \ - \"Apply for vetting as a new persona\"." - .to_string(), - )); - None - } - } - } - } - } - - /// Mint a persona from the overlay this flow is hosting, without leaving it. - /// - /// The runtime loop spawns this job through its dispatcher; this loop has - /// none, so it awaits it the way it already awaits the join sequence — - /// raced against the interrupt, so a mint that hangs does not take Ctrl-C - /// with it (R15). Progress lines stream onto the overlay as they arrive - /// rather than after, because minting a DID is not instant and a frozen - /// "Creating persona…" is indistinguishable from a wedged one. - /// - /// On success the persona is persisted and brought online here: a persona - /// the join can see but cannot send as would be worse than none, since the - /// route it unblocks is the one that then fails. - #[allow(clippy::too_many_arguments)] - async fn mint_persona_inline( - &self, - interrupt_rx: &mut broadcast::Receiver, - state: &mut State, - tdk: &TDK, - config: &mut Config, - admin_vta: Option<&VtaClient>, - profile: &str, - messaging: Option<&Messaging>, - ) -> Option { - use crate::state_handler::background_dispatch::{DispatchOutcome, ProgressUpdate}; - - let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel(); - let Some(job) = super::advance_persona_overlay(state, config, tdk, admin_vta, &progress_tx) - else { - // A phase moved on, or refused and said why on the overlay. - let _ = self.state_tx.publish(state); - return None; - }; - drop(progress_tx); - let _ = self.state_tx.publish(state); - - let run = job.run(); - tokio::pin!(run); - let outcome = loop { - tokio::select! { - outcome = &mut run => break outcome, - Some(update) = progress_rx.recv() => { - if let DispatchOutcome::Progress(ProgressUpdate::PersonaMint(line)) = update - && let Some(o) = state.main_page.create_persona.as_mut() - { - o.messages.push(line); - let _ = self.state_tx.publish(state); - } - } - Ok(interrupted) = interrupt_rx.recv() => return Some(interrupted), - } - }; - - if let Some(minted) = outcome.apply(state) { - // The identity names the listener; the shareable form (a - // did:scid's DID URL with `src`) is what is shown and copied. - let did = minted.did.clone(); - let shareable = minted.shareable_did(); - match minted.persist(config, tdk, profile).await { - Ok(persona_id) => { - state.join.minted_persona = Some(persona_id); - let copied = crate::clipboard::copy_to_clipboard(&shareable).is_ok(); - if let Some(o) = state.main_page.create_persona.as_mut() { - o.phase = - crate::state_handler::main_page::content::CreatePersonaPhase::Done; - o.did = Some(shareable.clone()); - o.copied = copied; - o.messages.push("Persona created.".to_string()); - } - state.main_page.sync_from_config(config); - state - .main_page - .log(format!("Created persona DID {shareable}")); - // Bring it online — in the background. The join is about to - // offer it as a way in, and a persona that cannot send is - // not one; but this loop reads no keys while it awaits, so - // waiting here (up to `add_listener`'s connect bound) left - // Esc and F10 unanswered on a page that had nothing left to - // do. Started and left: the submit waits, bounded, for the - // socket right before the request goes out - // (`await_persona_online`), which is where being online - // matters. A no-op in State A, which has no service. - spawn_persona_listener(messaging, config, tdk, persona_id, &did).await; - state - .join - .info("Connecting the new persona to its mediator…".to_string()); - } - Err(e) => { - // The DID exists at the VTA but is not in the config. Say so - // plainly: it is not lost, and a retry would mint a second. - if let Some(o) = state.main_page.create_persona.as_mut() { - o.phase = - crate::state_handler::main_page::content::CreatePersonaPhase::Failed; - o.messages - .push(format!("Minted, but could not be saved: {e}")); - } - state - .main_page - .log_error("Persona minted but not saved", &e); - } - } - } - let _ = self.state_tx.publish(state); - None - } - - /// Go on with a join once any vetting has been dealt with: collect the - /// community's invitations, then choose the identity to present, or mint - /// one on a first join. `Some` when the person cancelled mid-sequence. - #[allow(clippy::too_many_arguments)] - async fn continue_join( - &self, - vtc_did: String, - interrupt_rx: &mut broadcast::Receiver, - state: &mut State, - tdk: &TDK, - config: &mut Config, - admin_vta: Option<&VtaClient>, - profile: &str, - messaging: Option<&Messaging>, - ) -> Option { - state.join.vetting = None; - // Idempotency is now per-persona (R-B-9): a community may - // be joined as more than one persona, so the duplicate - // check happens once the identity is chosen (in the - // sequence) rather than at the community level here. - // Identity first: collect the invitations available for - // this community so each persona can be badged with its - // usable-invitation count, then let the operator pick the - // identity to present (the invitation choice, if any, - // follows for the chosen persona). - ensure_invitations(state, admin_vta, tdk.did_resolver(), &vtc_did).await; - let options = build_persona_options(config, &state.join.available_vics); - if options.is_empty() { - // First join — nothing to reuse; mint a fresh identity. With no - // persona to vouch for it, the new one cannot present an invitation - // (`mint_can_present` is false); the loaded one is kept so the - // sequence stores it in the vault and says why it went unused. - state.join.present_invitation = mint_can_present(config, state, &vtc_did); - state.join.pending_vtc = Some(vtc_did.clone()); - if let Some(context_id) = offer_contexts(state, config, IdentityPick::Mint, &vtc_did) { - return self - .launch_join_sequence( - IdentityPick::Mint, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await; - } - } else { - state.join.pending_vtc = Some(vtc_did); - state.join.persona_options = options; - state.join.identity_selected = 0; - state.join.reuse_confirm = None; - state.join.page = JoinPage::IdentityChoice; - } - let _ = self.state_tx.publish(state); - None - } - - /// Move to the progress page and run [`run_join_sequence`] for the chosen - /// identity, raced against the interrupt (R15). Returns `Some(interrupted)` - /// when the user cancelled mid-sequence (the caller then exits the flow); - /// `None` when the sequence ran to its own success/failure terminal. - #[allow(clippy::too_many_arguments)] - async fn launch_join_sequence( - &self, - choice: IdentityPick, - vtc_did: String, - context_id: String, - interrupt_rx: &mut broadcast::Receiver, - state: &mut State, - tdk: &TDK, - config: &mut Config, - admin_vta: Option<&VtaClient>, - profile: &str, - messaging: Option<&Messaging>, - ) -> Option { - // A community that asks the applicant to tell it about themselves is - // answered before anything else happens: which face, and exactly what - // it would send. The launch waits on the Answers page and resumes from - // `JoinAnswersChoose`. - let asked = config.private.vetting.requested_attributes(&vtc_did); - let answered = state - .join - .answers - .as_ref() - .is_some_and(|a| a.approved.is_some()); - if !asked.is_empty() && !answered { - open_answers(state, admin_vta, asked, (choice, vtc_did, context_id)).await; - let _ = self.state_tx.publish(state); - return None; - } - - // A directory statement the community published but this client could - // not read stops the join: the person would otherwise apply without - // seeing what joining makes visible about them (`manifest/0.4`). - if let Some(DirectoryStatement::Malformed(why)) = config.private.vetting.directory(&vtc_did) - { - let name = vetting_actions::community_display(config, &vtc_did); - state.join.page = JoinPage::Progress; - state.join.messages.clear(); - state.join.fail(format!( - "{name} published a statement of what its member directory would show about you, and it could not be read ({}). Nothing has been sent: joining without seeing it is not offered. Ask the community to fix it, then try again.", - sanitize_display(why, 200) - )); - let _ = self.state_tx.publish(state); - return None; - } - - // Then the trust-registry question, last, so it is the final thing the - // person sees before the request goes out — and on every join, because - // there is no right answer to assume. Every launch passes through here - // (identity, invitation, context, vetting and answers all end in this - // function), so no path can submit without the person having answered. - let registry_answered = state - .join - .registry - .as_ref() - .is_some_and(|r| r.answered_for(&vtc_did)); - if !registry_answered { - let community_name = config.agent_name_for(&vtc_did).map(str::to_string); - let review = join_review(config, state, &vtc_did, &choice); - let directory = match config.private.vetting.directory(&vtc_did) { - Some(DirectoryStatement::Stated(d)) => { - Some(crate::state_handler::join::DirectoryReview { - sentences: d.describe(|label| sanitize_display(label, 64)), - statement_digest: d.statement_digest.to_string(), - }) - } - _ => None, - }; - open_registry_choice(state, community_name, (choice, vtc_did, context_id)); - if let Some(registry) = state.join.registry.as_mut() { - registry.review = Some(review); - registry.directory = directory; - } - let _ = self.state_tx.publish(state); - return None; - } - - // A criterion asking for a credential a wallet presents: offer to - // collect it now, before the request goes out, so the submit can name - // the criterion and the community counts the evidence it already holds. - if let Some(offer) = wallet_offer(config, state, &vtc_did, &choice) { - state.join.wallet = Some(WalletStep { - parked: Some((choice, vtc_did, context_id)), - ..offer - }); - state.join.page = JoinPage::Wallet; - state.join.processing = false; - let _ = self.state_tx.publish(state); - return None; - } - - // A community that hides its vetters takes a proof bound to a challenge - // it issues — once, and fresh for each attempt (see `challenge_step`). - // Asked for here, after every question the person answers, so the - // challenge is as young as it can be when the proof is built over it. - // The flow hands the wait to the loop that can hear the answer and comes - // back through `JoinEntry::Challenge`, which lands here again. - let fresh = std::mem::take(&mut state.join.challenge_fresh); - let step = challenge_step( - presented_application(config, &vtc_did, &choice), - state.join.hears_replies, - fresh, - ); - if step == ChallengeStep::Ask - && let IdentityPick::Reuse(persona) = choice - { - state.join.page = JoinPage::Progress; - state.join.completed = Completion::NotFinished; - state.join.messages.clear(); - let name = vetting_actions::community_display(config, &vtc_did); - match ask_challenge(config, tdk, messaging, &vtc_did, persona).await { - Ok(document_id) => { - // Locked while the answer is awaited: the wait is bounded - // (`CHALLENGE_WAIT`), and nothing on this page can usefully - // be done in the meantime. - state.join.processing = true; - state.join.waiting_on_challenge = true; - state.join.info(format!( - "This community hides its vetters. Asking {name} for a fresh challenge \ - to bind your proof to — this gives up after {} seconds. Nothing has \ - been sent yet; Esc stops waiting.", - CHALLENGE_WAIT.as_secs() - )); - state.join.challenge_wait = Some(AwaitingChallenge { - vtc_did, - document_id, - asked_at: std::time::Instant::now(), - choice, - context_id, - }); - } - Err(reason) => state.join.fail(challenge_failed_words(&name, &reason)), - } - let _ = self.state_tx.publish(state); - return None; - } - - // Move to the progress page and lock input. - state.join.page = JoinPage::Progress; - state.join.processing = true; - state.join.completed = Completion::NotFinished; - state.join.messages.clear(); - state.join.info(format!("Joining {vtc_did}…")); - let _ = self.state_tx.publish(state); - - // R15: race the multi-step VTA sequence against the interrupt so Ctrl-C / - // Exit stay live for its whole (network-bound) duration. On interrupt the - // sequence future is DROPPED — cancelled at whatever `.await` it parked - // on. `minted_persona` is the only handle to mid-sequence persisted state - // (`mint_persona_into` writes a persona before the receipt), so a cancel - // after that point rolls it back. It lives outside the future so it stays - // readable after the drop. A *reused* persona is never set here, so it is - // never rolled back. - let mut minted_persona: Option = None; - // The listener [`start_persona_listener`] installed, for the same reason - // and by the same discipline as `minted_persona`: it is state the - // sequence created before the join was committed, and a cancel drops - // the future at whatever await it parked on, so the only place that can - // still tear it down is out here. Left behind, it is a mediator socket - // held open for a persona that was just rolled back. - let mut started_listener: Option = None; - // Captured before the mint so a rollback (cancel or failure) can restore - // it — `mint_persona_into` overwrites `public.friendly_name` with the - // attempted community's persona name. - let prior_friendly_name = config.public.friendly_name.clone(); - let sequence = run_join_sequence( - self, - state, - tdk, - config, - admin_vta, - profile, - vtc_did, - choice, - context_id, - &mut minted_persona, - &prior_friendly_name, - messaging, - &mut started_listener, - ); - let interrupted = race_against_interrupt(sequence, interrupt_rx).await; - - if let Some(interrupted) = interrupted { - if let (Some(service), Some(listener_id)) = (messaging, started_listener.as_deref()) { - service.remove_listener(listener_id).await; - } - if let Some(persona_id) = minted_persona - && !config.account.persona_referenced(&persona_id) - { - rollback_minted_persona(config, persona_id, state, profile, &prior_friendly_name); - } - state.join.processing = false; - state.join.completed = Completion::CompletedFail; - state.join.info( - "Join cancelled. Any partially-minted persona was rolled back; a sub-context may remain at the VTA.", - ); - state.main_page.log("Join cancelled by user."); - let _ = self.state_tx.publish(state); - return Some(interrupted); - } - - state.join.processing = false; - let _ = self.state_tx.publish(state); - None - } -} - /// Open the invitation step for a join as `persona_id`: this community's valid /// invitations bound to that persona, plus one loaded by hand. fn open_invitation_choice(state: &mut State, config: &Config, persona_id: PersonaId) { @@ -3074,34 +1589,20 @@ fn build_persona_options(config: &Config, vics: &[AvailableVic]) -> Vec, - resolver: &affinidi_tdk::did_resolver::DIDCacheClient, - vtc_did: &str, -) { - if state.join.invitations_for.as_deref() == Some(vtc_did) { - return; - } - state.join.available_vics = collect_available_vics(state, admin_vta, resolver, vtc_did).await; - state.join.invitations_for = Some(vtc_did.to_string()); -} - +/// Two screens need them — the vetting page, to say whether presenting one is +/// a way in, and the identity step, to badge each persona with its count — and +/// the collection is a vault listing plus a fetch per descriptor, so it runs off +/// the loop and once per community: `JoinState::invitations_for`, rather than +/// the emptiness of the list, is what says it has been done (holding none is a +/// legitimate answer). async fn collect_available_vics( - state: &State, + loaded: Option<&serde_json::Value>, admin_vta: Option<&VtaClient>, resolver: &affinidi_tdk::did_resolver::DIDCacheClient, vtc_did: &str, @@ -3148,7 +1649,7 @@ async fn collect_available_vics( // A loaded VIC (--invitation / paste) that matches this community, if not // already surfaced from the vault. - if let Some(vic) = state.invitation_credential.as_ref() + if let Some(vic) = loaded && usable(vic).await && let Some(av) = to_available_vic(vic) && !out.iter().any(|o| o.id == av.id) @@ -3173,26 +1674,6 @@ fn to_available_vic(body: &serde_json::Value) -> Option { }) } -/// Outcome of a `join_flow` invocation. -pub(crate) enum JoinExit { - /// User cancelled / finished — return to the main page and resume the - /// caller's loop. Carries the just-joined session when a join succeeded, so - /// the runtime loop can register it + start its listener live (R-B-5). - Returned(Option), - /// The community was asked what it requires. The caller keeps the join - /// screen up, waits for the answer, and enters the flow again - /// ([`AwaitingRequirements`]). - AwaitRequirements(AwaitingRequirements), - /// The launch is parked on the challenge its hidden-vetting proof binds; - /// the caller hears the answer and enters the flow again - /// ([`AwaitingChallenge`]). - AwaitChallenge(AwaitingChallenge), - /// The launch is parked on a wallet session ([`AwaitingWallet`]). - AwaitWallet(AwaitingWallet), - /// Application is exiting (Exit / UXError / interrupt). - Exit(Interrupted), -} - /// Accept a pasted / clipboard-read invitation credential (VIC). /// /// `vtc_did` is `Some` only on the invitation step, where the community is @@ -3430,36 +1911,6 @@ async fn load_invitation_from_vault( serde_json::to_value(got).ok()?.get("credential").cloned() } -/// #1b: build a subject-linkage proof when the presenting DID differs from the -/// loaded VIC's subject — the invited persona authorizes the presenter. Returns -/// `None` on the join-as-subject path (presenter == subject), when no invitation -/// is loaded, or when the subject isn't one of our personas (we can't sign for a -/// key we don't hold; the VTC then refuses the mismatched binding). Best-effort: -/// a signing failure is logged and yields `None`. -async fn build_linkage_proof( - config: &Config, - admin_vta: &VtaClient, - state: &State, - presenter_did: &str, -) -> Option { - let vic = state.invitation_credential.as_ref()?; - let subject = openvtc_core::join::invitation_subject(vic)?; - if subject == presenter_did { - return None; // join-as-subject — no linkage needed - } - let vic_id = openvtc_core::join::invitation_id(vic)?; - match config - .build_subject_linkage(subject, Some(admin_vta), vic_id, presenter_did) - .await - { - Ok(linkage) => Some(linkage), - Err(e) => { - debug!(subject = %subject, error = %e, "subject-linkage proof unavailable"); - None - } - } -} - /// Idempotency decision (R-B-9): is there already a *live* (Active/Pending) /// membership for this VTC? /// @@ -3472,770 +1923,6 @@ fn is_duplicate_membership(config: &Config, vtc_did: &str, persona: PersonaId) - config.account.has_live_membership(vtc_did, persona) } -/// Run the automated mint → sub-context → join-submit → persist sequence. -/// -/// All progress and errors land in `state.join`. On success -/// `state.join.completed` is `CompletedOK` and `created_community` holds the new -/// pending record; on any failure it is `CompletedFail` with the error logged. -/// -/// `minted_persona` is written as soon as the persona is minted-and-persisted so -/// the caller can roll it back if the whole future is cancelled (R15) before the -/// persona is bound to a community. -#[allow(clippy::too_many_arguments)] -async fn run_join_sequence( - handler: &StateHandler, - state: &mut State, - tdk: &TDK, - config: &mut Config, - admin_vta: Option<&VtaClient>, - profile: &str, - vtc_did: String, - choice: IdentityPick, - context_id: String, - minted_persona: &mut Option, - prior_friendly_name: &str, - messaging: Option<&Messaging>, - started_listener: &mut Option, -) { - // Idempotency (R-B-9) was already enforced at submit, before the identity - // choice — no re-check here. - - // The person's trust-registry answer, consumed by this one submit. Taken, - // not read: a later join from the same open flow — another community, or a - // retry after a failure — asks again rather than inheriting it. - let registry = state.join.registry.take(); - let registry_consent = registry.as_ref().is_some_and(|r| r.consent_for(&vtc_did)); - // The directory statement the person was shown, named by its digest, kept - // with the join so a later change to the terms can be told apart. - let directory_statement_digest = registry - .filter(|r| r.community == vtc_did) - .and_then(|r| r.directory) - .map(|d| d.statement_digest); - - // The mint + join sequence needs the admin VTA session. - let Some(admin_vta) = admin_vta else { - state - .join - .fail("VTA session unavailable — cannot join right now."); - return; - }; - - // 2. Resolve the community's verified agent name for display (best-effort). - // A verified name (`example.com/@acme`) is the community's human-readable - // handle; `None` when it has no verifiable name, so the sub-context - // derivation falls back to the DID-derived token (D9). The lookup is also - // seeded into the persisted cache so the communities list shows it without - // waiting for the background sweep. - let display_name = - openvtc_core::agent_name::resolve_verified_name(tdk.did_resolver(), &vtc_did).await; - config.set_cached_agent_name(&vtc_did, display_name.clone(), chrono::Utc::now()); - state.join.display_name = display_name.clone(); - - // 3. Preflight the community's transports, before anything is minted. - // - // A peer that advertises no messaging service cannot be joined at all, and - // discovering that after the persona mint leaves an orphaned identity - // attached to a request nobody could receive. This is a check on what the - // document *offers* — not on whether the peer can actually serve it, which - // no client can know before sending, and which is the peer's defect to fix - // rather than ours to route around. - let transports = openvtc_core::config::peer_messaging_transports(&vtc_did).await; - let Some(peer_preferred) = transports.preferred() else { - state.join.fail(format!( - "This community advertises no messaging transport ({}). Its DID \ - document offers neither a TSP nor a DIDComm service, so a join \ - request cannot reach it. Nothing was created.", - state.join.display_name.as_deref().unwrap_or(&vtc_did) - )); - return; - }; - // What the community *offers* — not yet the wire the submit goes out on. - // That needs our own leg too, and is settled at step 8 once the persona (and - // so the mediator we post through) is known. - state - .join - .info(format!("Community reachable over {peer_preferred}.")); - let _ = handler.state_tx.publish(state); - - let top_context_id = config.account.top_context_id.clone(); - - // 4. The community's context exists before anything is minted into it. A - // retried join reuses a context an interrupted one left behind. - let context_name = display_name - .clone() - .unwrap_or_else(|| shorten_did(&vtc_did, 48)); - match community_context::ensure_context(admin_vta, &top_context_id, &context_id, &context_name) - .await - { - Ok(true) => state.join.info(format!("Created context {context_id}.")), - Ok(false) => state.join.info(format!("Using context {context_id}.")), - Err(e) => { - state.join.fail(format!( - "Could not prepare context {context_id}: {e}. Nothing was created." - )); - return; - } - } - let _ = handler.state_tx.publish(state); - - // Resolve the persona to present: reuse an existing account persona (R-B-3) - // or mint a fresh, self-contained one (D6). Only a *minted* persona is - // recorded in `minted_persona` for rollback — a reused persona pre-exists and - // must never be rolled back. - let (persona_id, persona_did) = match choice { - IdentityPick::Reuse(persona_id) => match config.identities.get(&persona_id) { - Some(ident) => { - // Per-persona idempotency (R-B-9): block a second live membership - // as the *same* persona, while still allowing other personas. - if is_duplicate_membership(config, &vtc_did, persona_id) { - state.join.fail( - "Already a member of (or have a pending request for) this community as this persona.", - ); - return; - } - // A persona's keys live in one context; one minted into a - // sub-context is only ever presented from it. - if let Some(record) = config.account.personas.get(&persona_id) { - let home = community_context::persona_context(record, &top_context_id); - if community_context::is_sub_context(home, &top_context_id) - && home != context_id - { - state.join.fail(format!( - "This persona's keys live in {home}, so it can only join from there." - )); - return; - } - } - let did = ident.persona_did().to_string(); - state.join.info(format!("Reusing persona {did}…")); - let _ = handler.state_tx.publish(state); - (persona_id, did) - } - None => { - state - .join - .fail("Selected persona is unavailable — cannot reuse it."); - return; - } - }, - IdentityPick::Mint => { - // The persona's mediator is the account's VTA mediator: the DID minted via - // the VTA's webvh server advertises that mediator in its DIDComm service, so - // the persona listener must use the same one. Hardcoding `None` (the public - // default) left the persona with no usable mediator — the listener then - // failed with "No Mediator is configured" and retried forever. A VTA that - // advertised no mediator refuses the mint here, before any key or DID is - // created at the VTA: there is no built-in mediator to fall back on. - let Some(mediator) = setup_config::vta_mediator(config) else { - state.join.fail(setup_config::NO_VTA_MEDIATOR); - return; - }; - state.setup.custom_mediator = Some(mediator); - - // 5. Mint a fresh persona into `state.setup` (reusing the setup - // helpers), with every key and the DID in the community's context. - state - .join - .info("Creating persona keys (signing, authentication, encryption)…"); - let _ = handler.state_tx.publish(state); - match vta::create_persona_keys(admin_vta, Some(&context_id)).await { - Ok(keys) => state.setup.did_keys = Some(keys), - Err(e) => { - state - .join - .fail(format!("Failed to create persona keys: {e}")); - return; - } - } - // WebVH update keys. - state.join.info("Creating DID update keys…"); - let _ = handler.state_tx.publish(state); - match vta::create_update_keys(admin_vta, Some(&context_id)).await { - Ok((update, next_update)) => { - state.setup.vta.update_secret = Some(update); - state.setup.vta.next_update_secret = Some(next_update); - } - Err(e) => { - state - .join - .fail(format!("Failed to create update keys: {e}")); - return; - } - } - - // Pick the first WebVH server. Serverless mint is a deliberate follow-up. - state.join.info("Finding a DID hosting server…"); - let _ = handler.state_tx.publish(state); - let server_id = match vta::list_webvh_servers(admin_vta).await { - Ok(servers) => match servers.into_iter().next() { - Some(s) => s.id, - None => { - state.join.fail( - "No WebVH server available from the VTA (serverless mint not yet supported).", - ); - return; - } - }, - Err(e) => { - state - .join - .fail(format!("Failed to list WebVH servers: {e}")); - return; - } - }; - - // Create the persona did:webvh via the server (auto-assigned path). - state - .join - .info(format!("Creating persona DID via {server_id}…")); - let _ = handler.state_tx.publish(state); - // How the new persona is presented: the operator's choice on the - // identity page, else the account's preference (VTI-VTA-036). - let presentation = openvtc_core::config::account::persona_presentation( - &config.account, - state.join.new_persona_did_method, - ); - match vta::create_did_via_server( - admin_vta, - tdk, - &context_id, - &server_id, - WebvhPathMode::AutoAssign, - presentation, - ) - .await - { - Ok(vta::MintedDid { - keys, - did, - document, - locator, - }) => { - // A persona the VTA minted without `#tsp` can never reach a - // TSP-only community — and this join may be to one. Say so - // here rather than letting it surface later as a request - // that goes out and is never answered. - if let Some(warning) = - openvtc_core::config::did::tsp_advertisement_warning(&document) - { - state.join.info(warning); - } - state.setup.did_keys = Some(keys); - state.setup.webvh_address.did = did; - state.setup.webvh_address.document = document; - state.setup.webvh_address.locator = locator; - } - Err(e) => { - state - .join - .fail(format!("Failed to create persona DID: {e}")); - return; - } - } - - // A join mints the persona *unlabelled*. `display_name` here is the - // COMMUNITY's name — naming the persona after it (or, with no - // verified name, after a DID-derived rendering of the VTC DID) put a - // community identifier in the persona's own label, and - // `mint_persona_into` copied that on into `public.friendly_name`, so - // the header announced the community twice and the DID manager - // listed a persona labelled with a community DID. The persona's - // community binding is recorded on the membership record and shown - // beside it; the label is for a name the operator chose. - state.setup.username = String::new(); - - // 5. Persist the persona into the account. `mint_persona_into` writes the - // persona record + runtime identity + key info to disk *immediately* (a - // synchronous `Config::save`), so from here until the community record is - // persisted (step 9) the on-disk config holds a persona with no community. - // Record its id so a cancel (R15) or later failure can roll it back. - let persona_id = - match Config::mint_persona_into(config, &state.setup, tdk, profile).await { - Ok(id) => id, - Err(e) => { - state.join.fail(format!("Failed to save persona: {e}")); - return; - } - }; - *minted_persona = Some(persona_id); - // Where its keys live, which decides every context it can join from. - if let Some(record) = config.account.personas.get_mut(&persona_id) { - record.origin_context_id = context_id.clone(); - } - let persona_did = state.setup.webvh_address.did.clone(); - state.join.info(format!("Persona created: {persona_did}")); - let _ = handler.state_tx.publish(state); - (persona_id, persona_did) - } - }; - // Only a freshly-minted persona is rolled back on a later failure; a reused - // persona pre-existed and is left intact. - let minted = minted_persona.is_some(); - - // 6. The membership lives in the context chosen for it, prepared at step 4. - // A vetting application's context was offered as the only choice, so the - // community sees the face the vetters checked. - let sub_context_id = context_id; - - // 8. Submit the join request to the VTC over DIDComm. The persona is - // the authcrypt sender (the VTC reads the applicant from the - // envelope — no holder-binding signature, and a did:webvh persona - // can't use the VTC's did:key-only REST signature path). The minted - // persona's runtime identity (ATM profile + mediator) was built into - // `config.identities` by `mint_persona_into`. The VTC's - // submit-receipt (with the authoritative requestId) returns - // asynchronously to the persona's mediator; until that receipt - // handler lands, the request message id is the correlation handle - // stored on the Pending record. - state.join.info("Submitting join request…"); - let _ = handler.state_tx.publish(state); - - if tdk.atm.is_none() { - state - .join - .fail("Messaging (ATM) unavailable — cannot submit the join request."); - if minted { - rollback_minted_persona(config, persona_id, state, profile, prior_friendly_name); - } - return; - } - let (applicant_did, persona_mediator) = match config.identities.get(&persona_id) { - Some(ident) => ( - ident.persona_did().to_string(), - ident.mediator_did.clone().unwrap_or_default(), - ), - None => { - state - .join - .fail("Persona identity unavailable after mint — cannot submit."); - if minted { - rollback_minted_persona(config, persona_id, state, profile, prior_friendly_name); - } - return; - } - }; - - // Open the applicant's mailbox before knocking on the door. The VTC decides - // a well-formed invited join in under a second and pushes the VMC + role VEC - // straight back, so the reply is in flight while this sequence is still - // running — and a mediator only *live-streams* a message whose recipient is - // connected at the instant it lands. Bringing the socket up after the - // sequence returned (which is what `register_joined_session` did alone) left - // a window as long as the operator's own reading speed, and a community - // reply that landed inside it was stored and never pushed: the VTC's outbox - // read "sent", the mediator held two messages, and the membership sat - // Pending with nothing in any log to say why. Connecting first closes the - // window at the only end we control. - // - // Started here, waited for at the submit ([`await_persona_online`]): the - // connect is I/O the invitation resolution and VP build below do not depend - // on, so overlapping them spends the connect out of work already being done - // rather than out of the operator's time. - *started_listener = start_persona_listener( - handler, - state, - messaging, - config, - tdk, - persona_id, - &applicant_did, - ) - .await; - // #2: resolve the VIC to present for THIS community. A VIC's issuer is the - // community's VTC DID, so a presentable invitation must match `vtc_did` and - // be unexpired — presenting a mismatched or expired one only earns a VTC - // rejection (and reads as a failed invitation rather than an open request). - // An explicitly loaded VIC (--invitation / paste) is still stored in the - // vault regardless (its durable home), but only *presented* when it matches; - // otherwise we fall back to a community-matched VIC the vault already holds, - // else submit as an open request. Setting `state.invitation_credential` to - // the resolved VIC keeps the VP, the linkage proof, and the summary all - // consistent with what is actually presented. All vault calls are - // best-effort — the join proceeds regardless. - let loaded = state.invitation_credential.take(); - let mut presentable: Option = None; - if state.join.present_invitation { - // The operator chose to present an invitation (or one was available and - // they accepted the default). Resolve the one to present. - if let Some(vic) = loaded { - if let Err(e) = admin_vta.cred_vault_receive(vic.clone(), None).await { - debug!(error = %e, "storing invitation in the VTA vault failed (continuing)"); - } - if !openvtc_core::join::invitation_matches_community(&vic, &vtc_did) { - state.join.info( - "Loaded invitation is for a different community — \ - looking for one that matches…", - ); - } else if openvtc_core::join::invitation_is_expired(&vic, Utc::now()) { - state - .join - .info("Loaded invitation has expired — looking for a valid one…"); - } else { - presentable = Some(vic); - } - } - if presentable.is_none() { - presentable = load_invitation_from_vault(admin_vta, &vtc_did).await; - } - } else if let Some(vic) = loaded { - // The operator chose to join *without* an invitation. Still store a - // loaded VIC in the vault (its durable home) but present nothing — this - // is what honours the choice over the vault fallback above. - let foreign = foreign_invitation_subject(config, &vic); - if let Err(e) = admin_vta.cred_vault_receive(vic, None).await { - debug!(error = %e, "storing invitation in the VTA vault failed (continuing)"); - } - match foreign { - // Not a choice the operator made: say what stopped the invitation. - Some(subject) => state.join.info(format!( - "Your invitation names {}, which is not one of your personas — \ - the community accepts it only from that DID, so this is an open \ - request (awaiting approval).", - shorten_did(&sanitize_display(&subject, 256), 48) - )), - None => state.join.info( - "Joining without an invitation — submitting an open request (awaiting approval).", - ), - } - } - // Completeness gate before presenting: a VIC resolved from the vault may - // predate the ingest-time validation (or have lost fields in storage). An - // incomplete VIC is unusable — the VTC can't extract it and silently refers - // the join to a moderator — so drop it here and fall to an open request with - // a clear reason, rather than presenting junk. - if let Some(vic) = &presentable - && let Err(why) = - openvtc_core::join::verify_invitation_credential(vic, tdk.did_resolver(), Utc::now()) - .await - { - state.join.info(format!( - "Resolved invitation is not usable ({why}) — submitting as an open request instead." - )); - presentable = None; - } - state.invitation_credential = presentable; - state.join.has_invitation = state.invitation_credential.is_some(); - state.join.presented_invitation = state.invitation_credential.as_ref().map(|vic| { - let subject = openvtc_core::join::invitation_subject(vic).map(str::to_string); - PresentedInvitation { - id: openvtc_core::join::invitation_id(vic) - .unwrap_or_default() - .to_string(), - // Verified-only, from the persisted cache: the subject is one - // of our own personas, so the sweep normally has a name for it. - subject_agent_name: subject - .as_deref() - .and_then(|s| config.agent_name_for(s)) - .map(|n| sanitize_display(n, 256)), - subject, - } - }); - - // Present the holder VP. When a matching, unexpired invitation (VIC) is - // resolved it rides in the VP's `verifiableCredential` array; the VTC - // verifies it and auto-admits on a valid, trusted, unconsumed invitation (no - // manual approval). With no presentable invitation the join is an open - // request the community reviews and approves manually. - if state.invitation_credential.is_some() { - state - .join - .info("Presenting your invitation credential to the community…"); - } else if state.join.present_invitation { - // Wanted to present one, but none resolved to a usable VIC. - state.join.info( - "No valid invitation for this community — \ - submitting as an open request (awaiting approval).", - ); - } - // (When the operator chose to join without an invitation, the note was - // already surfaced above where the VIC was suppressed.) - let _ = handler.state_tx.publish(state); - // Subject-linkage (#1b): when the presenting DID differs from the VIC - // subject, prove the subject authorized this presenter (signed with the - // subject persona's key). On the join-as-subject path (#1a) this is `None`. - let linkage = build_linkage_proof(config, admin_vta, state, &applicant_did).await; - if linkage.is_some() - && let Some(subject) = state - .invitation_credential - .as_ref() - .and_then(|vic| openvtc_core::join::invitation_subject(vic)) - { - // Presenting through another persona is a disclosure: the community - // learns that the persona it invited vouches for this one. - let name = config - .agent_name_for(subject) - .map(|n| sanitize_display(n, 256)) - .unwrap_or_else(|| shorten_did(subject, 48)); - state.join.info(format!( - "The invitation names {name}, which signs for this persona — the community \ - will see that the two are linked." - )); - } - let mut vp = openvtc_core::join::build_join_vp( - &applicant_did, - state.invitation_credential.as_ref(), - linkage.as_ref(), - ); - // The community's questions, answered from the face the holder chose on the - // Answers page — through a disclosure, so the holder's own history records - // what they told the community. The persona wears the face here first: - // a disclosure is always of what a persona wears in a context. - let approved_answers = state.join.answers.as_ref().and_then(|a| { - a.approved - .clone() - .map(|(face, values)| (a.asked.clone(), face, values)) - }); - let attributes = match approved_answers { - None => Vec::new(), - Some((asked, face_id, approved)) => { - state - .join - .info("Answering the community's questions from your face…"); - let _ = handler.state_tx.publish(state); - let answered = answer_from_face( - admin_vta, - &sub_context_id, - &applicant_did, - &vtc_did, - &asked, - &face_id, - &approved, - ) - .await; - match answered { - Ok(attributes) => attributes, - Err(e) => { - state.join.fail(e); - if let (Some(service), Some(listener_id)) = (messaging, started_listener.take()) - { - service.remove_listener(&listener_id).await; - } - if minted { - rollback_minted_persona( - config, - persona_id, - state, - profile, - prior_friendly_name, - ); - } - return; - } - } - } - }; - - // Peer identity vetting: present the statements gathered for this community - // under this persona, and name the requirements they were gathered against - // so the community applies the same criterion (vetting-process.md §10.1). - // Under a hidden-vetting criterion the proof is built here, bound to a challenge of this - // submission's own, and rides in `extensions` (design §8). The statements themselves never - // travel: there are none that name a vetter. - // - // Every way the proof can be missing stops the join here. Submitting without it sends no - // vetting at all — the named statements never travel on this path — so the community would - // decide on nothing while the holder believed they had presented their vetting. - let hidden_refusal = match config - .private - .vetting - .application_mut(&vtc_did, persona_id) - .filter(|a| a.join_did == applicant_did && a.hidden.is_some()) - { - None => None, - Some(application) => hidden_proof_refusal(application, admin_vta, &mut state.join).await, - }; - if let Some(refusal) = hidden_refusal { - state.join.fail(refusal); - if let (Some(service), Some(listener_id)) = (messaging, started_listener.take()) { - service.remove_listener(&listener_id).await; - } - if minted { - rollback_minted_persona(config, persona_id, state, profile, prior_friendly_name); - } - return; - } - let protocol = config.private.vetting.protocol_for(&vtc_did); - let presentation = match config.private.vetting.application(&vtc_did, persona_id) { - Some(application) if application.join_did == applicant_did => { - let statements = application.presentable_statements(chrono::Utc::now()); - if !statements.is_empty() { - state.join.info(format!( - "Presenting {} vetting statement(s) to the community…", - statements.len() - )); - } - openvtc_core::join::attach_credentials(&mut vp, statements); - openvtc_core::join::JoinPresentation { - vp, - extensions: application.join_extensions(), - attributes, - protocol, - // The vetting criterion this application was made under. - criterion: application.requirements_digest.clone(), - registry_consent, - } - } - // No application: the community decides under the first criterion this - // submission meets — an invitation, a credential it recognises, or - // review — which is the route the person chose. - // With wallet evidence, under the criterion it was collected for: the - // community counts it only there. - _ => openvtc_core::join::JoinPresentation { - attributes, - protocol, - registry_consent, - criterion: match &state.join.wallet_outcome { - Some(WalletOutcome::Presented(digest)) => Some(digest.clone()), - _ => None, - }, - ..vp.into() - }, - }; - // Settle the wire (TSP needs both legs; `choose_submit_route` says why). - let route = - openvtc_core::join::sequence::choose_submit_route(&vtc_did, &persona_mediator, &transports) - .await; - if let Some(note) = route.note { - state.join.info(note.describe()); - let _ = handler.state_tx.publish(state); - } - let submit_transport = route.transport(); - // Last gate before the request goes out: the socket started above has had - // the invitation + VP work to come up, so this is normally already true. - let applicant_online = await_persona_online(handler, state, messaging, &applicant_did).await; - - let request_id = match openvtc_core::join::sequence::submit( - config, - tdk, - persona_id, - &vtc_did, - &route, - presentation, - ) - .await - { - Ok(id) => id, - // A persona whose key cannot be read cannot apply. Nothing was sent; - // the persona is kept, as it always was on this path. - Err(openvtc_core::join::sequence::SubmitError::Signer(e)) => { - state - .join - .fail(format!("Couldn't sign the application: {e}")); - return; - } - Err(e) => { - state - .join - .fail(format!("Failed to submit join request: {e}")); - // Nothing was submitted, so nothing will reply — release the socket - // we opened for this attempt before the persona is rolled back, or - // it outlives the identity it speaks for. - if let (Some(service), Some(listener_id)) = (messaging, started_listener.take()) { - service.remove_listener(&listener_id).await; - } - rollback_minted_persona(config, persona_id, state, profile, prior_friendly_name); - return; - } - }; - - // The challenge a hidden proof was bound to is spent now: the community - // consumes it the moment it reads the proof, whatever it then decides. Kept, - // it would be bound into the next attempt's proof and refused there. Saved - // with the membership below. - if let Some(application) = config - .private - .vetting - .application_mut(&vtc_did, persona_id) - .filter(|a| a.join_did == applicant_did) - { - application.hidden_submission_sent(); - } - - // 9. Record the pending membership and persist. - let record = sequence::pending_record( - vtc_did.clone(), - display_name, - sub_context_id, - persona_id, - request_id, - Utc::now(), - submit_transport, - ); - let record = CommunityRecord { - directory_statement_digest, - ..record - }; - config.account.add_membership(record.clone()); - if let Err(e) = save_config(config, profile) { - state - .join - .fail(format!("Failed to save community record: {e}")); - return; - } - - // 10. Sent — refresh the communities panel and surface the relaunch prompt. - // - // "Sent", not "delivered". Every word below is deliberately about what this - // client actually witnessed: `submit_join_request` resolves `Ok` when *our - // own mediator* accepts the frame, which says nothing about whether the - // community's mediator received it or the community ever read it. Claiming - // otherwise is R1.1 — a send `Ok` is not a delivery — and it is the reason a - // join that died two hops away read here as an unqualified success while the - // community's log showed no trace of it. - // - // The acknowledgement is asynchronous and cannot be waited for here: the - // join flow's `select!` loop reads UI actions only, so the inbound dispatch - // that reconciles a receipt (`CommunityRecord::receipt_at`) does not run - // until this sequence returns. `pending_unacknowledged` flags the record in - // the communities panel if nothing arrives within `PENDING_ACK_GRACE_SECS`, - // so the honest thing to do here is name what is still outstanding and point - // at where the answer will show up. - // - // What the pre-submit connect changed is *where* an early reply waits, not - // when it is read: the persona's socket is up, so a reply that arrives - // during this window is streamed and queued on the DIDComm event channel for - // the runtime loop to drain, rather than sitting unstreamed in a mailbox - // whose owner never connected. - state.main_page.sync_from_config(config); - // Durable, unlike the ceremony commentary in `state.join`, which is - // transient UI and gone on the next launch. A submitted join is the thing - // you most want a record of when it does not complete. - // - // Whether the applicant was connected is part of that record. A join - // submitted from an unconnected persona is still valid — the community - // admits, and `Messaging::pickup_stored` collects the reply when the socket - // comes up — but it is the one that takes the slow path, and an operator - // reading this log later cannot otherwise tell that from a community that - // never answered. - config.private.logs.insert( - LogFamily::Community, - format!( - "Join request sent to ({}) as persona ({}) over {} — Pending, not yet acknowledged.{}", - state.join.display_name.as_deref().unwrap_or(&vtc_did), - persona_did, - submit_transport, - if applicant_online { - "" - } else { - " The applicant was not connected at submit; a reply that arrives before it \ - connects is collected from the mediator rather than streamed." - } - ), - ); - state.main_page.log(format!( - "Join request sent over {submit_transport} — Pending in your Communities list, awaiting \ - the community's acknowledgement." - )); - state.join.created_community = Some(record); - state.join.created_persona_did = Some(persona_did.clone()); - state.join.registry_consent_sent = Some(registry_consent); - state.join.completed = Completion::CompletedOK; - state.join.info(format!( - "Join request sent over {submit_transport}. Waiting for the community to acknowledge it — \ - it's Pending in your Communities list, which will flag it if no response arrives." - )); -} - /// How long the join sequence waits for the applicant persona's mediator socket /// before submitting anyway (R1.2: the wait is finite). /// @@ -4246,77 +1933,12 @@ async fn run_join_sequence( /// operator a pause, not the join: the submit goes out either way. const PERSONA_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -/// Install the applicant persona's mediator listener so its socket is coming up -/// while the rest of the sequence runs. Paired with [`await_persona_online`], -/// which does the waiting at the submit. -/// -/// Returns the listener id **only when this call installed one** — that is the -/// caller's handle for tearing it down if the join then fails or is cancelled. -/// `None` covers every case where there is nothing to undo: no messaging service -/// (State-A), a reused persona that is already live, or an install that failed. -/// -/// Failure is never fatal. A join whose applicant could not connect is still a -/// valid join: it is submitted, the community still admits, and the reply waits -/// in the mailbox for the persona's listener to pick up — on the mediator's -/// redelivery when the socket does come up, or on the next launch. So the -/// outcome is reported and the sequence continues (R6.4: the operator is told -/// what happened, not handed one generic hint). -async fn start_persona_listener( - handler: &StateHandler, - state: &mut State, - messaging: Option<&Messaging>, - config: &Config, - tdk: &TDK, - persona_id: PersonaId, - applicant_did: &str, -) -> Option { - // State-A has no service to install into. The degraded loop restarts into - // the full pipeline after a first join, and startup registration brings the - // persona up there. - let service = messaging?; - let listener_id = openvtc_core::didcomm::persona_listener_id(applicant_did); - - // A reused persona already serving another community has its socket — the - // one identity, one listener rule (D1/D11). Re-adding would error on the - // duplicate id, and it is not ours to claim: tearing it down on a cancelled - // join would take the other community's session with it. - if service.has_listener(&listener_id).await { - return None; - } - - let Some(spec) = - openvtc_core::didcomm::persona_listener_config_for(config, tdk, persona_id).await - else { - // The mint wrote this identity moments ago, so this is unexpected — but - // it costs the join nothing, so log it and carry on. - debug!( - persona = %applicant_did, - "no listener config for the applicant persona; submitting without a live session" - ); - return None; - }; - if let Err(e) = openvtc_core::didcomm::add_listener(service, &spec).await { - state.join.info(format!( - "Couldn't open this persona's mediator session ({e}) — submitting anyway; the \ - community's reply will be collected when it connects." - )); - let _ = handler.state_tx.publish(state); - return None; - } - - state - .join - .info("Connecting the new persona to its mediator…"); - let _ = handler.state_tx.publish(state); - Some(listener_id) -} - /// Start `persona_id`'s listener without waiting for it to connect. /// /// The connect runs on its own task, so the page that made the persona keeps /// answering keys meanwhile. What it needs from the config is read here, before /// the task starts; the task only connects. A failure is logged — the submit's -/// own wait for the socket ([`await_persona_online`]) is what tells the person +/// own wait for the socket (bounded by [`PERSONA_CONNECT_TIMEOUT`]) is what tells the person /// whether the request went out live. async fn spawn_persona_listener( messaging: Option<&Messaging>, @@ -4346,104 +1968,6 @@ async fn spawn_persona_listener( }); } -/// Wait — bounded — for the applicant persona's socket to be live, immediately -/// before the join request goes out. -/// -/// Split from [`start_persona_listener`] so the connect overlaps the invitation -/// resolution and VP build rather than adding to them; by the time this runs the -/// answer is normally already `Connected` and it returns at once. A persona with -/// no listener at all (State-A, or an install that failed) is nothing to wait -/// for, and the submit proceeds either way. -/// Returns whether the applicant was live at the moment of the submit — which -/// the durable join record then states, because "sent while offline" is the one -/// fact that explains a join taking the slow (collect-on-connect) path, and it -/// has to survive the relaunch you make to go looking. -async fn await_persona_online( - handler: &StateHandler, - state: &mut State, - messaging: Option<&Messaging>, - applicant_did: &str, -) -> bool { - let Some(service) = messaging else { - return false; - }; - let listener_id = openvtc_core::didcomm::persona_listener_id(applicant_did); - if !service.has_listener(&listener_id).await { - return false; - } - - let online = match service - .wait_connected(&listener_id, PERSONA_CONNECT_TIMEOUT) - .await - { - Ok(()) => { - state - .join - .info("Persona connected — ready to receive the community's reply."); - true - } - Err(e) => { - // Installed but not up yet. The listener's own restart policy keeps - // working on it, so this is a slow connect, not a dead one. - debug!(listener = %listener_id, error = %e, "applicant persona not connected before submit"); - state.join.info( - "The persona's mediator session is still connecting — submitting now; the \ - community's reply will be collected once it is up.", - ); - false - } - }; - let _ = handler.state_tx.publish(state); - online -} - -/// Have the VTA build a hidden-vetting application's proof for this submission -/// ([`openvtc_core::vetting::pcs_vta::prove`]), or say why it cannot be. -/// -/// `None` when the proof is built and will ride in `extensions`. `Some` is the sentence the -/// progress page fails with — each naming what the holder can do, because "the proof could not -/// be built" alone leaves them nowhere to go. -async fn hidden_proof_refusal( - application: &mut openvtc_core::vetting::applicant::Application, - admin_vta: &VtaClient, - join: &mut crate::state_handler::join::JoinState, -) -> Option { - let challenge = match hidden_proof_precheck(application) { - Ok(challenge) => challenge, - Err(refusal) => return Some(refusal), - }; - // Hidden parameters but no application at the VTA: no vetter's attestation has arrived. - let (Some(params), Some(app)) = (application.hidden.clone(), application.hidden_app.clone()) - else { - return Some(NO_ATTESTATION.to_string()); - }; - join.info("Proving that enough vetters vetted you, without naming them…".to_string()); - let digest = application.requirements_digest.clone().unwrap_or_default(); - match openvtc_core::vetting::pcs_vta::prove( - admin_vta, - &app.application_id, - ¶ms, - &digest, - &challenge, - ) - .await - { - Ok(proof) => { - application.set_hidden_submission(proof); - None - } - Err(e) - if e.is(openvtc_core::vetting::pcs_vta::tasks::prove::v0_1::error_codes::NO_USABLE_ATTESTATION) => - { - Some(NO_ATTESTATION.to_string()) - } - Err(e) => Some(format!( - "The zero-knowledge proof of your vetting could not be built ({e}). Nothing was sent \ - — joining without it would present no vetting at all." - )), - } -} - /// Everything that stops a hidden-vetting join before the VTA is asked for a proof: the /// community's challenge it must bind, or the sentence the join fails with. fn hidden_proof_precheck( @@ -4503,14 +2027,6 @@ const NO_ATTESTATION: &str = "This community hides its vetters, and no vetter's reached this application yet, so there is nothing to prove. \ Nothing was sent."; -/// Persist the config, abstracting over the openpgp-card touch prompt. -/// Roll back a just-minted persona when a later join step fails before the -/// persona is bound to a community. The mint (`mint_persona_into`) persists the -/// persona record + runtime identity + key info *before* the submit; without -/// this, a failed join (e.g. a submit error) leaves an orphan persona in the -/// account — a spurious identity with no membership, which then confuses the -/// active-identity display. Best-effort re-save; the VTA-side keys are cleaned -/// separately via the DID manager. /// Wear `face_id` in the community's context, preview the disclosure of what /// it asks, refuse if that differs from what the holder approved, and release /// it. `Err` is the sentence the progress page shows; nothing was sent. @@ -4582,7 +2098,7 @@ async fn open_answers( } /// What the join request for `vtc_did` as `pick` will carry, read from the -/// same records `run_join_sequence` builds it from: the persona's DID, the +/// same records the launch builds it from: the persona's DID, the /// invitation chosen, the application's statements or hidden proof, and the /// face's approved answers. fn join_review( @@ -4717,7 +2233,7 @@ fn vetting_decision(config: &Config, vtc_did: &str, app: &Application) -> Option } /// The vetting a join by `persona_id` to `vtc_did` will present, whatever is -/// chosen on the invitation page — `run_join_sequence` attaches it either way. +/// chosen on the invitation page — the launch attaches it either way. fn vetting_in_hand( config: &Config, vtc_did: &str, @@ -4786,67 +2302,12 @@ async fn load_shown(answers: &mut JoinAnswers, client: &VtaClient) { } } -fn rollback_minted_persona( - config: &mut Config, - persona_id: PersonaId, - state: &State, - profile: &str, - prior_friendly_name: &str, -) { - sequence::forget_minted_persona( - config, - persona_id, - state.setup.did_keys.as_ref(), - prior_friendly_name, - ); - if let Err(e) = save_config(config, profile) { - debug!("persona rollback re-save failed after a failed join: {e}"); - } -} - -fn save_config(config: &Config, profile: &str) -> Result<(), openvtc_core::errors::OpenVTCError> { - config.save( - profile, - #[cfg(feature = "openpgp-card")] - &|| { - eprintln!("Touch confirmation needed for decryption"); - }, - ) -} - -/// Race a sequence future against the interrupt channel (R15). -/// -/// Returns `None` if `sequence` completed first, or `Some(interrupted)` if an -/// interrupt arrived while it was still running — in which case `sequence` is -/// DROPPED (cancelled at its current `.await` point) by the `select!`. Dropping -/// the future is what makes Ctrl-C / Exit take effect within ~1 s even while a -/// network await is parked; the caller is responsible for any state cleanup the -/// dropped future may have left behind (e.g. a persisted-but-unbound persona). -async fn race_against_interrupt( - sequence: F, - interrupt_rx: &mut broadcast::Receiver, -) -> Option -where - F: std::future::Future, -{ - tokio::select! { - () = sequence => None, - Ok(interrupted) = interrupt_rx.recv() => Some(interrupted), - } -} - #[cfg(test)] mod tests { - //! R15: these tests cover the *select-against-interrupt wiring* in - //! isolation — i.e. that an interrupt delivered while the join sequence is - //! still running wins the race, drops the sequence future, and surfaces the - //! interrupt. The full end-to-end cancel-safety property (a Ctrl-C against a - //! live/unreachable VTA leaves no persisted-but-unbound persona) needs a real - //! `StateHandler` + `TDK` + VTA session and is NOT unit-testable here; it is - //! covered by manual verification and the in-code rollback at the cancel site - //! (`join_flow` → `rollback_minted_persona` when `!persona_referenced`). - - use super::race_against_interrupt; + //! The join's pure page logic and its bounded helpers. Cancel safety — a + //! Ctrl-C or F10 mid-launch undoing what the launch made (R15) — is the + //! driver's, and is tested there against injected failures + //! (`driver::tests`). /// A community that asks the applicant about themselves stops the join on /// the Answers page before anything is minted or sent, with the launch @@ -4978,23 +2439,19 @@ mod tests { assert!(!given.consent_for("did:web:another.example")); assert!(!given.answered_for("did:web:another.example")); } + use super::driver::applicant_listener; use super::{ is_duplicate_membership, joined_session, load_pasted_vic, parse_pasted_vic, - start_persona_listener, stash_verified_vic, validate_join_input, + stash_verified_vic, validate_join_input, }; - use crate::Interrupted; use crate::state_handler::dispatch_util::test_config; use crate::state_handler::join::JoinState; use crate::state_handler::setup_sequence::MessageType; use crate::state_handler::state::State; - use crate::state_handler::{StartingMode, StateHandler}; use affinidi_tdk::{TDK, common::config::TDKConfig}; use openvtc_core::config::account::{CommunityRecord, CommunityStatus, PersonaId}; use openvtc_core::didcomm::Messaging; use serde_json::json; - use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; - use tokio::sync::broadcast; // ---- Pure-decision tests (peeled out of the join sequence) ---- @@ -5206,65 +2663,6 @@ mod tests { assert_eq!(joined.vtc_did, "did:vtc:c"); } - #[tokio::test] - async fn completes_when_no_interrupt() { - let (_tx, mut rx) = broadcast::channel::(4); - let ran = Arc::new(AtomicBool::new(false)); - let ran2 = ran.clone(); - let outcome = race_against_interrupt( - async move { - ran2.store(true, Ordering::SeqCst); - }, - &mut rx, - ) - .await; - assert!(outcome.is_none(), "no interrupt → sequence wins the race"); - assert!( - ran.load(Ordering::SeqCst), - "sequence future ran to completion" - ); - } - - #[tokio::test] - async fn interrupt_cancels_pending_sequence() { - let (tx, mut rx) = broadcast::channel::(4); - // Deliver the interrupt before the race so the recv arm is immediately - // ready; the sequence is a never-completing future, so the only way to - // return is via the interrupt arm dropping it. - tx.send(Interrupted::UserInt).expect("send interrupt"); - let completed = Arc::new(AtomicBool::new(false)); - let completed2 = completed.clone(); - let outcome = race_against_interrupt( - async move { - std::future::pending::<()>().await; - // Unreachable: the future is dropped at the await above. - completed2.store(true, Ordering::SeqCst); - }, - &mut rx, - ) - .await; - assert!( - matches!(outcome, Some(Interrupted::UserInt)), - "interrupt wins and is surfaced: {outcome:?}" - ); - assert!( - !completed.load(Ordering::SeqCst), - "pending sequence future was dropped, not run to completion" - ); - } - - #[tokio::test] - async fn surfaces_os_sigint_variant() { - let (tx, mut rx) = broadcast::channel::(4); - tx.send(Interrupted::OsSignal("SIGINT")) - .expect("send interrupt"); - let outcome = race_against_interrupt(std::future::pending::<()>(), &mut rx).await; - assert!( - matches!(outcome, Some(Interrupted::OsSignal("SIGINT"))), - "the specific interrupt variant propagates: {outcome:?}" - ); - } - // ---- `load_pasted_vic`: the paste row on the invitation step ---- const COMMUNITY: &str = "did:webvh:example.com:community"; @@ -5547,10 +2945,10 @@ mod tests { // ---- Pre-submit persona connect ---- // - // What `start_persona_listener` returns is an *ownership claim*: the cancel - // and submit-failure paths remove whatever id they are handed, so handing - // back one this call did not install would tear down a socket serving - // something else. Both no-op paths are covered here; the install-and-connect + // What `applicant_listener` returns is an *ownership claim*: the launch's + // saga records it, and a cancel or failure removes whatever it recorded, so + // handing back one the launch should not install would tear down a socket + // serving something else. Both no-op paths are covered here; the install-and-connect // path needs a live mediator and is covered by the messaging e2e suite. /// Offline TDK — no environment load, no network. @@ -5570,14 +2968,10 @@ mod tests { /// nothing is claimed — and a cancel has nothing to remove. #[tokio::test] async fn without_a_messaging_service_nothing_is_claimed() { - let (handler, _state_rx) = StateHandler::new("test", StartingMode::NotSet); - let mut state = State::default(); let config = test_config(); let tdk = test_tdk().await; - let claimed = start_persona_listener( - &handler, - &mut state, + let claimed = applicant_listener( None, &config, &tdk, @@ -5594,17 +2988,13 @@ mod tests { /// on regardless — an applicant that cannot connect still gets admitted. #[tokio::test] async fn an_unresolvable_persona_is_not_claimed() { - let (handler, _state_rx) = StateHandler::new("test", StartingMode::NotSet); - let mut state = State::default(); // `test_config` has no identities, so any persona id is unresolvable. let config = test_config(); let tdk = test_tdk().await; let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel(); let service = Messaging::start(event_tx); - let claimed = start_persona_listener( - &handler, - &mut state, + let claimed = applicant_listener( Some(&service), &config, &tdk, diff --git a/openvtc/src/state_handler/join_flow/driver.rs b/openvtc/src/state_handler/join_flow/driver.rs new file mode 100644 index 00000000..3994b272 --- /dev/null +++ b/openvtc/src/state_handler/join_flow/driver.rs @@ -0,0 +1,3207 @@ +//! The join flow, driven by the loop that owns it (#529). +//! +//! The join used to be a nested `tokio::select!` loop the runtime awaited +//! inline, so while the join screen was up every other arm — saves, VTA +//! supervision, inbound messages, timers — was parked. It is now a state +//! machine: [`JoinDriver`] holds the open join (a [`JoinSession`]) and each of +//! its methods does one step and returns. +//! +//! - **Actions** reach [`JoinDriver::on_action`] from the owning loop's action +//! arm. The pure page logic runs there, on the loop, unchanged. +//! - **Long network work** runs as an off-loop job on +//! [`DispatchDomain::Join`], one at a time. Its result comes back as +//! [`DispatchOutcome::Join`] carrying the job's generation, which +//! [`JoinDriver::on_outcome`] matches against the job it is waiting for (an +//! aborted or superseded job's result is ignored), and its progress lines as +//! [`ProgressUpdate::Join`], applied by [`JoinDriver::on_progress`]. +//! - **While a job is in flight** the join actions are queued and replayed, in +//! order, once it has finished — what the old loop's channel did — except +//! the ways out: `JoinCancel`, `Exit` and `UXError` abort the job at once. +//! - **A launch** is a saga ([`JoinSaga`]): off-loop stages with on-loop +//! applies between them, each recording what it did. Every way a launch can +//! stop short — a failed stage, a cancel, an interrupt — undoes it from +//! [`JoinSaga::compensations`], so what is rolled back no longer depends on +//! where an `.await` happened to be when a future was dropped. +//! +//! Short, bounded sends (a manifest or challenge question, a wallet-session +//! request) and the VTA reads behind the Answers page stay inline awaits on +//! the loop, as they are everywhere else in the runtime. +//! +//! Everything that touches the network, the disk or a live messaging account +//! goes through [`JoinIo`], so a test can drive a whole launch offline and fail +//! it at any stage. + +use std::{collections::VecDeque, future::Future, pin::Pin, sync::Arc}; + +use affinidi_tdk::{did_resolver::DIDCacheClient, secrets_resolver::secrets::Secret}; +use openvtc_core::config::{PeerTransports, PersonaDIDKeys, keys::SubjectSigner}; +use openvtc_core::didcomm::{ListenerSpec, MessagingTransport}; +use openvtc_core::errors::OpenVTCError; +use openvtc_core::join::{ + SubjectLinkage, + saga::{Compensation, JoinSaga}, + sequence::{NotLearned, OverHttp, PreparedSubmit, SubmitError}, +}; +use openvtc_core::persona::join_answers::Asked; +use openvtc_core::vetting::hidden::HiddenParams; +use tokio::sync::{ + mpsc::{UnboundedReceiver, UnboundedSender}, + watch, +}; +use tracing::error; +use vta_sdk::protocols::join_requests::JoinRequestAttribute; + +use super::*; +use crate::state_handler::{ + background_dispatch::{DispatchDomain, DispatchOutcome, InFlight, ProgressUpdate}, + save_coalesce::SaveScheduler, + setup_sequence::SetupState, +}; + +/// A boxed future, as [`JoinIo`] returns them. +pub(crate) type IoFuture<'a, T> = Pin + Send + 'a>>; + +/// Everything a join step acts on, lent by the loop that owns the join. +/// +/// The runtime loop lends its own; the State-A loop lends what it has, with +/// `messaging` its listener-less service. +pub(crate) struct JoinCtx<'a> { + pub state: &'a mut State, + pub config: &'a mut Config, + pub tdk: &'a TDK, + pub admin_vta: Option<&'a VtaClient>, + pub messaging: Option<&'a Messaging>, + pub profile: &'a str, + pub save: &'a mut SaveScheduler, + pub dispatch_tx: &'a UnboundedSender, + pub in_flight: &'a mut InFlight, + pub state_tx: &'a watch::Sender>, +} + +/// What a join step asks of the loop that owns it. +pub(crate) enum JoinStep { + /// The join goes on: nothing for the loop to do. + Continue, + /// User cancelled / finished — return to the main page and resume the + /// caller's loop. Carries the just-joined session when a join succeeded, so + /// the runtime loop can register it + start its listener live (R-B-5). + Returned(Option), + /// The community was asked what it requires. The caller keeps the join + /// screen up, waits for the answer, and enters the flow again + /// ([`AwaitingRequirements`]). + AwaitRequirements(AwaitingRequirements), + /// The launch is parked on the challenge its hidden-vetting proof binds; + /// the caller hears the answer and enters the flow again + /// ([`AwaitingChallenge`]). + AwaitChallenge(AwaitingChallenge), + /// The launch is parked on a wallet session ([`AwaitingWallet`]). + AwaitWallet(AwaitingWallet), + /// Application is exiting (Exit / UXError / interrupt). + Exit(Interrupted), +} + +/// A finished off-loop join job, as it comes back over the dispatch channel. +pub(crate) struct JoinOutcome { + generation: u64, + result: JobResult, +} + +/// What a job brought back. +enum JobResult { + Resolved(Result), + Discovered(Box), + Reached(Reached), + Context(Result), + Minted(Box), + Invited(Invited), + Presented(Presented), + Submitted(Submitted), + /// The job panicked and brought back nothing. + Panicked, +} + +/// Where a running job says what it is doing: the join page's log, as it +/// happens rather than when the job ends. +#[derive(Clone)] +pub(crate) struct Progress { + tx: UnboundedSender, + generation: u64, +} + +impl Progress { + /// Say `line` on the join page. + pub(crate) fn say(&self, line: impl Into) { + let _ = self + .tx + .send(DispatchOutcome::Progress(ProgressUpdate::Join { + generation: self.generation, + line: line.into(), + })); + } +} + +// --------------------------------------------------------------------------- +// The I/O seam +// --------------------------------------------------------------------------- + +/// What reading a community needs: its invitations, its manifest, its DID +/// document — any of them. +pub(crate) struct DiscoverReq { + pub resolver: DIDCacheClient, + pub admin_vta: Option, + pub vtc_did: String, + /// Collect the community's usable invitations, with the one loaded by hand + /// (if any). `None` when they are already collected. + pub invitations: Option>, + /// Read the manifest from the community's REST endpoint. + pub manifest: bool, + pub document: DocumentWhen, +} + +/// When to resolve a community's DID document, to learn how it signs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DocumentWhen { + Never, + Always, + /// Only when the manifest could not be read: a read manifest records it. + IfManifestFails, +} + +/// What reading a community found. +#[derive(Default)] +pub(crate) struct Discovered { + pub invitations: Option>, + pub manifest: Option>, + pub document: Option, +} + +/// A launch's first stage: how the community is named, and what it offers. +pub(crate) struct ReachReq { + pub resolver: DIDCacheClient, + pub vtc_did: String, +} + +pub(crate) struct Reached { + pub display_name: Option, + pub transports: PeerTransports, +} + +/// The community's context at the VTA, made if it is missing. +pub(crate) struct ContextReq { + pub admin_vta: VtaClient, + pub top_context_id: String, + pub context_id: String, + pub name: String, +} + +/// The four VTA calls that mint a persona. +pub(crate) struct MintReq { + pub admin_vta: VtaClient, + pub tdk: TDK, + pub context_id: String, + pub presentation: vta_sdk::client::DidPresentation, +} + +/// What the mint got as far as making, and why it stopped if it did. +#[derive(Default)] +pub(crate) struct MintedParts { + pub keys: Option, + pub update: Option<(Secret, Secret)>, + pub did: Option, + pub failure: Option, +} + +/// Bring the applicant online and settle the invitation to present. +pub(crate) struct InviteReq { + pub messaging: Option, + /// The listener to install — `None` when there is none to install (no + /// service, or one already serving this persona). + pub listener: Option, + pub admin_vta: VtaClient, + pub resolver: DIDCacheClient, + pub vtc_did: String, + /// The person chose to present an invitation. + pub present: bool, + /// The invitation loaded by hand, if any. + pub loaded: Option, + /// The loaded invitation's subject when it is none of our personas. + pub foreign: Option, +} + +pub(crate) struct Invited { + /// Whether the listener came up; `None` when none was installed. + pub listener_up: Option, + /// The invitation to present, verified. + pub presentable: Option, +} + +/// The VTA work of presenting: the subject-linkage proof, the answers from a +/// face, the hidden-vetting proof. +pub(crate) struct PresentReq { + pub admin_vta: VtaClient, + pub linkage: Option, + pub answers: Option, + pub hidden: Option, +} + +pub(crate) struct LinkagePlan { + pub signer: SubjectSigner, + pub subject: String, + pub vic_id: String, + pub presenter: String, + /// What the join page says once the proof is built. + pub said: String, +} + +pub(crate) struct AnswersPlan { + pub context_id: String, + pub persona_did: String, + pub community_did: String, + pub asked: Vec, + pub face_id: String, + pub approved: Vec<(String, serde_json::Value)>, +} + +/// A hidden-vetting launch's proof: refused before the VTA is asked, or asked +/// for. +pub(crate) enum HiddenPlan { + Refuse(String), + Prove { + application_id: String, + params: Box, + digest: String, + challenge: String, + }, +} + +pub(crate) struct Presented { + pub linkage: Option, + pub attributes: Vec, + pub proof: Option, + /// Why the join stops here; nothing was sent. + pub failure: Option, +} + +/// The send. +pub(crate) struct SubmitReq { + pub messaging: Option, + pub vtc_did: String, + pub applicant_did: String, + pub mediator: String, + pub transports: PeerTransports, + /// What the send needs from the config, or why it could not be read — + /// said only after the wire is settled, as it always was. + pub prepared: Result, SubmitError>, + pub presentation: openvtc_core::join::JoinPresentation, +} + +pub(crate) struct Submitted { + pub transport: MessagingTransport, + pub online: bool, + pub request: Result, +} + +/// Everything the join does that touches the network, the disk or a live +/// messaging account. [`LiveIo`] in production; a test supplies its own. +/// +/// The `'static` methods are the off-loop jobs. The borrowing ones run on the +/// loop, where they always did: they are here only because they need an ATM, +/// a keyring or a mediator that a test does not have. +pub(crate) trait JoinIo: Send + Sync { + fn resolve( + &self, + resolver: DIDCacheClient, + name: String, + ) -> IoFuture<'static, Result>; + fn discover(&self, req: DiscoverReq) -> IoFuture<'static, Discovered>; + fn reach(&self, req: ReachReq, progress: Progress) -> IoFuture<'static, Reached>; + fn context(&self, req: ContextReq) -> IoFuture<'static, Result>; + fn mint(&self, req: MintReq, progress: Progress) -> IoFuture<'static, MintedParts>; + fn invite(&self, req: InviteReq, progress: Progress) -> IoFuture<'static, Invited>; + fn present(&self, req: PresentReq, progress: Progress) -> IoFuture<'static, Presented>; + fn submit(&self, req: SubmitReq, progress: Progress) -> IoFuture<'static, Submitted>; + + /// Write a just-minted persona into the account (and save it). + fn persist_mint<'a>( + &'a self, + config: &'a mut Config, + setup: &'a SetupState, + tdk: &'a TDK, + profile: &'a str, + ) -> IoFuture<'a, anyhow::Result>; + /// Whether an ATM is up to send with. + fn can_message(&self, tdk: &TDK) -> bool; + /// A persona's messaging identity: the DID it sends as and its mediator. + fn identity(&self, config: &Config, persona: PersonaId) -> Option<(String, String)>; + /// The listener to install for the applicant, when there is one to install. + fn listener_spec<'a>( + &'a self, + messaging: Option<&'a Messaging>, + config: &'a Config, + tdk: &'a TDK, + persona: PersonaId, + did: &'a str, + ) -> IoFuture<'a, Option>; + /// Read what the send needs out of the config. + fn prepare_submit<'a>( + &'a self, + config: &'a Config, + tdk: &'a TDK, + persona: PersonaId, + ) -> IoFuture<'a, Result, SubmitError>>; + fn remove_listener<'a>(&'a self, messaging: &'a Messaging, id: &'a str) -> IoFuture<'a, ()>; + /// Persist the config now. + fn save<'a>( + &'a self, + save: &'a mut SaveScheduler, + config: &'a Config, + ) -> IoFuture<'a, Result<(), OpenVTCError>>; +} + +/// The join's I/O, for real. +pub(crate) struct LiveIo; + +impl JoinIo for LiveIo { + fn resolve( + &self, + resolver: DIDCacheClient, + name: String, + ) -> IoFuture<'static, Result> { + Box::pin(async move { + openvtc_core::agent_name::resolve_identifier(&resolver, &name) + .await + .map_err(|e| e.to_string()) + }) + } + + fn discover(&self, req: DiscoverReq) -> IoFuture<'static, Discovered> { + Box::pin(async move { + let DiscoverReq { + resolver, + admin_vta, + vtc_did, + invitations, + manifest, + document, + } = req; + let invitations = match invitations { + Some(loaded) => Some( + collect_available_vics( + loaded.as_ref(), + admin_vta.as_ref(), + &resolver, + &vtc_did, + ) + .await, + ), + None => None, + }; + let manifest = if manifest { + Some(sequence::fetch_over_http(&resolver, &vtc_did).await) + } else { + None + }; + let resolve = match document { + DocumentWhen::Never => false, + DocumentWhen::Always => true, + DocumentWhen::IfManifestFails => matches!(manifest, Some(Err(_))), + }; + let document = if resolve { + sequence::resolve_document(&resolver, &vtc_did).await + } else { + None + }; + Discovered { + invitations, + manifest, + document, + } + }) + } + + fn reach(&self, req: ReachReq, _progress: Progress) -> IoFuture<'static, Reached> { + Box::pin(async move { + // Resolve the community's verified agent name for display + // (best-effort). A verified name (`example.com/@acme`) is the + // community's human-readable handle; `None` when it has no + // verifiable name, so the sub-context derivation falls back to the + // DID-derived token (D9). + let display_name = + openvtc_core::agent_name::resolve_verified_name(&req.resolver, &req.vtc_did).await; + // Preflight the community's transports, before anything is minted. + let transports = openvtc_core::config::peer_messaging_transports(&req.vtc_did).await; + Reached { + display_name, + transports, + } + }) + } + + fn context(&self, req: ContextReq) -> IoFuture<'static, Result> { + Box::pin(async move { + community_context::ensure_context( + &req.admin_vta, + &req.top_context_id, + &req.context_id, + &req.name, + ) + .await + .map_err(|e| e.to_string()) + }) + } + + fn mint(&self, req: MintReq, progress: Progress) -> IoFuture<'static, MintedParts> { + Box::pin(async move { + let MintReq { + admin_vta, + tdk, + context_id, + presentation, + } = req; + let mut parts = MintedParts::default(); + // Every key and the DID in the community's context. + match vta::create_persona_keys(&admin_vta, Some(&context_id)).await { + Ok(keys) => parts.keys = Some(keys), + Err(e) => { + parts.failure = Some(format!("Failed to create persona keys: {e}")); + return parts; + } + } + // WebVH update keys. + progress.say("Creating DID update keys…"); + match vta::create_update_keys(&admin_vta, Some(&context_id)).await { + Ok(update) => parts.update = Some(update), + Err(e) => { + parts.failure = Some(format!("Failed to create update keys: {e}")); + return parts; + } + } + // Pick the first WebVH server. Serverless mint is a deliberate follow-up. + progress.say("Finding a DID hosting server…"); + let server_id = match vta::list_webvh_servers(&admin_vta).await { + Ok(servers) => match servers.into_iter().next() { + Some(s) => s.id, + None => { + parts.failure = Some( + "No WebVH server available from the VTA (serverless mint not yet supported)." + .to_string(), + ); + return parts; + } + }, + Err(e) => { + parts.failure = Some(format!("Failed to list WebVH servers: {e}")); + return parts; + } + }; + // Create the persona did:webvh via the server (auto-assigned path). + progress.say(format!("Creating persona DID via {server_id}…")); + match vta::create_did_via_server( + &admin_vta, + &tdk, + &context_id, + &server_id, + WebvhPathMode::AutoAssign, + presentation, + ) + .await + { + Ok(minted) => parts.did = Some(minted), + Err(e) => parts.failure = Some(format!("Failed to create persona DID: {e}")), + } + parts + }) + } + + fn invite(&self, req: InviteReq, progress: Progress) -> IoFuture<'static, Invited> { + Box::pin(async move { + let InviteReq { + messaging, + listener, + admin_vta, + resolver, + vtc_did, + present, + loaded, + foreign, + } = req; + // Open the applicant's mailbox before knocking on the door. The VTC + // decides a well-formed invited join in under a second and pushes + // the VMC + role VEC straight back, and a mediator only + // *live-streams* a message whose recipient is connected at the + // instant it lands. Connecting first closes that window at the only + // end we control; the submit waits, bounded, for the socket + // (`applicant_online`). + // + // Failure is never fatal. A join whose applicant could not connect + // is still a valid join: the reply waits in the mailbox for the + // persona's listener to pick up (R6.4: the operator is told what + // happened, not handed one generic hint). + let listener_up = match (messaging.as_ref(), listener.as_ref()) { + (Some(service), Some(spec)) => Some( + match openvtc_core::didcomm::add_listener(service, spec).await { + Err(e) => { + progress.say(format!( + "Couldn't open this persona's mediator session ({e}) — submitting \ + anyway; the community's reply will be collected when it connects." + )); + false + } + Ok(()) => { + progress.say("Connecting the new persona to its mediator…"); + true + } + }, + ), + _ => None, + }; + // #2: resolve the VIC to present for THIS community. A VIC's issuer + // is the community's VTC DID, so a presentable invitation must + // match `vtc_did` and be unexpired. An explicitly loaded VIC is + // still stored in the vault regardless (its durable home), but only + // *presented* when it matches; otherwise fall back to a + // community-matched VIC the vault already holds, else submit as an + // open request. All vault calls are best-effort. + let mut presentable: Option = None; + if present { + if let Some(vic) = loaded { + if let Err(e) = admin_vta.cred_vault_receive(vic.clone(), None).await { + debug!(error = %e, "storing invitation in the VTA vault failed (continuing)"); + } + if !openvtc_core::join::invitation_matches_community(&vic, &vtc_did) { + progress.say( + "Loaded invitation is for a different community — \ + looking for one that matches…", + ); + } else if openvtc_core::join::invitation_is_expired(&vic, Utc::now()) { + progress.say("Loaded invitation has expired — looking for a valid one…"); + } else { + presentable = Some(vic); + } + } + if presentable.is_none() { + presentable = load_invitation_from_vault(&admin_vta, &vtc_did).await; + } + } else if let Some(vic) = loaded { + // The operator chose to join *without* an invitation. Still + // store a loaded VIC in the vault but present nothing — this is + // what honours the choice over the vault fallback above. + if let Err(e) = admin_vta.cred_vault_receive(vic, None).await { + debug!(error = %e, "storing invitation in the VTA vault failed (continuing)"); + } + match foreign { + // Not a choice the operator made: say what stopped the invitation. + Some(subject) => progress.say(format!( + "Your invitation names {}, which is not one of your personas — \ + the community accepts it only from that DID, so this is an open \ + request (awaiting approval).", + shorten_did(&sanitize_display(&subject, 256), 48) + )), + None => progress.say( + "Joining without an invitation — submitting an open request (awaiting approval).", + ), + } + } + // Completeness gate before presenting: a VIC resolved from the vault + // may predate the ingest-time validation. An incomplete VIC is + // unusable — the VTC can't extract it and silently refers the join to + // a moderator — so drop it and fall to an open request with a reason. + if let Some(vic) = &presentable + && let Err(why) = + openvtc_core::join::verify_invitation_credential(vic, &resolver, Utc::now()) + .await + { + progress.say(format!( + "Resolved invitation is not usable ({why}) — submitting as an open request instead." + )); + presentable = None; + } + Invited { + listener_up, + presentable, + } + }) + } + + fn present(&self, req: PresentReq, progress: Progress) -> IoFuture<'static, Presented> { + Box::pin(async move { + let PresentReq { + admin_vta, + linkage, + answers, + hidden, + } = req; + let refused = |linkage, attributes, why: String| Presented { + linkage, + attributes, + proof: None, + failure: Some(why), + }; + // Subject-linkage (#1b): when the presenting DID differs from the + // VIC subject, prove the subject authorized this presenter (signed + // with the subject persona's key). Best-effort: a signing failure is + // logged and the join presents without it. + let linkage = match linkage { + None => None, + Some(plan) => match plan + .signer + .sign(Some(&admin_vta), &plan.vic_id, &plan.presenter) + .await + { + Ok(linkage) => { + // Presenting through another persona is a disclosure: + // the community learns that the persona it invited + // vouches for this one. + progress.say(plan.said); + Some(linkage) + } + Err(e) => { + debug!(subject = %plan.subject, error = %e, "subject-linkage proof unavailable"); + None + } + }, + }; + // The community's questions, answered from the face the holder chose + // on the Answers page — through a disclosure, so the holder's own + // history records what they told the community. + let attributes = match answers { + None => Vec::new(), + Some(a) => { + progress.say("Answering the community's questions from your face…"); + match answer_from_face( + &admin_vta, + &a.context_id, + &a.persona_did, + &a.community_did, + &a.asked, + &a.face_id, + &a.approved, + ) + .await + { + Ok(attributes) => attributes, + Err(e) => return refused(linkage, Vec::new(), e), + } + } + }; + // Under a hidden-vetting criterion the proof is built here, bound to + // a challenge of this submission's own (design §8). Every way the + // proof can be missing stops the join: submitting without it sends no + // vetting at all. + let proof = match hidden { + None => None, + Some(HiddenPlan::Refuse(why)) => return refused(linkage, attributes, why), + Some(HiddenPlan::Prove { + application_id, + params, + digest, + challenge, + }) => { + progress.say("Proving that enough vetters vetted you, without naming them…"); + match openvtc_core::vetting::pcs_vta::prove( + &admin_vta, + &application_id, + ¶ms, + &digest, + &challenge, + ) + .await + { + Ok(proof) => Some(proof), + Err(e) + if e.is(openvtc_core::vetting::pcs_vta::tasks::prove::v0_1::error_codes::NO_USABLE_ATTESTATION) => + { + return refused(linkage, attributes, NO_ATTESTATION.to_string()); + } + Err(e) => { + return refused( + linkage, + attributes, + format!( + "The zero-knowledge proof of your vetting could not be built \ + ({e}). Nothing was sent — joining without it would present \ + no vetting at all." + ), + ); + } + } + } + }; + Presented { + linkage, + attributes, + proof, + failure: None, + } + }) + } + + fn submit(&self, req: SubmitReq, progress: Progress) -> IoFuture<'static, Submitted> { + Box::pin(async move { + let SubmitReq { + messaging, + vtc_did, + applicant_did, + mediator, + transports, + prepared, + presentation, + } = req; + // Settle the wire (TSP needs both legs; `choose_submit_route` says why). + let route = sequence::choose_submit_route(&vtc_did, &mediator, &transports).await; + if let Some(note) = route.note { + progress.say(note.describe()); + } + let transport = route.transport(); + // Last gate before the request goes out: the socket started earlier + // has had the invitation + VP work to come up, so this is normally + // already true. + let online = applicant_online(messaging.as_ref(), &applicant_did, &progress).await; + let request = match prepared { + Ok(Some(prepared)) => { + sequence::submit_prepared(&prepared, &vtc_did, &route, presentation).await + } + Ok(None) => Err(SubmitError::NoMessaging), + Err(e) => Err(e), + }; + Submitted { + transport, + online, + request, + } + }) + } + + fn persist_mint<'a>( + &'a self, + config: &'a mut Config, + setup: &'a SetupState, + tdk: &'a TDK, + profile: &'a str, + ) -> IoFuture<'a, anyhow::Result> { + Box::pin(::mint_persona_into( + config, setup, tdk, profile, + )) + } + + fn can_message(&self, tdk: &TDK) -> bool { + tdk.atm.is_some() + } + + fn identity(&self, config: &Config, persona: PersonaId) -> Option<(String, String)> { + config.identities.get(&persona).map(|ident| { + ( + ident.persona_did().to_string(), + ident.mediator_did.clone().unwrap_or_default(), + ) + }) + } + + fn listener_spec<'a>( + &'a self, + messaging: Option<&'a Messaging>, + config: &'a Config, + tdk: &'a TDK, + persona: PersonaId, + did: &'a str, + ) -> IoFuture<'a, Option> { + Box::pin(applicant_listener(messaging, config, tdk, persona, did)) + } + + fn prepare_submit<'a>( + &'a self, + config: &'a Config, + tdk: &'a TDK, + persona: PersonaId, + ) -> IoFuture<'a, Result, SubmitError>> { + Box::pin(async move { + sequence::prepare_submit(config, tdk, persona) + .await + .map(Some) + }) + } + + fn remove_listener<'a>(&'a self, messaging: &'a Messaging, id: &'a str) -> IoFuture<'a, ()> { + Box::pin(messaging.remove_listener(id)) + } + + fn save<'a>( + &'a self, + save: &'a mut SaveScheduler, + config: &'a Config, + ) -> IoFuture<'a, Result<(), OpenVTCError>> { + Box::pin(save.flush(config)) + } +} + +/// The listener to install for the applicant persona, so its socket is coming +/// up while the rest of the launch runs. +/// +/// `Some` **only when the launch should install one** — that is the launch's +/// claim on it, recorded in its saga so a failure or cancel tears it down. +/// `None` covers every case where there is nothing to install, and so nothing +/// to undo: no messaging service (State A with none), a reused persona that is +/// already live (one identity, one listener — D1/D11; tearing it down on a +/// cancelled join would take another community's session with it), or a +/// persona with no listener config. +pub(super) async fn applicant_listener( + messaging: Option<&Messaging>, + config: &Config, + tdk: &TDK, + persona_id: PersonaId, + applicant_did: &str, +) -> Option { + let service = messaging?; + let listener_id = openvtc_core::didcomm::persona_listener_id(applicant_did); + if service.has_listener(&listener_id).await { + return None; + } + let spec = openvtc_core::didcomm::persona_listener_config_for(config, tdk, persona_id).await; + if spec.is_none() { + // The mint wrote this identity moments ago, so this is unexpected — but + // it costs the join nothing, so log it and carry on. + debug!( + persona = %applicant_did, + "no listener config for the applicant persona; submitting without a live session" + ); + } + spec +} + +/// Wait — bounded by [`PERSONA_CONNECT_TIMEOUT`] — for the applicant persona's +/// socket to be live, immediately before the join request goes out. +/// +/// Returns whether the applicant was live at the moment of the submit — which +/// the durable join record then states, because "sent while offline" is the one +/// fact that explains a join taking the slow (collect-on-connect) path. A +/// persona with no listener at all is nothing to wait for. +async fn applicant_online( + messaging: Option<&Messaging>, + applicant_did: &str, + progress: &Progress, +) -> bool { + let Some(service) = messaging else { + return false; + }; + let listener_id = openvtc_core::didcomm::persona_listener_id(applicant_did); + if !service.has_listener(&listener_id).await { + return false; + } + match service + .wait_connected(&listener_id, PERSONA_CONNECT_TIMEOUT) + .await + { + Ok(()) => { + progress.say("Persona connected — ready to receive the community's reply."); + true + } + Err(e) => { + // Installed but not up yet. The listener's own restart policy keeps + // working on it, so this is a slow connect, not a dead one. + debug!(listener = %listener_id, error = %e, "applicant persona not connected before submit"); + progress.say( + "The persona's mediator session is still connecting — submitting now; the \ + community's reply will be collected once it is up.", + ); + false + } + } +} + +// --------------------------------------------------------------------------- +// The driver +// --------------------------------------------------------------------------- + +/// The join flow, as a state machine its owning loop drives. +pub(crate) struct JoinDriver { + /// Whether the owning loop hears inbound messages, and so can wait for a + /// community's answer. + hears_replies: bool, + /// Bumped for every job, so a result is matched to the job that asked. + generation: u64, + session: Option, + /// Actions queued during a job that are now due, for the loop to take + /// before reading its channel again. + leftover: VecDeque, + io: Arc, +} + +/// An open join. +struct JoinSession { + hears_replies: bool, + job: Option, + launch: Option, + /// Join actions that arrived while a job was in flight. + queued: VecDeque, +} + +/// The job in flight, and what its result is for. +struct Job { + generation: u64, + abort: tokio::task::AbortHandle, + then: Then, +} + +enum Then { + /// An agent name was resolved: enter the community it names. + Enter, + /// A community was read. + Discovered { vtc_did: String, purpose: Purpose }, + /// A launch stage finished. + Stage, +} + +/// Why a community was read. +enum Purpose { + /// Entering it: decide which page answers it. `knew` is what the book knew + /// before the manifest was read again. + Enter { knew: Option }, + /// Back from waiting for its requirements. + Resume(RequirementsOutcome), + /// Only its invitations were missing: go on with the join. + ContinueJoin, + /// Only its invitations were missing: open the invitation step. + InvitationChoice(PersonaId), +} + +/// A launch in progress: what was chosen, what it has done, and what it has +/// learned on the way. +struct Launch { + vtc_did: String, + choice: IdentityPick, + context_id: String, + admin: VtaClient, + saga: JoinSaga, + /// `mint_persona_into` may overwrite `public.friendly_name`; a rollback + /// restores it. + prior_friendly_name: String, + registry_consent: bool, + directory_statement_digest: Option, + display_name: Option, + transports: PeerTransports, + /// The keys a minted persona was made with, for its rollback. + minted_keys: Option, + /// The persona presented, and its DID. + persona: Option<(PersonaId, String)>, + /// The applicant's messaging DID and mediator. + applicant: Option<(String, String)>, +} + +impl JoinDriver { + /// A driver for a loop that hears inbound messages (`hears_replies`), or + /// does not (State A). + pub(crate) fn new(hears_replies: bool) -> Self { + Self::with_io(hears_replies, Arc::new(LiveIo)) + } + + pub(crate) fn with_io(hears_replies: bool, io: Arc) -> Self { + Self { + hears_replies, + generation: 0, + session: None, + leftover: VecDeque::new(), + io, + } + } + + /// Whether a join is open — its screen up and its actions this driver's. + pub(crate) fn is_active(&self) -> bool { + self.session.is_some() + } + + /// The next action for the owning loop: one queued during a join job that + /// is now due, else the next from the channel. + pub(crate) async fn next_action( + &mut self, + action_rx: &mut UnboundedReceiver, + ) -> Option { + if let Some(action) = self.leftover.pop_front() { + return Some(action); + } + action_rx.recv().await + } + + /// Open the join flow. + pub(crate) async fn enter(&mut self, ctx: &mut JoinCtx<'_>, entry: JoinEntry) -> JoinStep { + if self.session.is_some() { + debug!("a join was entered while one was open; the open one is cancelled"); + self.cancel(ctx).await; + } + let state = &mut *ctx.state; + // Enter the flow on a fresh EnterDid page — unless this is a launch + // coming back with its challenge, which must find everything the person + // chose on the way to it exactly as they left it. + // Whichever way a parked wait ends, it has ended: Esc is no longer + // the cheap way out once the launch carries on. + state.join.waiting_on_challenge = false; + if !matches!( + entry, + JoinEntry::Challenge { .. } | JoinEntry::Wallet { .. } + ) { + state.join.reset(); + // Surface the launch-supplied invitation on the entry page (reset + // clears the transient join sub-state, so mirror the flag back in + // afterwards). + state.join.has_invitation = state.invitation_credential.is_some(); + state.join.invitation_foreign_subject = state + .invitation_credential + .as_ref() + .and_then(|vic| foreign_invitation_subject(ctx.config, vic)); + } + state.active_page = ActivePage::Join; + let hears_replies = match &entry { + JoinEntry::Fresh | JoinEntry::ForCommunity { .. } => self.hears_replies, + // Only the runtime loop waits on an answer, so only it resumes. + JoinEntry::Resume { .. } | JoinEntry::Challenge { .. } | JoinEntry::Wallet { .. } => { + true + } + }; + state.join.hears_replies = hears_replies; + self.session = Some(JoinSession { + hears_replies, + job: None, + launch: None, + queued: VecDeque::new(), + }); + let step = match entry { + JoinEntry::Fresh => JoinStep::Continue, + // The community is already chosen, so the flow opens where the DID + // entry page would have led. + JoinEntry::ForCommunity { vtc_did } => { + self.enter_community(ctx, vtc_did); + JoinStep::Continue + } + JoinEntry::Resume { vtc_did, outcome } => { + // `reset` above cleared what the asking pass collected, and the + // routes cannot be drawn without it. The manifest came over + // DIDComm, which never resolved the community's DID document. + let document = if matches!(outcome, RequirementsOutcome::Learned) + && ctx + .config + .private + .vetting + .post_quantum_key(&vtc_did) + .is_none() + { + DocumentWhen::Always + } else { + DocumentWhen::Never + }; + self.discover(ctx, vtc_did, Purpose::Resume(outcome), false, document); + JoinStep::Continue + } + JoinEntry::Challenge { awaiting, outcome } => { + match outcome { + ChallengeOutcome::Issued => { + // The stored challenge is the one this launch asked for; + // the launch takes the flag, so it serves exactly one submit. + ctx.state.join.challenge_fresh = true; + self.launch(ctx, awaiting.choice, awaiting.vtc_did, awaiting.context_id) + .await; + } + ChallengeOutcome::Unanswered(reason) => { + let name = + vetting_actions::community_display(ctx.config, &awaiting.vtc_did); + ctx.state.join.page = JoinPage::Progress; + ctx.state.join.fail(challenge_failed_words(&name, &reason)); + } + } + JoinStep::Continue + } + JoinEntry::Wallet { end } => { + ctx.state.join.processing = false; + match end { + WalletEnd::Presented => { + let join = &mut ctx.state.join; + let parked = join.wallet.as_mut().and_then(|w| { + join.wallet_outcome = + Some(WalletOutcome::Presented(w.requirements_digest.clone())); + w.parked.take() + }); + join.wallet = None; + if let Some((pick, vtc_did, context_id)) = parked { + self.launch(ctx, pick, vtc_did, context_id).await; + } + } + WalletEnd::Unfinished(why) => { + ctx.state.join.page = JoinPage::Wallet; + if let Some(wallet) = ctx.state.join.wallet.as_mut() { + wallet.phase = WalletPhase::Offered { note: Some(why) }; + } + } + } + JoinStep::Continue + } + }; + self.settle(ctx, step) + } + + /// A UI action, while the join is open. + pub(crate) async fn on_action(&mut self, ctx: &mut JoinCtx<'_>, action: Action) -> JoinStep { + let Some(session) = self.session.as_mut() else { + return JoinStep::Continue; + }; + if session.job.is_some() { + // The ways out take effect now, whatever the job is waiting on; + // everything else waits its turn, as it did in the old loop's + // channel. + let step = match action { + Action::Exit => JoinStep::Exit(Interrupted::UserInt), + Action::UXError(interrupted) => JoinStep::Exit(interrupted), + Action::JoinCancel => JoinStep::Returned(None), + action => { + session.queued.push_back(action); + return JoinStep::Continue; + } + }; + self.interrupt(ctx).await; + if let JoinStep::Returned(_) = step { + ctx.state.active_page = ActivePage::Main; + return self.settle(ctx, JoinStep::Returned(joined_session(&ctx.state.join))); + } + return self.settle(ctx, step); + } + let step = self.act(ctx, action).await; + self.settle(ctx, step) + } + + /// A join job finished. + pub(crate) async fn on_outcome( + &mut self, + ctx: &mut JoinCtx<'_>, + outcome: JoinOutcome, + ) -> JoinStep { + let JoinOutcome { generation, result } = outcome; + // Stale — aborted, or from a session that is over: the domain it held + // was freed when it was abandoned. + let Some(job) = self + .session + .as_mut() + .and_then(|s| s.job.take_if(|j| j.generation == generation)) + else { + return JoinStep::Continue; + }; + ctx.in_flight.finish(DispatchDomain::Join); + let step = match (job.then, result) { + (Then::Stage, JobResult::Panicked) => { + self.fail_and_undo(ctx, JOB_LOST).await; + JoinStep::Continue + } + (_, JobResult::Panicked) => { + ctx.state.join.fail(JOB_LOST); + JoinStep::Continue + } + (Then::Enter, JobResult::Resolved(resolved)) => { + match resolved { + Ok(did) => self.enter_community(ctx, did), + Err(e) => ctx.state.join.fail(e), + } + JoinStep::Continue + } + (Then::Discovered { vtc_did, purpose }, JobResult::Discovered(found)) => { + self.discovered(ctx, vtc_did, purpose, *found).await + } + (Then::Stage, JobResult::Reached(reached)) => { + self.on_reached(ctx, reached); + JoinStep::Continue + } + (Then::Stage, JobResult::Context(context)) => { + self.on_context(ctx, context).await; + JoinStep::Continue + } + (Then::Stage, JobResult::Minted(parts)) => { + self.on_minted(ctx, *parts).await; + JoinStep::Continue + } + (Then::Stage, JobResult::Invited(invited)) => { + self.on_invited(ctx, invited); + JoinStep::Continue + } + (Then::Stage, JobResult::Presented(presented)) => { + self.on_presented(ctx, presented).await; + JoinStep::Continue + } + (Then::Stage, JobResult::Submitted(submitted)) => { + self.on_submitted(ctx, submitted).await; + JoinStep::Continue + } + _ => { + debug!("a join job's result did not match what it was started for"); + JoinStep::Continue + } + }; + self.settle(ctx, step) + } + + /// A running join job said what it is doing. + pub(crate) fn on_progress(&mut self, state: &mut State, generation: u64, line: String) { + if self + .session + .as_ref() + .and_then(|s| s.job.as_ref()) + .is_some_and(|j| j.generation == generation) + { + state.join.info(line); + } + } + + /// End the open join now — Exit, an interrupt, the loop stopping. A job in + /// flight is aborted and a launch undone; the session is over. + pub(crate) async fn cancel(&mut self, ctx: &mut JoinCtx<'_>) { + if self.session.is_none() { + return; + } + self.interrupt(ctx).await; + self.end_session(); + } + + /// A persona the join's create-persona overlay minted was just persisted + /// (through the loop's ordinary dispatch path). + /// + /// It is brought online here — in the background: the join is about to + /// offer it as a way in, and a persona that cannot send is not one. The + /// submit waits, bounded, for the socket right before the request goes + /// out, which is where being online matters. A no-op without a service. + pub(crate) async fn on_persona_minted(&mut self, ctx: &mut JoinCtx<'_>, persona_id: PersonaId) { + ctx.state.join.minted_persona = Some(persona_id); + if let Some(did) = ctx + .config + .identities + .get(&persona_id) + .map(|ident| ident.did.clone()) + { + spawn_persona_listener(ctx.messaging, ctx.config, ctx.tdk, persona_id, &did).await; + } + ctx.state + .join + .info("Connecting the new persona to its mediator…".to_string()); + } + + // ---- session bookkeeping ---- + + /// Finish a step: hand a parked launch to the loop that can hear its + /// answer, release the actions a finished job held back, and close the + /// session when the step leaves the flow. + fn settle(&mut self, ctx: &mut JoinCtx<'_>, step: JoinStep) -> JoinStep { + let Some(session) = self.session.as_mut() else { + return step; + }; + let idle = session.job.is_none(); + let step = match step { + // A launch that stopped to ask a hidden-vetting community for its + // challenge leaves the flow here, whichever page or entry started + // it: only the caller's loop hears the answer. Likewise a wallet + // session. + JoinStep::Continue if idle => { + if let Some(awaiting) = ctx.state.join.challenge_wait.take() { + JoinStep::AwaitChallenge(awaiting) + } else if let Some(awaiting) = ctx.state.join.wallet_wait.take() { + JoinStep::AwaitWallet(awaiting) + } else { + JoinStep::Continue + } + } + step => step, + }; + match step { + JoinStep::Continue => { + if idle { + let queued = std::mem::take(&mut session.queued); + for action in queued.into_iter().rev() { + self.leftover.push_front(action); + } + } + } + _ => { + if let Some(job) = session.job.take() { + job.abort.abort(); + ctx.in_flight.finish(DispatchDomain::Join); + } + self.end_session(); + } + } + step + } + + fn end_session(&mut self) { + if let Some(session) = self.session.take() { + for action in session.queued.into_iter().rev() { + self.leftover.push_front(action); + } + } + } + + fn hears_replies(&self) -> bool { + self.session.as_ref().is_some_and(|s| s.hears_replies) + } + + /// Abort the job in flight, and undo a launch that was under way. + async fn interrupt(&mut self, ctx: &mut JoinCtx<'_>) { + if let Some(job) = self.session.as_mut().and_then(|s| s.job.take()) { + job.abort.abort(); + ctx.in_flight.finish(DispatchDomain::Join); + } + if self.session.as_ref().is_some_and(|s| s.launch.is_some()) { + self.compensate(ctx).await; + self.end_launch(ctx); + ctx.state.join.completed = Completion::CompletedFail; + ctx.state.join.info( + "Join cancelled. Any partially-minted persona was rolled back; a sub-context may remain at the VTA.", + ); + ctx.state.main_page.log("Join cancelled by user."); + } + } + + /// Start a job off the loop. `make` builds its future from the I/O seam; + /// `wrap` says which result it is. + fn start( + &mut self, + ctx: &mut JoinCtx<'_>, + then: Then, + make: impl FnOnce(&dyn JoinIo, Progress) -> IoFuture<'static, T>, + wrap: fn(T) -> JobResult, + ) { + let Some(session) = self.session.as_mut() else { + return; + }; + self.generation += 1; + let generation = self.generation; + let progress = Progress { + tx: ctx.dispatch_tx.clone(), + generation, + }; + let work = make(self.io.as_ref(), progress); + if !ctx.in_flight.try_begin(DispatchDomain::Join) { + debug!("the join domain was still claimed when a join job started"); + } + let task = tokio::spawn(async move { wrap(work.await) }); + let abort = task.abort_handle(); + let tx = ctx.dispatch_tx.clone(); + tokio::spawn(async move { + let result = match task.await { + Ok(result) => result, + // Aborted: whoever aborted it has moved on and freed the domain. + Err(e) if e.is_cancelled() => return, + Err(e) => { + error!(error = %e, "join job panicked"); + JobResult::Panicked + } + }; + let _ = tx.send(DispatchOutcome::Join(Box::new(JoinOutcome { + generation, + result, + }))); + }); + session.job = Some(Job { + generation, + abort, + then, + }); + } + + fn launch_mut(&mut self) -> Option<&mut Launch> { + self.session.as_mut().and_then(|s| s.launch.as_mut()) + } + + // ---- the pages ---- + + /// One action on an idle join: the old loop's `match`, arm for arm. + async fn act(&mut self, ctx: &mut JoinCtx<'_>, action: Action) -> JoinStep { + match action { + Action::Exit => return JoinStep::Exit(Interrupted::UserInt), + Action::UXError(interrupted) => return JoinStep::Exit(interrupted), + Action::JoinCancel => { + // Leave the flow; the caller restores the main page. Hand back + // the joined session (if any) so the runtime loop can bring its + // live session up (R-B-5). + ctx.state.active_page = ActivePage::Main; + return JoinStep::Returned(joined_session(&ctx.state.join)); + } + Action::JoinPasteVic(text) => { + // #3: a pasted invitation credential — validate it is a VIC and + // stash it so the join presents it (mirrors the `--invitation + // ` launch flag). On the entry page the community isn't + // chosen yet, so only the credential shape is checked; on the + // invitation step it is, so the paste is also matched against it. + let vtc = if ctx.state.join.page == JoinPage::InvitationChoice { + ctx.state.join.pending_vtc.clone() + } else { + None + }; + ctx.state.join.messages.clear(); + load_pasted_vic(ctx.state, ctx.tdk.did_resolver(), &text, vtc.as_deref()).await; + ctx.state.join.invitation_foreign_subject = ctx + .state + .invitation_credential + .as_ref() + .and_then(|vic| foreign_invitation_subject(ctx.config, vic)); + } + Action::JoinClipboardFailed(why) => { + // Only the failure reaches the loop: `[Ctrl+V]` reads and applies + // the text where the input lives, so a pasted DID never takes a + // trip through here to be mistaken for an invitation. + ctx.state.join.messages.clear(); + ctx.state.join.messages.push(MessageType::Error(format!( + "Could not read the clipboard ({why}). Paste directly into \ + this screen instead — that works over SSH, where reading \ + the clipboard cannot." + ))); + } + Action::JoinClearVic => { + // Explicit "proceed without a VIC": drop the loaded invitation so + // it isn't presented, and flag the clear so the entry page shows + // "joining without an invitation". + ctx.state.invitation_credential = None; + ctx.state.join.has_invitation = false; + ctx.state.join.invitation_issuer = None; + ctx.state.join.invitation_foreign_subject = None; + ctx.state.join.vic_cleared = true; + ctx.state.join.messages.clear(); + } + Action::JoinSubmitVtc(vtc_did) => { + let vtc_did = match validate_join_input(&vtc_did) { + Ok(did) => did, + // Say why nothing happened. A keypress that cannot proceed + // must not be a silent no-op — that reads as a frozen screen + // (issue #29). + Err(why) => { + ctx.state.join.messages.push(MessageType::Error(why)); + return JoinStep::Continue; + } + }; + // Accept an agent name (`example.com/@acme`) in place of the VTC + // DID. Only resolve when the input actually looks like a name, + // so pasting a DID keeps its existing (no-extra-round-trip) + // path; the resolved DID is what everything downstream persists. + if openvtc_core::agent_name::looks_like_agent_name(&vtc_did) { + // Say what the wait is for: a name costs a redirect, a + // resolve and a check (bounded by IDENTIFIER_RESOLVE_TIMEOUT). + // Off the loop, so Esc and F10 are heard while it runs. + ctx.state + .join + .messages + .push(MessageType::Info(format!("Resolving {vtc_did}…"))); + let resolver = ctx.tdk.did_resolver().clone(); + self.start( + ctx, + Then::Enter, + |io, _| io.resolve(resolver, vtc_did), + JobResult::Resolved, + ); + } else { + self.enter_community(ctx, vtc_did); + } + } + Action::JoinIdentitySelect(i) => { + // Clamp to the reuse rows plus the trailing "mint" row. + ctx.state.join.identity_selected = i.min(ctx.state.join.mint_row()); + // Moving the highlight dismisses any armed warning. + ctx.state.join.reuse_confirm = None; + } + Action::JoinNewPersonaMethodCycle => { + ctx.state.join.new_persona_did_method = + next_did_method(ctx.state.join.new_persona_did_method); + } + Action::JoinIdentityChoose => { + if ctx.state.join.mint_row_selected() { + let Some(vtc_did) = ctx.state.join.pending_vtc.clone() else { + return JoinStep::Continue; + }; + ctx.state.join.present_invitation = + mint_can_present(ctx.config, ctx.state, &vtc_did); + if let Some(context_id) = + offer_contexts(ctx.state, ctx.config, IdentityPick::Mint, &vtc_did) + { + self.launch(ctx, IdentityPick::Mint, vtc_did, context_id) + .await; + } + } else if let Some(opt) = ctx + .state + .join + .persona_options + .get(ctx.state.join.identity_selected) + { + // Arm the cross-community linkage warning (D1). + ctx.state.join.reuse_confirm = Some(opt.id); + } + } + Action::JoinReuseConfirm => { + let Some(persona_id) = ctx.state.join.reuse_confirm else { + return JoinStep::Continue; + }; + // The community DID stays parked in `pending_vtc`; the + // invitation step is what launches the join now. + if ctx.state.join.pending_vtc.is_none() { + return JoinStep::Continue; + } + ctx.state.join.reuse_confirm = None; + open_invitation_choice(ctx.state, ctx.config, persona_id); + } + Action::JoinReuseCancel => { + ctx.state.join.reuse_confirm = None; + } + Action::JoinInvitationSelect(i) => { + // Rows: 0..len an invitation, len = paste, len+1 = without. + let max = ctx.state.join.invitation_without_row(); + ctx.state.join.invitation_use_selected = i.min(max); + } + Action::JoinInvitationChoose => { + let Some(vtc_did) = ctx.state.join.pending_vtc.clone() else { + return JoinStep::Continue; + }; + let Some(persona_id) = ctx.state.join.invitation_for_persona else { + return JoinStep::Continue; + }; + // The paste row loads a VIC instead of launching: it is the + // answer to "I have one, it just isn't in the vault". Read the OS + // clipboard as a convenience; the portable path is the + // terminal's own bracketed paste, which arrives as + // `JoinPasteVic` from anywhere on this page. Either way we stay + // on the step so the operator sees the loaded invitation before + // committing to it. + if ctx.state.join.invitation_use_selected == ctx.state.join.invitation_paste_row() { + ctx.state.join.messages.clear(); + match crate::clipboard::read_clipboard() { + Ok(text) => { + load_pasted_vic( + ctx.state, + ctx.tdk.did_resolver(), + &text, + Some(&vtc_did), + ) + .await; + } + Err(why) => { + ctx.state.join.messages.push(MessageType::Error(format!( + "Could not read the clipboard ({why}). Paste the \ + invitation JSON directly into this screen instead." + ))); + } + } + return JoinStep::Continue; + } + // A selected invitation row presents that VIC; the trailing row + // joins without one. + let sel = ctx.state.join.invitation_use_selected; + if let Some(av) = ctx.state.join.invitation_options.get(sel) { + ctx.state.invitation_credential = Some(av.body.clone()); + ctx.state.join.present_invitation = true; + } else { + ctx.state.invitation_credential = None; + ctx.state.join.present_invitation = false; + } + let pick = IdentityPick::Reuse(persona_id); + if let Some(context_id) = offer_contexts(ctx.state, ctx.config, pick, &vtc_did) { + self.launch(ctx, pick, vtc_did, context_id).await; + } + } + Action::JoinAnswersSelect(i) => { + if let (Some(answers), Some(client)) = + (ctx.state.join.answers.as_mut(), ctx.admin_vta) + { + answers.selected = i.min(answers.faces.len().saturating_sub(1)); + load_shown(answers, client).await; + } + } + Action::JoinAnswersChoose => { + let Some(answers) = ctx.state.join.answers.as_mut() else { + return JoinStep::Continue; + }; + let Some((face_id, face_name)) = answers.faces.get(answers.selected).cloned() + else { + answers.error = Some( + "You have no face to answer with. Make one under Identity, \ + then join again." + .into(), + ); + return JoinStep::Continue; + }; + let missing = answers.unanswered(); + if !missing.is_empty() { + answers.error = Some(format!( + "{face_name} does not show {} — the community requires it. \ + Pick another face, or add it to this one under Identity.", + missing.join(", ") + )); + return JoinStep::Continue; + } + // What the holder approves is these values, exactly. + let approved = answers + .shown + .iter() + .filter_map(|(t, v)| v.clone().map(|v| (t.clone(), v))) + .collect(); + answers.approved = Some((face_id, approved)); + let Some((pick, vtc_did, context_id)) = answers.parked.take() else { + return JoinStep::Continue; + }; + self.launch(ctx, pick, vtc_did, context_id).await; + } + Action::JoinWalletStart => { + let Some(wallet) = ctx.state.join.wallet.as_ref() else { + return JoinStep::Continue; + }; + if !matches!(wallet.phase, WalletPhase::Offered { .. }) { + return JoinStep::Continue; + } + let Some((IdentityPick::Reuse(persona), vtc_did, _)) = wallet.parked.clone() else { + return JoinStep::Continue; + }; + let digest = wallet.requirements_digest.clone(); + match ask_wallet_start( + ctx.config, + ctx.tdk, + ctx.messaging, + &vtc_did, + persona, + &digest, + ) + .await + { + Ok(awaiting) => { + if let Some(wallet) = ctx.state.join.wallet.as_mut() { + wallet.phase = WalletPhase::Opening; + } + ctx.state.join.wallet_wait = Some(awaiting); + } + Err(why) => { + if let Some(wallet) = ctx.state.join.wallet.as_mut() { + wallet.phase = WalletPhase::Offered { note: Some(why) }; + } + } + } + } + Action::JoinWalletSkip => { + let parked = ctx.state.join.wallet.as_mut().and_then(|w| { + matches!(w.phase, WalletPhase::Offered { .. }) + .then(|| w.parked.take()) + .flatten() + }); + let Some((pick, vtc_did, context_id)) = parked else { + return JoinStep::Continue; + }; + ctx.state.join.wallet = None; + ctx.state.join.wallet_outcome = Some(WalletOutcome::Skipped); + self.launch(ctx, pick, vtc_did, context_id).await; + } + Action::JoinRegistryToggle => { + if let Some(registry) = ctx.state.join.registry.as_mut() { + registry.publish = !registry.publish; + } + } + Action::JoinRegistryConfirm => { + // Either answer is an answer. What is sent is the box as it + // stands now — read again by the launch via + // `RegistryChoice::consent_for`, never cached apart from the + // page that showed it. + let Some(registry) = ctx.state.join.registry.as_mut() else { + return JoinStep::Continue; + }; + registry.confirmed = true; + let Some((pick, vtc_did, context_id)) = registry.parked.take() else { + return JoinStep::Continue; + }; + self.launch(ctx, pick, vtc_did, context_id).await; + } + Action::JoinContextSelect(i) => { + let last = ctx.state.join.context_options.len().saturating_sub(1); + ctx.state.join.context_selected = i.min(last); + ctx.state.join.messages.clear(); + } + Action::JoinContextSlug(slug) => { + ctx.state.join.context_slug = slug; + ctx.state.join.messages.clear(); + } + Action::JoinContextChoose => { + let (Some(vtc_did), Some(pick)) = ( + ctx.state.join.pending_vtc.clone(), + ctx.state.join.picked_identity, + ) else { + return JoinStep::Continue; + }; + let Some(option) = ctx + .state + .join + .context_options + .get(ctx.state.join.context_selected) + .cloned() + else { + return JoinStep::Continue; + }; + let context_id = if option.kind == ContextKind::New { + match community_context::new_context_id( + &ctx.config.account.top_context_id, + &ctx.state.join.context_slug, + |id| sequence::context_taken(ctx.config, id), + ) { + Ok(id) => id, + Err(e) => { + ctx.state.join.messages.clear(); + ctx.state + .join + .messages + .push(MessageType::Error(e.to_string())); + return JoinStep::Continue; + } + } + } else { + option.context_id + }; + self.launch(ctx, pick, vtc_did, context_id).await; + } + Action::JoinVettingRow(forward) => { + if let Some(known) = known_vetting(ctx.state) { + known.step(forward); + } + } + Action::JoinVettingTake => { + // Enter on the routes list. A route the page drew as blocked must + // say why rather than do nothing — a keypress that cannot proceed + // reads as a frozen screen (issue #29). + let Some(option) = + known_vetting(ctx.state).and_then(|known| known.selected_route().cloned()) + else { + return JoinStep::Continue; + }; + if !option.available() { + let why = option.blocked().unwrap_or_default(); + ctx.state.join.messages.clear(); + ctx.state + .join + .messages + .push(MessageType::Error(format!("{}: {why}", option.label))); + return JoinStep::Continue; + } + // A route that starts with the join doing something: do it, and + // remember what it was on the way to. That is the difference + // between being guided and being told to go and configure + // something elsewhere. + // + // A step that is merely the next page needs none of this — + // taking the route reaches it. + if option.first_step_kind() == Some(FirstStepKind::CreatePersona) { + ctx.state.join.messages.clear(); + ctx.state.join.resume_route = Some(option.route); + crate::state_handler::handle_nav_action(ctx.state, &Action::StartCreatePersona); + return JoinStep::Continue; + } + return self.take_route(ctx, option.route, option.applicant).await; + } + Action::JoinVettingApply => { + // `a`: read the highlighted row's own application. + let Some(vtc_did) = ctx.state.join.pending_vtc.clone() else { + return JoinStep::Continue; + }; + let Some(persona) = known_vetting(ctx.state).and_then(|k| k.selected_persona()) + else { + return JoinStep::Continue; + }; + ctx.state.join.messages.clear(); + match apply_for_vetting( + ctx.state, + ctx.config, + self.io.as_ref(), + ctx.save, + &vtc_did, + persona, + ) + .await + { + Ok(()) => { + ctx.state.active_page = ActivePage::Main; + return JoinStep::Returned(None); + } + Err(why) => ctx.state.join.messages.push(MessageType::Error(why)), + } + } + Action::JoinVettingJoin => { + // `j`, offered only while the community is being asked or could + // not be: there are no rows yet, so no persona has been chosen + // and none of its statements are presumed. The identity step + // asks, and the submit attaches the chosen persona's own. + let Some(vtc_did) = ctx.state.join.pending_vtc.clone() else { + return JoinStep::Continue; + }; + ctx.state.join.messages.clear(); + self.continue_join(ctx, vtc_did).await; + } + // The create-persona overlay, hosted here rather than on the main + // page. Everything except the mint is the shared pure reducer, so + // the phases and keys cannot drift from the ones the main page + // shows. + action if crate::state_handler::handle_nav_action(ctx.state, &action) => { + // A persona minted behind this page changes what the routes say, + // so they are re-derived once the overlay is gone. Cheap, + // idempotent, and it means the row that was blocked on having no + // persona unblocks itself rather than waiting for a re-entry. + if ctx.state.join.page == JoinPage::Vetting + && ctx.state.main_page.create_persona.is_none() + && let Some(vtc_did) = ctx.state.join.pending_vtc.clone() + { + show_vetting(ctx.state, ctx.config, &vtc_did); + // And carry on where the step interrupted — as the persona + // the step made, on that persona's own row. Only when the + // step actually produced one: an overlay closed with Esc + // leaves the page as it was, with the "new persona" row + // still saying what it starts with. + let minted = ctx.state.join.minted_persona; + let resumable = ctx.state.join.resume_route.and_then(|route| { + let known = known_vetting(ctx.state)?; + let row = match route { + JoinRoute::Vetting => known.row_for(Applicant::Persona(minted?))?, + other => known.row_of(other)?, + }; + let option = known.routes.get(row)?; + (option.first_step().is_none() && option.available()).then_some(( + row, + option.route, + option.applicant, + )) + }); + if let Some((row, route, applicant)) = resumable { + ctx.state.join.resume_route = None; + if let Some(known) = known_vetting(ctx.state) { + known.row = row; + } + return self.take_route(ctx, route, applicant).await; + } + } + } + // The mint itself goes through the loop's ordinary dispatch path; + // when it settles, the loop hands the persona back to + // [`JoinDriver::on_persona_minted`] while this join is open. + Action::CreatePersonaSubmit => { + crate::state_handler::spawn_persona_mint( + ctx.dispatch_tx, + ctx.in_flight, + ctx.state, + ctx.config, + ctx.tdk, + ctx.admin_vta, + ); + } + Action::CreatePersonaCopy => { + if let Some(did) = ctx + .state + .main_page + .create_persona + .as_ref() + .and_then(|o| o.did.clone()) + { + let copied = crate::clipboard::copy_to_clipboard(&did).is_ok(); + if let Some(o) = ctx.state.main_page.create_persona.as_mut() { + o.copied = copied; + } + } + } + Action::JoinVettingAskAgain => { + let Some(vtc_did) = ctx.state.join.pending_vtc.clone() else { + return JoinStep::Continue; + }; + if self.hears_replies() + && let Ok(awaiting) = + ask_requirements(ctx.state, ctx.config, ctx.tdk, ctx.messaging, &vtc_did) + .await + { + return JoinStep::AwaitRequirements(awaiting); + } + } + _ => {} + } + JoinStep::Continue + } + + /// Enter `vtc_did` into the flow: learn what it asks of the people who + /// join, and put up the page that answers it. + /// + /// Shared by the DID entry page and by a join started for a community + /// already chosen (a vetting application's), so both get the same + /// discovery rather than the entry page's alone. The reading runs off the + /// loop; [`Self::entered`] decides on it. + fn enter_community(&mut self, ctx: &mut JoinCtx<'_>, vtc_did: String) { + // Known before is not known now. A community changes what it asks, and + // a join decided on the last visit's copy shows the person a community + // that no longer exists. So the manifest is read again on every join, + // over the endpoint that needs nothing of us (bounded, R1.2). Only when + // the community cannot be reached does the stored copy stand in. + let knew = knowledge_of(ctx.config, &vtc_did); + // The re-read records how it signs; if it cannot be made, the DID + // document may still resolve on its own. + let document = if knew == Some(true) + && ctx + .config + .private + .vetting + .post_quantum_key(&vtc_did) + .is_none() + { + DocumentWhen::IfManifestFails + } else { + DocumentWhen::Never + }; + self.discover(ctx, vtc_did, Purpose::Enter { knew }, true, document); + } + + /// Read a community off the loop: its invitations when they are not + /// collected yet (whether presenting one is a way in is a fact about this + /// account, and the vetting page cannot say what the options are without + /// it), its manifest, its DID document. + fn discover( + &mut self, + ctx: &mut JoinCtx<'_>, + vtc_did: String, + purpose: Purpose, + manifest: bool, + document: DocumentWhen, + ) { + let invitations = (ctx.state.join.invitations_for.as_deref() != Some(vtc_did.as_str())) + .then(|| ctx.state.invitation_credential.clone()); + let req = DiscoverReq { + resolver: ctx.tdk.did_resolver().clone(), + admin_vta: ctx.admin_vta.cloned(), + vtc_did: vtc_did.clone(), + invitations, + manifest, + document, + }; + self.start( + ctx, + Then::Discovered { vtc_did, purpose }, + |io, _| io.discover(req), + |found| JobResult::Discovered(Box::new(found)), + ); + } + + /// A community was read: take in what was found and carry on with what it + /// was read for. + async fn discovered( + &mut self, + ctx: &mut JoinCtx<'_>, + vtc_did: String, + purpose: Purpose, + found: Discovered, + ) -> JoinStep { + let Discovered { + invitations, + manifest, + document, + } = found; + if let Some(vics) = invitations { + ctx.state.join.available_vics = vics; + ctx.state.join.invitations_for = Some(vtc_did.clone()); + } + match purpose { + Purpose::Enter { knew } => { + return self.entered(ctx, vtc_did, knew, manifest, document).await; + } + Purpose::Resume(outcome) => match outcome { + RequirementsOutcome::Unanswered(reason) => { + // We got here by sending the question, so the DID resolved: + // it is the answer that never came. + show_unknown(ctx.state, ctx.config, &vtc_did, reason, true, true); + } + outcome => { + let learned = matches!(outcome, RequirementsOutcome::Learned); + if learned + && ctx + .config + .private + .vetting + .post_quantum_key(&vtc_did) + .is_none() + && let Some(doc) = document + { + sequence::note_signing(ctx.config, &vtc_did, &doc); + } + let shown = learned && show_vetting(ctx.state, ctx.config, &vtc_did); + if !shown { + self.continue_join(ctx, vtc_did).await; + } + } + }, + Purpose::ContinueJoin => self.continue_with_invitations(ctx, vtc_did).await, + Purpose::InvitationChoice(persona) => { + open_invitation_choice(ctx.state, ctx.config, persona); + } + } + JoinStep::Continue + } + + /// Decide which page answers a community just read. + /// + /// Peer identity vetting: a community that vets says what it requires + /// before anything about the applicant is sent (vetting-process.md §6.1). + /// What the book knows decides at once. Otherwise the community is asked, + /// and the wait happens in the caller's loop — the one that hears the + /// answer — which enters this flow again. + async fn entered( + &mut self, + ctx: &mut JoinCtx<'_>, + vtc_did: String, + knew: Option, + mut manifest: Option>, + document: Option, + ) -> JoinStep { + let mut knowledge = knew; + if knowledge.is_some() { + match manifest.take() { + Some(Ok(fetched)) => { + sequence::learn_fetched(ctx.config, &vtc_did, fetched); + knowledge = knowledge_of(ctx.config, &vtc_did); + } + Some(Err(why)) => { + debug!(community = %vtc_did, reason = %why.reason(), "manifest not re-read"); + ctx.state.main_page.log(format!( + "Could not read {}'s current requirements ({}); showing what it said \ + last time.", + vetting_actions::community_display(ctx.config, &vtc_did), + why.reason() + )); + } + None => {} + } + } + match knowledge { + Some(true) => { + // The re-read above records how it signs; if it could not be + // made, the DID document may still have resolved on its own. + if ctx + .config + .private + .vetting + .post_quantum_key(&vtc_did) + .is_none() + && let Some(doc) = document + { + sequence::note_signing(ctx.config, &vtc_did, &doc); + } + show_vetting(ctx.state, ctx.config, &vtc_did); + } + // Known before, and not after reading it again: read it as a first + // visit would. + None if knew.is_some() => self.enter_community(ctx, vtc_did), + None => match manifest { + // The community's own endpoint first. It needs no mediator, no + // persona and no loop that can hear a reply, so it answers on a + // first join — where the DIDComm question cannot be asked at + // all — and it answers in one round trip everywhere else. + Some(Ok(fetched)) => { + sequence::learn_fetched(ctx.config, &vtc_did, fetched); + if !show_vetting(ctx.state, ctx.config, &vtc_did) { + // It answered, and the answer is that it does not vet. + self.continue_join(ctx, vtc_did).await; + } + } + // Nothing resolved, so there is nobody to ask by any route: a + // DIDComm question needs an endpoint the document would have + // named. Stop here rather than send a request into the dark. + Some(Err(why)) if !why.resolvable() => { + show_unknown( + ctx.state, + ctx.config, + &vtc_did, + why.reason().to_string(), + false, + false, + ); + } + Some(Err(why)) if self.hears_replies() => { + debug!(community = %vtc_did, reason = %why.reason(), "manifest not read over REST"); + if let Ok(awaiting) = + ask_requirements(ctx.state, ctx.config, ctx.tdk, ctx.messaging, &vtc_did) + .await + { + return JoinStep::AwaitRequirements(awaiting); + } + // The page now says why the question could not be sent. + } + Some(Err(why)) => { + // Both ways of asking are shut: its endpoint did not answer, + // and this loop has no inbound arm to hear a message on. Say + // so rather than sending an open request on the quiet — a + // community that vets refers that request to its moderators, + // and the applicant would never learn there was a way in they + // could have taken. + show_unknown( + ctx.state, + ctx.config, + &vtc_did, + why.reason().to_string(), + false, + true, + ); + } + None => {} + }, + Some(false) => self.continue_join(ctx, vtc_did).await, + } + JoinStep::Continue + } + + /// Take one of the community's ways in, as `applicant` when it is a + /// vetting row. + /// + /// Split out of the keypress so the join can also take a route it had to + /// interrupt: a route whose first step was creating a persona resumes here + /// once that persona exists, rather than putting the person back on the + /// list to press the same key again. + /// + /// Which persona presents statements is decided here and nowhere else on + /// this page, and only from the row's own persona: its application, ready + /// or not. The invitation and open-request rows name no persona, so they go + /// to the identity step, and the submit attaches whatever the persona + /// chosen there gathered for this community — its own, matched on its DID, + /// which the review page shows before it is sent. + async fn take_route( + &mut self, + ctx: &mut JoinCtx<'_>, + route: JoinRoute, + applicant: Option, + ) -> JoinStep { + let Some(vtc_did) = ctx.state.join.pending_vtc.clone() else { + return JoinStep::Continue; + }; + ctx.state.join.messages.clear(); + match route { + // The invitation is chosen further on, once the persona it is bound + // to is known: the invitation step lists this community's VICs for + // that persona and is where one is picked. + JoinRoute::Invitation | JoinRoute::OpenRequest => { + self.continue_join(ctx, vtc_did).await; + } + JoinRoute::Vetting => { + let ready = known_vetting(ctx.state).and_then(|k| k.ready_as(applicant)); + match (ready, applicant) { + // This persona's own statements meet the requirements: + // joining is what presents them, and they fix the persona — + // so the identity step has nothing left to ask and the flow + // goes straight to the invitation choice. + (Some(persona), _) => { + if ctx.state.join.invitations_for.as_deref() == Some(vtc_did.as_str()) { + open_invitation_choice(ctx.state, ctx.config, persona); + } else { + self.discover( + ctx, + vtc_did, + Purpose::InvitationChoice(persona), + false, + DocumentWhen::Never, + ); + } + } + // Anything else is taken by working on this persona's own + // application — carrying it on, or starting it. + (None, Some(Applicant::Persona(persona))) => { + match apply_for_vetting( + ctx.state, + ctx.config, + self.io.as_ref(), + ctx.save, + &vtc_did, + persona, + ) + .await + { + Ok(()) => { + ctx.state.active_page = ActivePage::Main; + return JoinStep::Returned(None); + } + Err(why) => ctx.state.join.messages.push(MessageType::Error(why)), + } + } + // A new persona's row starts by making it (its first step is + // taken before this is reached) and comes back on that + // persona's own row. Reaching here without one is a page out + // of step with itself; say so rather than guess. + (None, Some(Applicant::NewPersona) | None) => { + ctx.state.join.messages.push(MessageType::Error( + "Applying as a new persona starts by making it — press Enter on \ + \"Apply for vetting as a new persona\"." + .to_string(), + )); + } + } + } + } + JoinStep::Continue + } + + /// Go on with a join once any vetting has been dealt with: collect the + /// community's invitations, then choose the identity to present, or mint + /// one on a first join. + async fn continue_join(&mut self, ctx: &mut JoinCtx<'_>, vtc_did: String) { + ctx.state.join.vetting = None; + // Identity first: collect the invitations available for this community + // so each persona can be badged with its usable-invitation count, then + // let the operator pick the identity to present (the invitation choice, + // if any, follows for the chosen persona). + if ctx.state.join.invitations_for.as_deref() == Some(vtc_did.as_str()) { + self.continue_with_invitations(ctx, vtc_did).await; + } else { + self.discover( + ctx, + vtc_did, + Purpose::ContinueJoin, + false, + DocumentWhen::Never, + ); + } + } + + async fn continue_with_invitations(&mut self, ctx: &mut JoinCtx<'_>, vtc_did: String) { + // Idempotency is per-persona (R-B-9): a community may be joined as more + // than one persona, so the duplicate check happens once the identity is + // chosen (in the launch) rather than at the community level here. + let options = build_persona_options(ctx.config, &ctx.state.join.available_vics); + if options.is_empty() { + // First join — nothing to reuse; mint a fresh identity. With no + // persona to vouch for it, the new one cannot present an invitation + // (`mint_can_present` is false); the loaded one is kept so the launch + // stores it in the vault and says why it went unused. + ctx.state.join.present_invitation = mint_can_present(ctx.config, ctx.state, &vtc_did); + ctx.state.join.pending_vtc = Some(vtc_did.clone()); + if let Some(context_id) = + offer_contexts(ctx.state, ctx.config, IdentityPick::Mint, &vtc_did) + { + self.launch(ctx, IdentityPick::Mint, vtc_did, context_id) + .await; + } + } else { + ctx.state.join.pending_vtc = Some(vtc_did); + ctx.state.join.persona_options = options; + ctx.state.join.identity_selected = 0; + ctx.state.join.reuse_confirm = None; + ctx.state.join.page = JoinPage::IdentityChoice; + } + } + + // ---- the launch ---- + + /// Launch the join for the chosen identity, unless something must be asked + /// first: the community's questions, an unreadable directory statement, + /// the trust-registry consent, a wallet, a hidden-vetting challenge. Every + /// way to submit passes through here, so none can skip them. + async fn launch( + &mut self, + ctx: &mut JoinCtx<'_>, + choice: IdentityPick, + vtc_did: String, + context_id: String, + ) { + // A community that asks the applicant to tell it about themselves is + // answered before anything else happens: which face, and exactly what it + // would send. The launch waits on the Answers page and resumes from + // `JoinAnswersChoose`. + let asked = ctx.config.private.vetting.requested_attributes(&vtc_did); + let answered = ctx + .state + .join + .answers + .as_ref() + .is_some_and(|a| a.approved.is_some()); + if !asked.is_empty() && !answered { + open_answers( + ctx.state, + ctx.admin_vta, + asked, + (choice, vtc_did, context_id), + ) + .await; + return; + } + + // A directory statement the community published but this client could + // not read stops the join: the person would otherwise apply without + // seeing what joining makes visible about them (`manifest/0.4`). + if let Some(DirectoryStatement::Malformed(why)) = + ctx.config.private.vetting.directory(&vtc_did) + { + let name = vetting_actions::community_display(ctx.config, &vtc_did); + ctx.state.join.page = JoinPage::Progress; + ctx.state.join.messages.clear(); + ctx.state.join.fail(format!( + "{name} published a statement of what its member directory would show about you, and it could not be read ({}). Nothing has been sent: joining without seeing it is not offered. Ask the community to fix it, then try again.", + sanitize_display(why, 200) + )); + return; + } + + // Then the trust-registry question, last, so it is the final thing the + // person sees before the request goes out — and on every join, because + // there is no right answer to assume. + let registry_answered = ctx + .state + .join + .registry + .as_ref() + .is_some_and(|r| r.answered_for(&vtc_did)); + if !registry_answered { + let community_name = ctx.config.agent_name_for(&vtc_did).map(str::to_string); + let review = join_review(ctx.config, ctx.state, &vtc_did, &choice); + let directory = match ctx.config.private.vetting.directory(&vtc_did) { + Some(DirectoryStatement::Stated(d)) => { + Some(crate::state_handler::join::DirectoryReview { + sentences: d.describe(|label| sanitize_display(label, 64)), + statement_digest: d.statement_digest.to_string(), + }) + } + _ => None, + }; + open_registry_choice(ctx.state, community_name, (choice, vtc_did, context_id)); + if let Some(registry) = ctx.state.join.registry.as_mut() { + registry.review = Some(review); + registry.directory = directory; + } + return; + } + + // A criterion asking for a credential a wallet presents: offer to + // collect it now, before the request goes out, so the submit can name + // the criterion and the community counts the evidence it already holds. + if let Some(offer) = wallet_offer(ctx.config, ctx.state, &vtc_did, &choice) { + ctx.state.join.wallet = Some(WalletStep { + parked: Some((choice, vtc_did, context_id)), + ..offer + }); + ctx.state.join.page = JoinPage::Wallet; + ctx.state.join.processing = false; + return; + } + + // A community that hides its vetters takes a proof bound to a challenge + // it issues — once, and fresh for each attempt (see `challenge_step`). + // Asked for here, after every question the person answers, so the + // challenge is as young as it can be when the proof is built over it. + // The flow hands the wait to the loop that can hear the answer and comes + // back through `JoinEntry::Challenge`, which lands here again. + let fresh = std::mem::take(&mut ctx.state.join.challenge_fresh); + let step = challenge_step( + presented_application(ctx.config, &vtc_did, &choice), + ctx.state.join.hears_replies, + fresh, + ); + if step == ChallengeStep::Ask + && let IdentityPick::Reuse(persona) = choice + { + ctx.state.join.page = JoinPage::Progress; + ctx.state.join.completed = Completion::NotFinished; + ctx.state.join.messages.clear(); + let _ = ctx.state_tx.publish(ctx.state); + let name = vetting_actions::community_display(ctx.config, &vtc_did); + match ask_challenge(ctx.config, ctx.tdk, ctx.messaging, &vtc_did, persona).await { + Ok(document_id) => { + // Locked while the answer is awaited: the wait is bounded + // (`CHALLENGE_WAIT`), and nothing on this page can usefully + // be done in the meantime. + ctx.state.join.processing = true; + ctx.state.join.waiting_on_challenge = true; + ctx.state.join.info(format!( + "This community hides its vetters. Asking {name} for a fresh challenge \ + to bind your proof to — this gives up after {} seconds. Nothing has \ + been sent yet; Esc stops waiting.", + CHALLENGE_WAIT.as_secs() + )); + ctx.state.join.challenge_wait = Some(AwaitingChallenge { + vtc_did, + document_id, + asked_at: std::time::Instant::now(), + choice, + context_id, + }); + } + Err(reason) => ctx.state.join.fail(challenge_failed_words(&name, &reason)), + } + return; + } + + // Move to the progress page and lock input. + ctx.state.join.page = JoinPage::Progress; + ctx.state.join.processing = true; + ctx.state.join.completed = Completion::NotFinished; + ctx.state.join.messages.clear(); + ctx.state.join.info(format!("Joining {vtc_did}…")); + + // Captured before the mint so a rollback (cancel or failure) can + // restore it — `mint_persona_into` may overwrite `public.friendly_name`. + let prior_friendly_name = ctx.config.public.friendly_name.clone(); + // The person's trust-registry answer, consumed by this one submit. + // Taken, not read: a later join from the same open flow — another + // community, or a retry after a failure — asks again rather than + // inheriting it. + let registry = ctx.state.join.registry.take(); + let registry_consent = registry.as_ref().is_some_and(|r| r.consent_for(&vtc_did)); + // The directory statement the person was shown, named by its digest, + // kept with the join so a later change to the terms can be told apart. + let directory_statement_digest = registry + .filter(|r| r.community == vtc_did) + .and_then(|r| r.directory) + .map(|d| d.statement_digest); + + // The mint + join sequence needs the admin VTA session. + let Some(admin) = ctx.admin_vta.cloned() else { + ctx.state + .join + .fail("VTA session unavailable — cannot join right now."); + return; + }; + let resolver = ctx.tdk.did_resolver().clone(); + if let Some(session) = self.session.as_mut() { + session.launch = Some(Launch { + vtc_did: vtc_did.clone(), + choice, + context_id, + admin, + saga: JoinSaga::default(), + prior_friendly_name, + registry_consent, + directory_statement_digest, + display_name: None, + transports: PeerTransports::default(), + minted_keys: None, + persona: None, + applicant: None, + }); + } + self.start( + ctx, + Then::Stage, + |io, progress| io.reach(ReachReq { resolver, vtc_did }, progress), + JobResult::Reached, + ); + } + + /// Stage 1 came back: the community's name and what it offers. + fn on_reached(&mut self, ctx: &mut JoinCtx<'_>, reached: Reached) { + let Some(launch) = self.launch_mut() else { + return; + }; + // Seeded into the persisted cache so the communities list shows it + // without waiting for the background sweep. + ctx.config + .set_cached_agent_name(&launch.vtc_did, reached.display_name.clone(), Utc::now()); + ctx.state.join.display_name = reached.display_name.clone(); + launch.display_name = reached.display_name; + launch.transports = reached.transports; + // A peer that advertises no messaging service cannot be joined at all, + // and discovering that after the persona mint leaves an orphaned + // identity attached to a request nobody could receive. This is a check + // on what the document *offers*. + let Some(peer_preferred) = launch.transports.preferred() else { + let message = format!( + "This community advertises no messaging transport ({}). Its DID \ + document offers neither a TSP nor a DIDComm service, so a join \ + request cannot reach it. Nothing was created.", + ctx.state + .join + .display_name + .as_deref() + .unwrap_or(&launch.vtc_did) + ); + ctx.state.join.fail(message); + self.end_launch(ctx); + return; + }; + // What the community *offers* — not yet the wire the submit goes out + // on. That needs our own leg too, settled at the submit. + ctx.state + .join + .info(format!("Community reachable over {peer_preferred}.")); + // The community's context exists before anything is minted into it. A + // retried join reuses a context an interrupted one left behind. + let req = ContextReq { + admin_vta: launch.admin.clone(), + top_context_id: ctx.config.account.top_context_id.clone(), + context_id: launch.context_id.clone(), + name: launch + .display_name + .clone() + .unwrap_or_else(|| shorten_did(&launch.vtc_did, 48)), + }; + self.start( + ctx, + Then::Stage, + |io, _| io.context(req), + JobResult::Context, + ); + } + + /// Stage 1 done: the context is there. Choose the persona — reuse one, or + /// start minting. + async fn on_context(&mut self, ctx: &mut JoinCtx<'_>, context: Result) { + let io = Arc::clone(&self.io); + let Some(launch) = self.launch_mut() else { + return; + }; + let context_id = launch.context_id.clone(); + match context { + Ok(true) => ctx + .state + .join + .info(format!("Created context {context_id}.")), + Ok(false) => ctx.state.join.info(format!("Using context {context_id}.")), + Err(e) => { + ctx.state.join.fail(format!( + "Could not prepare context {context_id}: {e}. Nothing was created." + )); + self.end_launch(ctx); + return; + } + } + launch.saga.context_registered(context_id.clone()); + let top_context_id = ctx.config.account.top_context_id.clone(); + // Reuse an existing account persona (R-B-3) or mint a fresh, + // self-contained one (D6). Only a *minted* persona is recorded in the + // saga — a reused persona pre-exists and is never rolled back. + match launch.choice { + IdentityPick::Reuse(persona_id) => { + let Some((did, _)) = io.identity(ctx.config, persona_id) else { + ctx.state + .join + .fail("Selected persona is unavailable — cannot reuse it."); + self.end_launch(ctx); + return; + }; + // Per-persona idempotency (R-B-9): block a second live + // membership as the *same* persona, while still allowing other + // personas. + if is_duplicate_membership(ctx.config, &launch.vtc_did, persona_id) { + ctx.state.join.fail( + "Already a member of (or have a pending request for) this community as this persona.", + ); + self.end_launch(ctx); + return; + } + // A persona's keys live in one context; one minted into a + // sub-context is only ever presented from it. + if let Some(record) = ctx.config.account.personas.get(&persona_id) { + let home = community_context::persona_context(record, &top_context_id); + if community_context::is_sub_context(home, &top_context_id) + && home != context_id + { + let message = format!( + "This persona's keys live in {home}, so it can only join from there." + ); + ctx.state.join.fail(message); + self.end_launch(ctx); + return; + } + } + ctx.state.join.info(format!("Reusing persona {did}…")); + launch.persona = Some((persona_id, did)); + self.connect(ctx).await; + } + IdentityPick::Mint => { + // The persona's mediator is the account's VTA mediator: the DID + // minted via the VTA's webvh server advertises that mediator in + // its DIDComm service, so the persona listener must use the same + // one. A VTA that advertised no mediator refuses the mint here, + // before any key or DID is created at the VTA. + let Some(mediator) = setup_config::vta_mediator(ctx.config) else { + ctx.state.join.fail(setup_config::NO_VTA_MEDIATOR); + self.end_launch(ctx); + return; + }; + ctx.state.setup.custom_mediator = Some(mediator); + ctx.state + .join + .info("Creating persona keys (signing, authentication, encryption)…"); + // How the new persona is presented: the operator's choice on the + // identity page, else the account's preference (VTI-VTA-036). + let req = MintReq { + admin_vta: launch.admin.clone(), + tdk: ctx.tdk.clone(), + context_id, + presentation: openvtc_core::config::account::persona_presentation( + &ctx.config.account, + ctx.state.join.new_persona_did_method, + ), + }; + self.start( + ctx, + Then::Stage, + |io, progress| io.mint(req, progress), + |parts| JobResult::Minted(Box::new(parts)), + ); + } + } + } + + /// Stage 2 came back: what the VTA minted. Persist the persona. + async fn on_minted(&mut self, ctx: &mut JoinCtx<'_>, parts: MintedParts) { + let MintedParts { + keys, + update, + did, + failure, + } = parts; + if let Some(keys) = keys { + ctx.state.setup.did_keys = Some(keys); + } + if let Some((update, next_update)) = update { + ctx.state.setup.vta.update_secret = Some(update); + ctx.state.setup.vta.next_update_secret = Some(next_update); + } + if let Some(vta::MintedDid { + keys, + did, + document, + locator, + }) = did + { + // A persona the VTA minted without `#tsp` can never reach a + // TSP-only community — and this join may be to one. Say so here + // rather than letting it surface later as a request that goes out + // and is never answered. + if let Some(warning) = openvtc_core::config::did::tsp_advertisement_warning(&document) { + ctx.state.join.info(warning); + } + ctx.state.setup.did_keys = Some(keys); + ctx.state.setup.webvh_address.did = did; + ctx.state.setup.webvh_address.document = document; + ctx.state.setup.webvh_address.locator = locator; + } + if let Some(failure) = failure { + ctx.state.join.fail(failure); + self.end_launch(ctx); + return; + } + // A join mints the persona *unlabelled*: the community's name is not + // the persona's. Its community binding is recorded on the membership + // record and shown beside it; the label is for a name the operator + // chose. + ctx.state.setup.username = String::new(); + // `mint_persona_into` writes the persona record + runtime identity + key + // info to disk *immediately*, so from here until the community record is + // persisted the account holds a persona with no community. The saga + // records it so a cancel or later failure rolls it back. + let persona_id = match self + .io + .persist_mint(ctx.config, &ctx.state.setup, ctx.tdk, ctx.profile) + .await + { + Ok(id) => id, + Err(e) => { + ctx.state.join.fail(format!("Failed to save persona: {e}")); + self.end_launch(ctx); + return; + } + }; + let minted_keys = ctx.state.setup.did_keys.clone(); + let Some(launch) = self.launch_mut() else { + return; + }; + launch.saga.minted(persona_id); + launch.minted_keys = minted_keys; + // Where its keys live, which decides every context it can join from. + if let Some(record) = ctx.config.account.personas.get_mut(&persona_id) { + record.origin_context_id = launch.context_id.clone(); + } + let persona_did = ctx.state.setup.webvh_address.did.clone(); + ctx.state + .join + .info(format!("Persona created: {persona_did}")); + launch.persona = Some((persona_id, persona_did)); + self.connect(ctx).await; + } + + /// Stage 3, on the loop: the applicant can send, and its listener is + /// claimed — recorded in the saga *before* it is installed, so a cancel + /// while it connects still removes it. Then off the loop: install it and + /// settle the invitation. + async fn connect(&mut self, ctx: &mut JoinCtx<'_>) { + let io = Arc::clone(&self.io); + let Some(persona_id) = self + .launch_mut() + .and_then(|l| l.persona.as_ref().map(|(id, _)| *id)) + else { + return; + }; + // Submit the join request to the VTC. The persona is the sender (the + // VTC reads the applicant from the envelope). The VTC's submit-receipt + // returns asynchronously to the persona's mediator; until it does, the + // request message id is the correlation handle stored on the Pending + // record. + ctx.state.join.info("Submitting join request…"); + if !io.can_message(ctx.tdk) { + self.fail_and_undo( + ctx, + "Messaging (ATM) unavailable — cannot submit the join request.", + ) + .await; + return; + } + let Some((applicant_did, mediator)) = io.identity(ctx.config, persona_id) else { + self.fail_and_undo( + ctx, + "Persona identity unavailable after mint — cannot submit.", + ) + .await; + return; + }; + let listener = io + .listener_spec( + ctx.messaging, + ctx.config, + ctx.tdk, + persona_id, + &applicant_did, + ) + .await; + let present = ctx.state.join.present_invitation; + let loaded = ctx.state.invitation_credential.take(); + let foreign = if present { + None + } else { + loaded + .as_ref() + .and_then(|vic| foreign_invitation_subject(ctx.config, vic)) + }; + let resolver = ctx.tdk.did_resolver().clone(); + let messaging = ctx.messaging.cloned(); + let Some(launch) = self.launch_mut() else { + return; + }; + if let Some(spec) = &listener { + launch.saga.listener_up(spec.id.clone()); + } + launch.applicant = Some((applicant_did, mediator)); + let req = InviteReq { + messaging, + listener, + admin_vta: launch.admin.clone(), + resolver, + vtc_did: launch.vtc_did.clone(), + present, + loaded, + foreign, + }; + self.start( + ctx, + Then::Stage, + |io, progress| io.invite(req, progress), + JobResult::Invited, + ); + } + + /// Stage 3 came back: the listener's fate and the invitation to present. + /// Plan the presenting — the linkage proof's signer, the answers, the + /// hidden-vetting proof — on the loop, and run it off. + fn on_invited(&mut self, ctx: &mut JoinCtx<'_>, invited: Invited) { + let Some(launch) = self.launch_mut() else { + return; + }; + if invited.listener_up == Some(false) { + launch.saga.listener_failed(); + } + // Setting the presented invitation to the resolved VIC keeps the VP, + // the linkage proof, and the summary all consistent with what is + // actually presented. + let state = &mut *ctx.state; + let config = &*ctx.config; + state.invitation_credential = invited.presentable; + state.join.has_invitation = state.invitation_credential.is_some(); + state.join.presented_invitation = state.invitation_credential.as_ref().map(|vic| { + let subject = openvtc_core::join::invitation_subject(vic).map(str::to_string); + PresentedInvitation { + id: openvtc_core::join::invitation_id(vic) + .unwrap_or_default() + .to_string(), + // Verified-only, from the persisted cache: the subject is one of + // our own personas, so the sweep normally has a name for it. + subject_agent_name: subject + .as_deref() + .and_then(|s| config.agent_name_for(s)) + .map(|n| sanitize_display(n, 256)), + subject, + } + }); + // Present the holder VP. When a matching, unexpired invitation (VIC) is + // resolved it rides in the VP's `verifiableCredential` array; the VTC + // verifies it and auto-admits on a valid, trusted, unconsumed + // invitation. With none the join is an open request the community + // reviews. + if state.invitation_credential.is_some() { + state + .join + .info("Presenting your invitation credential to the community…"); + } else if state.join.present_invitation { + // Wanted to present one, but none resolved to a usable VIC. + state.join.info( + "No valid invitation for this community — \ + submitting as an open request (awaiting approval).", + ); + } + let Some((persona_id, _)) = launch.persona.clone() else { + return; + }; + let Some((applicant_did, _)) = launch.applicant.clone() else { + return; + }; + let linkage = plan_linkage(config, state, &applicant_did); + let answers = state.join.answers.as_ref().and_then(|a| { + a.approved.clone().map(|(face_id, approved)| AnswersPlan { + context_id: launch.context_id.clone(), + persona_did: applicant_did.clone(), + community_did: launch.vtc_did.clone(), + asked: a.asked.clone(), + face_id, + approved, + }) + }); + // Peer identity vetting: present the statements gathered for this + // community under this persona. Under a hidden-vetting criterion the + // proof is built off the loop; everything that stops it before the VTA + // is asked is decided here, and said after the answers, as it always + // was. + let hidden = config + .private + .vetting + .application(&launch.vtc_did, persona_id) + .filter(|a| a.join_did == applicant_did && a.hidden.is_some()) + .map(hidden_plan); + let req = PresentReq { + admin_vta: launch.admin.clone(), + linkage, + answers, + hidden, + }; + self.start( + ctx, + Then::Stage, + |io, progress| io.present(req, progress), + JobResult::Presented, + ); + } + + /// Stage 4 came back: build the presentation and read the signer, then + /// send it off the loop. + async fn on_presented(&mut self, ctx: &mut JoinCtx<'_>, presented: Presented) { + let Presented { + linkage, + attributes, + proof, + failure, + } = presented; + if let Some(failure) = failure { + self.fail_and_undo(ctx, failure).await; + return; + } + let io = Arc::clone(&self.io); + let Some(launch) = self.launch_mut() else { + return; + }; + let (Some((persona_id, _)), Some((applicant_did, mediator))) = + (launch.persona.clone(), launch.applicant.clone()) + else { + return; + }; + let vtc_did = launch.vtc_did.clone(); + if let Some(proof) = proof + && let Some(application) = ctx + .config + .private + .vetting + .application_mut(&vtc_did, persona_id) + .filter(|a| a.join_did == applicant_did && a.hidden.is_some()) + { + application.set_hidden_submission(proof); + } + let mut vp = openvtc_core::join::build_join_vp( + &applicant_did, + ctx.state.invitation_credential.as_ref(), + linkage.as_ref(), + ); + let registry_consent = launch.registry_consent; + let protocol = ctx.config.private.vetting.protocol_for(&vtc_did); + let presentation = match ctx.config.private.vetting.application(&vtc_did, persona_id) { + Some(application) if application.join_did == applicant_did => { + let statements = application.presentable_statements(Utc::now()); + if !statements.is_empty() { + ctx.state.join.info(format!( + "Presenting {} vetting statement(s) to the community…", + statements.len() + )); + } + openvtc_core::join::attach_credentials(&mut vp, statements); + openvtc_core::join::JoinPresentation { + vp, + extensions: application.join_extensions(), + attributes, + protocol, + // The vetting criterion this application was made under. + criterion: application.requirements_digest.clone(), + registry_consent, + } + } + // No application: the community decides under the first criterion + // this submission meets — an invitation, a credential it recognises, + // or review — which is the route the person chose. With wallet + // evidence, under the criterion it was collected for: the community + // counts it only there. + _ => openvtc_core::join::JoinPresentation { + attributes, + protocol, + registry_consent, + criterion: match &ctx.state.join.wallet_outcome { + Some(WalletOutcome::Presented(digest)) => Some(digest.clone()), + _ => None, + }, + ..vp.into() + }, + }; + let prepared = io.prepare_submit(ctx.config, ctx.tdk, persona_id).await; + let req = SubmitReq { + messaging: ctx.messaging.cloned(), + vtc_did, + applicant_did, + mediator, + transports: launch.transports.clone(), + prepared, + presentation, + }; + self.start( + ctx, + Then::Stage, + |io, progress| io.submit(req, progress), + JobResult::Submitted, + ); + } + + /// Stage 5 came back: the request went out, or why not. Record the + /// pending membership. + async fn on_submitted(&mut self, ctx: &mut JoinCtx<'_>, submitted: Submitted) { + let Submitted { + transport: submit_transport, + online: applicant_online, + request, + } = submitted; + let request_id = match request { + Ok(id) => id, + // A persona whose key cannot be read cannot apply. Nothing was sent; + // the persona and its listener are kept, as they always were on + // this path. + Err(SubmitError::Signer(e)) => { + ctx.state + .join + .fail(format!("Couldn't sign the application: {e}")); + self.end_launch(ctx); + return; + } + // Nothing was submitted, so nothing will reply — release the socket + // opened for this attempt and roll back a persona minted for it. + Err(e) => { + self.fail_and_undo(ctx, format!("Failed to submit join request: {e}")) + .await; + return; + } + }; + let io = Arc::clone(&self.io); + let Some(launch) = self.launch_mut() else { + return; + }; + launch.saga.submitted(); + let (Some((persona_id, persona_did)), Some((applicant_did, _))) = + (launch.persona.clone(), launch.applicant.clone()) + else { + return; + }; + let vtc_did = launch.vtc_did.clone(); + // The challenge a hidden proof was bound to is spent now: the community + // consumes it the moment it reads the proof, whatever it then decides. + // Kept, it would be bound into the next attempt's proof and refused + // there. Saved with the membership below. + if let Some(application) = ctx + .config + .private + .vetting + .application_mut(&vtc_did, persona_id) + .filter(|a| a.join_did == applicant_did) + { + application.hidden_submission_sent(); + } + // Record the pending membership and persist. + let record = sequence::pending_record( + vtc_did.clone(), + launch.display_name.clone(), + launch.context_id.clone(), + persona_id, + request_id, + Utc::now(), + submit_transport, + ); + let record = CommunityRecord { + directory_statement_digest: launch.directory_statement_digest.clone(), + ..record + }; + ctx.config.account.add_membership(record.clone()); + launch.saga.persisted(); + let registry_consent = launch.registry_consent; + if let Err(e) = io.save(ctx.save, ctx.config).await { + ctx.state + .join + .fail(format!("Failed to save community record: {e}")); + self.end_launch(ctx); + return; + } + + // Sent — refresh the communities panel and surface the relaunch prompt. + // + // "Sent", not "delivered": the send resolves `Ok` when *our own + // mediator* accepts the frame, which says nothing about whether the + // community received it (R1.1). The acknowledgement is asynchronous; + // `pending_unacknowledged` flags the record in the communities panel if + // nothing arrives within `PENDING_ACK_GRACE_SECS`, so the honest thing to + // do here is name what is still outstanding and point at where the + // answer will show up. + ctx.state.main_page.sync_from_config(ctx.config); + // Durable, unlike the ceremony commentary in `state.join`, which is + // transient UI. Whether the applicant was connected is part of that + // record: a join submitted from an unconnected persona takes the slow + // path, and an operator reading this log later cannot otherwise tell + // that from a community that never answered. + ctx.config.private.logs.insert( + LogFamily::Community, + format!( + "Join request sent to ({}) as persona ({}) over {} — Pending, not yet acknowledged.{}", + ctx.state.join.display_name.as_deref().unwrap_or(&vtc_did), + persona_did, + submit_transport, + if applicant_online { + "" + } else { + " The applicant was not connected at submit; a reply that arrives before it \ + connects is collected from the mediator rather than streamed." + } + ), + ); + ctx.state.main_page.log(format!( + "Join request sent over {submit_transport} — Pending in your Communities list, awaiting \ + the community's acknowledgement." + )); + ctx.state.join.created_community = Some(record); + ctx.state.join.created_persona_did = Some(persona_did); + ctx.state.join.registry_consent_sent = Some(registry_consent); + ctx.state.join.completed = Completion::CompletedOK; + ctx.state.join.info(format!( + "Join request sent over {submit_transport}. Waiting for the community to acknowledge it — \ + it's Pending in your Communities list, which will flag it if no response arrives." + )); + self.end_launch(ctx); + } + + /// The launch failed: say why, undo what it did, and end it. + async fn fail_and_undo(&mut self, ctx: &mut JoinCtx<'_>, why: impl Into) { + ctx.state.join.fail(why); + self.compensate(ctx).await; + self.end_launch(ctx); + } + + /// Undo the launch from its saga: remove the listener it installed, and + /// forget the persona it minted unless something already references it. + async fn compensate(&mut self, ctx: &mut JoinCtx<'_>) { + let Some(launch) = self.session.as_ref().and_then(|s| s.launch.as_ref()) else { + return; + }; + let mut forgot = false; + for compensation in launch.saga.compensations() { + match compensation { + Compensation::RemoveListener(id) => { + if let Some(messaging) = ctx.messaging { + self.io.remove_listener(messaging, &id).await; + } + } + Compensation::ForgetPersona(persona_id) => { + if !ctx.config.account.persona_referenced(&persona_id) { + sequence::forget_minted_persona( + ctx.config, + persona_id, + launch.minted_keys.as_ref(), + &launch.prior_friendly_name, + ); + forgot = true; + } + } + } + } + if forgot && let Err(e) = self.io.save(ctx.save, ctx.config).await { + debug!("persona rollback re-save failed after a failed join: {e}"); + } + } + + /// The launch is over, however it ended: input unlocks. + fn end_launch(&mut self, ctx: &mut JoinCtx<'_>) { + if let Some(session) = self.session.as_mut() { + session.launch = None; + } + ctx.state.join.processing = false; + } +} + +/// What a job that panicked leaves on the join page. +const JOB_LOST: &str = "Joining a community failed (internal error)."; + +/// What the book knows about whether `vtc_did` vets. +fn knowledge_of(config: &Config, vtc_did: &str) -> Option { + match config.private.vetting.knowledge(vtc_did) { + Knowledge::Vetting(_) => Some(true), + Knowledge::NoVetting => Some(false), + Knowledge::Unknown => None, + } +} + +/// #1b: the subject-linkage proof to build when the presenting DID differs +/// from the presented VIC's subject — the invited persona authorizes the +/// presenter. `None` on the join-as-subject path (presenter == subject), when +/// no invitation is presented, or when the subject's signer cannot be read +/// (it isn't one of our personas: we can't sign for a key we don't hold, and +/// the VTC then refuses the mismatched binding). +fn plan_linkage(config: &Config, state: &State, presenter_did: &str) -> Option { + let vic = state.invitation_credential.as_ref()?; + let subject = openvtc_core::join::invitation_subject(vic)?; + if subject == presenter_did { + return None; // join-as-subject — no linkage needed + } + let vic_id = openvtc_core::join::invitation_id(vic)?; + let signer = match config.subject_signer(subject) { + Ok(signer) => signer, + Err(e) => { + debug!(subject = %subject, error = %e, "subject-linkage proof unavailable"); + return None; + } + }; + let name = config + .agent_name_for(subject) + .map(|n| sanitize_display(n, 256)) + .unwrap_or_else(|| shorten_did(subject, 48)); + Some(LinkagePlan { + signer, + subject: subject.to_string(), + vic_id: vic_id.to_string(), + presenter: presenter_did.to_string(), + said: format!( + "The invitation names {name}, which signs for this persona — the community \ + will see that the two are linked." + ), + }) +} + +/// What a hidden-vetting application's launch does about its proof: refuse +/// with the sentence the progress page fails with, or ask the VTA for it. +fn hidden_plan(application: &Application) -> HiddenPlan { + let challenge = match hidden_proof_precheck(application) { + Ok(challenge) => challenge, + Err(refusal) => return HiddenPlan::Refuse(refusal), + }; + // Hidden parameters but no application at the VTA: no vetter's attestation + // has arrived. + let (Some(params), Some(app)) = (application.hidden.clone(), application.hidden_app.clone()) + else { + return HiddenPlan::Refuse(NO_ATTESTATION.to_string()); + }; + HiddenPlan::Prove { + application_id: app.application_id, + params: Box::new(params), + digest: application.requirements_digest.clone().unwrap_or_default(), + challenge, + } +} diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index bd591ec3..e2ba27fd 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -1136,19 +1136,24 @@ impl StateHandler { /// without a restart (hot-start). All other exits return /// [`DegradedOutcome::Exit`] after closing the admin session. /// - /// # Invariant: this loop must never own a live listener across an iteration + /// The join is driven from here exactly as the runtime loop drives it — a + /// [`join_flow::JoinDriver`] whose network work runs off the loop — with + /// the one capability this loop lacks switched off: it has no inbound arm, + /// so the driver is told it cannot hear a community's answer and never + /// waits for one. /// - /// Its `select!` has exactly two arms — `action_rx` and `interrupt_rx`. There - /// is **no inbound arm**: nothing here drains the DIDComm event channel that - /// `dispatch_inbound` feeds. A listener opened here is therefore a socket - /// whose mail the SDK acknowledges (and the mediator then deletes) while no - /// consumer runs — inbound survives only as far as the channel's 256-slot - /// buffer, and only until the runtime loop drains it. + /// # Invariant: this loop must not stay put holding a listener /// - /// So every path that opens a listener must exit via - /// [`DegradedOutcome::Joined`] in the same iteration. The `StartJoin` arm - /// enforces this for itself *and* re-checks `list_listeners()` as a backstop, - /// because the cost of getting it wrong is silent, permanent message loss + /// There is **no inbound arm**: nothing here drains the DIDComm event + /// channel that `dispatch_inbound` feeds. A listener a join opens here (the + /// applicant's, brought up before the submit; or a persona minted from the + /// join's overlay) queues its mail on that unbounded channel for as long as + /// the join is open, and only the runtime loop drains it. + /// + /// So when a join ends, a loop holding any listener exits via + /// [`DegradedOutcome::Joined`] at once. [`Self::degraded_join_step`] enforces this + /// for every way a join ends *and* re-checks `list_listeners()` as a + /// backstop, because the cost of getting it wrong is a mailbox nobody reads /// that looks like a community that never answered. /// /// `vta_supervisor` watches the admin session held in `join_ctx` and @@ -1167,9 +1172,10 @@ impl StateHandler { messaging: Option<&didcomm::Messaging>, vta_supervisor: &mut vta_recovery::AdminVtaSupervisor, ) -> Result { - // R11: the degraded loop persists only via `remove_community` (State-A - // community withdrawal); `join_flow` saves itself synchronously. Coalesce - // here too and force-flush on every exit path so a withdrawal isn't lost. + // R11: the degraded loop persists via `remove_community` (State-A + // community withdrawal) and the join's own durable points, which flush + // this scheduler. Coalesce here too and force-flush on every exit path + // so a withdrawal isn't lost. let mut save = save_coalesce::SaveScheduler::new(self.profile.clone()); // State A reaches the VTA panel too (an account with no community still // manages personas and VICs there), so it needs the same off-loop path @@ -1180,9 +1186,41 @@ impl StateHandler { let (dispatch_tx, mut dispatch_rx) = tokio::sync::mpsc::unbounded_channel::(); let mut in_flight = background_dispatch::InFlight::default(); + // The join, driven from here. This loop has no inbound arm, so the join + // cannot wait on a community's answer. + let mut join = join_flow::JoinDriver::new(false); let result = loop { tokio::select! { - Some(action) = action_rx.recv() => match action { + Some(action) = join.next_action(action_rx) => match action { + // An open join owns the screen, and with it every action. + action if join.is_active() => { + let Some(ctx) = join_ctx.as_mut() else { + continue; + }; + let step = join + .on_action( + &mut join_flow::JoinCtx { + state, + config: &mut ctx.config, + tdk: &ctx.tdk, + admin_vta: ctx.admin_vta.as_ref(), + messaging, + profile: ctx.profile.as_str(), + save: &mut save, + dispatch_tx: &dispatch_tx, + in_flight: &mut in_flight, + state_tx: &self.state_tx, + }, + action, + ) + .await; + if let Some(outcome) = self + .degraded_join_step(step, state, &mut join_ctx, messaging, terminator) + .await + { + break outcome; + } + } // Shared nav reducer first — degraded mode now routes the exact // same pure-state nav set as the runtime loop (previously these // arms were duplicated here and the VTA DID-manager nav arms were @@ -1222,119 +1260,47 @@ impl StateHandler { // page it opens on differs, so they share this arm and its // listener invariant below. Action::StartJoin | Action::StartJoinFor(_) => { - let entry = match &action { - Action::StartJoinFor(vtc_did) => join_flow::JoinEntry::ForCommunity { - vtc_did: vtc_did.clone(), - // This loop has no inbound arm, so the flow - // cannot wait here for a community's answer. - hears_replies: false, - }, - _ => join_flow::JoinEntry::Fresh { - hears_replies: false, - }, - }; - // Set when a join succeeded: the loop then breaks `Joined` - // so `run()` can start messaging. - let mut joined_a_community = false; - if let Some(ctx) = join_ctx.as_mut() { - match self - .join_flow( - action_rx, - interrupt_rx, - state, - &ctx.tdk, - &mut ctx.config, - ctx.admin_vta.as_ref(), - ctx.profile.as_str(), - // The live runtime, so the applicant persona - // is connected before the submit goes out and - // an auto-admitted join's reply is streamed - // rather than stored unread. `None` only for - // the early load-failure callers, which - // cannot reach a join anyway. - messaging, - entry, - ) - .await - { - // The joined session is ignored here: a join from - // State A flips `joined_a_community`, breaking - // `Joined` so `run()` restarts into the full pipeline, - // whose startup registration (`IdentityRegistry`) - // brings the new session up (R-B-5). Live in-loop - // registration is only needed in the runtime loop. - Ok(join_flow::JoinExit::Returned(joined)) => { - // Back on the main page; resume the degraded loop. - state.active_page = state::ActivePage::Main; - // Gated on the *join*, never on whether it minted - // the account's first identity. This used to read - // `!had_identity && …`, on the assumption that the - // only way to already hold an identity here was the - // messaging-startup-failure path. It is not: the - // `CreatePersonaSubmit` arm below mints one in this - // very loop, and `active_identity()` falls back to - // the first persona in the map — so the ordinary - // first-run order (create persona, *then* join) - // left `had_identity` true, skipped the hand-off, - // and stranded the account in a loop that has no - // inbound arm. The community's reply was ACKed by - // the SDK, deleted at the mediator, and dropped. - joined_a_community = - joined.is_some() && ctx.config.active_identity().is_some(); - } - // Neither is returned without `hears_replies`. - Ok( - join_flow::JoinExit::AwaitRequirements(_) - | join_flow::JoinExit::AwaitChallenge(_) - | join_flow::JoinExit::AwaitWallet(_), - ) => { - state.active_page = state::ActivePage::Main; - } - Ok(join_flow::JoinExit::Exit(interrupted)) => { - if let Err(e) = terminator.terminate(interrupted.clone()) { - debug!("Failed to send terminate signal: {e}"); - } - break DegradedOutcome::Exit(interrupted); - } - Err(e) => { - state - .main_page - .log_error("Join flow failed", &e); - state.active_page = state::ActivePage::Main; - } + let entry = match action { + Action::StartJoinFor(vtc_did) => { + join_flow::JoinEntry::ForCommunity { vtc_did } } - } else { + _ => join_flow::JoinEntry::Fresh, + }; + let Some(ctx) = join_ctx.as_mut() else { state .main_page .log("Cannot join: no active VTA session."); - } - // Hot-start: the borrow on `join_ctx` has ended, so take - // the context and hand it back to `run()`, which brings up - // the new persona's DIDComm listener without a restart. - // - // `holds_listener` is the backstop for the same class of - // bug the condition above fixes: this loop has no inbound - // arm, so *any* live listener it owns is a mailbox nobody - // reads. Whatever opened one — this join, or some future - // arm — the only safe move is to hand off to the runtime - // loop that drains the event channel. See the invariant on - // `run_degraded_loop`. - let holds_listener = match messaging { - Some(m) => !m.list_listeners().await.is_empty(), - None => false, - }; - if must_hand_off(joined_a_community, holds_listener, join_ctx.is_some()) { - state.main_page.log(if joined_a_community { - "Joined — starting secure messaging…" - } else { - "Starting secure messaging…" - }); let _ = self.state_tx.publish(state); - break DegradedOutcome::Joined(Box::new( - join_ctx - .take() - .expect("join_ctx present, just checked is_some"), - )); + continue; + }; + let step = join + .enter( + &mut join_flow::JoinCtx { + state, + config: &mut ctx.config, + tdk: &ctx.tdk, + admin_vta: ctx.admin_vta.as_ref(), + // The live runtime, so the applicant persona + // is connected before the submit goes out + // and an auto-admitted join's reply is + // streamed rather than stored unread. `None` + // only for the early load-failure callers, + // which cannot reach a join anyway. + messaging, + profile: ctx.profile.as_str(), + save: &mut save, + dispatch_tx: &dispatch_tx, + in_flight: &mut in_flight, + state_tx: &self.state_tx, + }, + entry, + ) + .await; + if let Some(outcome) = self + .degraded_join_step(step, state, &mut join_ctx, messaging, terminator) + .await + { + break outcome; } } Action::CreatePersonaSubmit => { @@ -1603,8 +1569,10 @@ impl StateHandler { Action::StartAddVic | Action::AddVicInput(..) | Action::AddVicPaste(..) | Action::AddVicClose => {} - // Owned by the join-flow and setup-wizard sub-loops, which - // run their own action loops and never delegate to this one. + // Join actions reach the join driver (the guard arm above) + // while a join is open; here there is none, so they are + // stale keys. The rest are the setup wizard's, which runs + // its own action loop and never delegates to this one. Action::ActivateMainMenu | Action::JoinSubmitVtc(..) | Action::JoinIdentitySelect(..) | Action::JoinIdentityChoose | Action::JoinNewPersonaMethodCycle | @@ -1679,6 +1647,47 @@ impl StateHandler { } }, Some(outcome) = dispatch_rx.recv() => { + // The join's own jobs, before the shared applier. + let outcome = match outcome { + background_dispatch::DispatchOutcome::Join(outcome) => { + if let Some(ctx) = join_ctx.as_mut() { + let step = join + .on_outcome( + &mut join_flow::JoinCtx { + state, + config: &mut ctx.config, + tdk: &ctx.tdk, + admin_vta: ctx.admin_vta.as_ref(), + messaging, + profile: ctx.profile.as_str(), + save: &mut save, + dispatch_tx: &dispatch_tx, + in_flight: &mut in_flight, + state_tx: &self.state_tx, + }, + *outcome, + ) + .await; + if let Some(outcome) = self + .degraded_join_step(step, state, &mut join_ctx, messaging, terminator) + .await + { + break outcome; + } + let _ = self.state_tx.publish(state); + continue; + } + background_dispatch::DispatchOutcome::Join(outcome) + } + background_dispatch::DispatchOutcome::Progress( + background_dispatch::ProgressUpdate::Join { generation, line }, + ) => { + join.on_progress(state, generation, line); + let _ = self.state_tx.publish(state); + continue; + } + outcome => outcome, + }; // Applying needs the config the outcome is annotated against. // It lives in `join_ctx`, which is `take`n on hand-off to the // runtime loop — a listing that lands in that window has @@ -1719,7 +1728,33 @@ impl StateHandler { // Reuses the tested hot-start path rather than telling // the operator to restart. The borrow on `join_ctx` // ends with this match, so the break below can take it. - minted_persona = matches!(settled, AfterSettle::PersonaMinted(_)); + // + // Unless the persona was minted from an open join's + // own overlay: the join offers it as a way in and + // brings it online itself, and the hand-off waits + // for the join to end (`degraded_join_step`). + if let AfterSettle::PersonaMinted(persona_id) = settled { + if join.is_active() { + join.on_persona_minted( + &mut join_flow::JoinCtx { + state, + config: &mut ctx.config, + tdk: &ctx.tdk, + admin_vta: ctx.admin_vta.as_ref(), + messaging, + profile: ctx.profile.as_str(), + save: &mut save, + dispatch_tx: &dispatch_tx, + in_flight: &mut in_flight, + state_tx: &self.state_tx, + }, + persona_id, + ) + .await; + } else { + minted_persona = true; + } + } } None => in_flight.finish(outcome.domain()), } @@ -1752,6 +1787,25 @@ impl StateHandler { } } Ok(interrupted) = interrupt_rx.recv() => { + // An open join is undone first: a launch in flight rolls + // back what it made, as a cancel does. + if join.is_active() + && let Some(ctx) = join_ctx.as_mut() + { + join.cancel(&mut join_flow::JoinCtx { + state, + config: &mut ctx.config, + tdk: &ctx.tdk, + admin_vta: ctx.admin_vta.as_ref(), + messaging, + profile: ctx.profile.as_str(), + save: &mut save, + dispatch_tx: &dispatch_tx, + in_flight: &mut in_flight, + state_tx: &self.state_tx, + }) + .await; + } break DegradedOutcome::Exit(interrupted); } } @@ -1771,6 +1825,89 @@ impl StateHandler { Ok(result) } + /// What the State-A loop does with a join step. `Some` is the outcome the + /// loop breaks with. + /// + /// Nothing while the join goes on. When it ends, back to the main page — + /// and, when the join succeeded or a listener is held, hand the runtime + /// context back to `run()`, which brings up the new persona's DIDComm + /// listener without a restart (hot-start). See the invariant on + /// [`Self::run_degraded_loop`]. + async fn degraded_join_step( + &self, + step: join_flow::JoinStep, + state: &mut State, + join_ctx: &mut Option, + messaging: Option<&didcomm::Messaging>, + terminator: &mut Terminator, + ) -> Option { + // Set when a join succeeded: the loop then breaks `Joined` so `run()` + // can start messaging. + let joined_a_community = match step { + join_flow::JoinStep::Continue => return None, + // The joined session itself is not registered here: breaking + // `Joined` restarts into the full pipeline, whose startup + // registration (`IdentityRegistry`) brings the new session up + // (R-B-5). Live in-loop registration is only needed in the runtime + // loop. + join_flow::JoinStep::Returned(joined) => { + // Back on the main page; resume the degraded loop. + state.active_page = state::ActivePage::Main; + // Gated on the *join*, never on whether it minted the account's + // first identity. This used to read `!had_identity && …`, on the + // assumption that the only way to already hold an identity here + // was the messaging-startup-failure path. It is not: the + // `CreatePersonaSubmit` arm mints one in this very loop, and + // `active_identity()` falls back to the first persona in the map + // — so the ordinary first-run order (create persona, *then* + // join) left `had_identity` true, skipped the hand-off, and + // stranded the account in a loop that has no inbound arm. The + // community's reply was ACKed by the SDK, deleted at the + // mediator, and dropped. + joined.is_some() + && join_ctx + .as_ref() + .is_some_and(|ctx| ctx.config.active_identity().is_some()) + } + // Never returned by a driver that cannot hear replies. + join_flow::JoinStep::AwaitRequirements(_) + | join_flow::JoinStep::AwaitChallenge(_) + | join_flow::JoinStep::AwaitWallet(_) => { + state.active_page = state::ActivePage::Main; + false + } + join_flow::JoinStep::Exit(interrupted) => { + if let Err(e) = terminator.terminate(interrupted.clone()) { + debug!("Failed to send terminate signal: {e}"); + } + return Some(DegradedOutcome::Exit(interrupted)); + } + }; + // `holds_listener` is the backstop for the same class of bug the + // condition above fixes: this loop has no inbound arm, so *any* live + // listener it owns is a mailbox nobody reads. Whatever opened one — the + // join, or some future arm — the only safe move is to hand off to the + // runtime loop that drains the event channel. + let holds_listener = match messaging { + Some(m) => !m.list_listeners().await.is_empty(), + None => false, + }; + if must_hand_off(joined_a_community, holds_listener, join_ctx.is_some()) { + state.main_page.log(if joined_a_community { + "Joined — starting secure messaging…" + } else { + "Starting secure messaging…" + }); + let _ = self.state_tx.publish(state); + return Some(DegradedOutcome::Joined(Box::new( + join_ctx + .take() + .expect("join_ctx present, just checked is_some"), + ))); + } + None + } + /// Run the degraded loop for a path that cannot hot-start (no loaded config), /// collapsing the outcome to an [`Interrupted`]. `DegradedOutcome::Joined` is /// unreachable for these callers — only the State-A entry can transition @@ -1810,7 +1947,8 @@ impl StateHandler { } } -/// Runtime context the degraded loop hands to [`StateHandler::join_flow`]. +/// Runtime context the degraded loop lends the join it drives +/// ([`join_flow::JoinDriver`]). /// /// Owns the live `Config` (mutated + persisted by a successful join), the TDK, /// the always-on admin VTA session, and the profile name. The admin session is @@ -3029,7 +3167,7 @@ fn handle_nav_action(state: &mut State, action: &Action) -> bool { /// /// On the common path the listener is **already installed**: the join sequence /// brings the applicant online before it submits, so a reply that arrives during -/// the submit is not stranded at the mediator (`join_flow::start_persona_listener`). +/// the submit is not stranded at the mediator (`join_flow::driver::applicant_listener`). /// This is then the step that binds the community's session to it. The install /// arm still matters — a State-A join, a listener that could not be opened before /// the submit, or a reused persona whose session was dropped all land here. diff --git a/openvtc/src/state_handler/runtime/arms.rs b/openvtc/src/state_handler/runtime/arms.rs index d36e7bf1..e819ade7 100644 --- a/openvtc/src/state_handler/runtime/arms.rs +++ b/openvtc/src/state_handler/runtime/arms.rs @@ -9,8 +9,17 @@ use crate::state_handler::*; impl Runtime { /// A UI action. Everything but leaving and the join flow's entry points is - /// `runtime_actions::handle_action`'s. + /// `runtime_actions::handle_action`'s — and while a join is open, every + /// action is the join's, as the screen is. pub(super) async fn on_action(&mut self, action: Action) -> Flow { + if self.join.is_active() { + let (join, mut ctx) = self.join_parts(); + let step = join.on_action(&mut ctx, action).await; + return match self.apply_join_step(step).await { + Some(interrupted) => Flow::Exit(interrupted), + None => Flow::Continue, + }; + } let Runtime { state, config, @@ -41,23 +50,18 @@ impl Runtime { Action::StartJoin => { // State-B join from the live runtime: reuse the always-on - // admin VTA session. The DIDComm service keeps running in - // the background; the join flow owns the screen until the - // user returns. Entered at the loop's tail, which is also - // where a join waiting on a community's requirements is - // entered again. - *join_entry = Some(join_flow::JoinEntry::Fresh { - hears_replies: true, - }); + // admin VTA session. The DIDComm service keeps running, and + // so does this loop: the join is driven from here, and owns + // the screen until the user returns. Entered at the loop's + // tail, which is also where a join waiting on a community's + // requirements is entered again. + *join_entry = Some(join_flow::JoinEntry::Fresh); } // The same join, for a community already chosen: `j` on a // vetting application resumes the join that application was // made for, without going back for its DID. Action::StartJoinFor(vtc_did) => { - *join_entry = Some(join_flow::JoinEntry::ForCommunity { - vtc_did, - hears_replies: true, - }); + *join_entry = Some(join_flow::JoinEntry::ForCommunity { vtc_did }); } // A join waiting on a community's requirements keeps its screen // up while this loop hears the answer, so the two keys that @@ -139,7 +143,27 @@ impl Runtime { pub(super) async fn on_dispatch_outcome( &mut self, outcome: background_dispatch::DispatchOutcome, - ) { + ) -> Flow { + // The join's own jobs, before the shared applier: the driver matches + // each to the job it is waiting for. + let outcome = match outcome { + background_dispatch::DispatchOutcome::Join(outcome) => { + let (join, mut ctx) = self.join_parts(); + let step = join.on_outcome(&mut ctx, *outcome).await; + return match self.apply_join_step(step).await { + Some(interrupted) => Flow::Exit(interrupted), + None => Flow::Continue, + }; + } + background_dispatch::DispatchOutcome::Progress( + background_dispatch::ProgressUpdate::Join { generation, line }, + ) => { + self.join.on_progress(&mut self.state, generation, line); + return Flow::Continue; + } + outcome => outcome, + }; + let mut minted = None; let Runtime { state, config, @@ -202,9 +226,7 @@ impl Runtime { // It has no community, so — like a community-less identity // at startup — it gets a listener but no session-manager // entry (that is per (persona, community)). - AfterSettle::PersonaMinted(persona_id) => { - install_persona_listener(didcomm_service, tdk, config, persona_id, state).await; - } + AfterSettle::PersonaMinted(persona_id) => minted = Some(persona_id), AfterSettle::HiddenVetting(outcome) => { let mut ctx = runtime_actions::ActionCtx { state, @@ -244,6 +266,24 @@ impl Runtime { persona_actions::PersonaEffect::Read, ); } + if let Some(persona_id) = minted { + if self.join.is_active() { + // Minted from the join's own create-persona overlay: the join + // offers it as a way in, and brings it online itself. + let (join, mut ctx) = self.join_parts(); + join.on_persona_minted(&mut ctx, persona_id).await; + } else { + install_persona_listener( + &self.didcomm_service, + &self.tdk, + &self.config, + persona_id, + &mut self.state, + ) + .await; + } + } + Flow::Continue } /// D13 presence: another install using this account, or a diff --git a/openvtc/src/state_handler/runtime/mod.rs b/openvtc/src/state_handler/runtime/mod.rs index 9fe45d75..3164338b 100644 --- a/openvtc/src/state_handler/runtime/mod.rs +++ b/openvtc/src/state_handler/runtime/mod.rs @@ -159,6 +159,10 @@ pub(crate) struct Runtime { pub(super) hidden_vetting_pacer: hidden_vetting_poll::HiddenVettingPacer, // ── The join flow ── + /// The open join, if any (#529): a state machine this loop drives — its + /// actions, its off-loop jobs' outcomes — rather than a loop of its own + /// that would park this one. + pub(super) join: join_flow::JoinDriver, /// The join flow to enter at the end of this iteration. pub(super) join_entry: Option, /// A join waiting on a community's requirements (see `join_flow::AwaitingRequirements`). @@ -496,6 +500,7 @@ impl Runtime { // catches up the ticks it missed. hidden_vetting_tick, hidden_vetting_pacer: hidden_vetting_poll::HiddenVettingPacer::default(), + join: join_flow::JoinDriver::new(true), join_entry: None, awaiting_requirements: None, awaiting_challenge: None, @@ -514,7 +519,7 @@ impl Runtime { ); let result = loop { let flow = tokio::select! { - Some(action) = self.action_rx.recv() => { + Some(action) = self.join.next_action(&mut self.action_rx) => { let flow = self.on_action(action).await; // A view that shows did-git-sign may just have opened: no // I/O here, only whether a refresh is due. @@ -532,8 +537,7 @@ impl Runtime { Flow::Continue } Some(outcome) = self.dispatch_rx.recv() => { - self.on_dispatch_outcome(outcome).await; - Flow::Continue + self.on_dispatch_outcome(outcome).await } Some(report) = self.presence_rx.recv() => { self.on_presence(report).await; @@ -615,7 +619,12 @@ impl Runtime { Ok(interrupted) = self.interrupt_rx.recv() => Flow::Exit(interrupted), }; match flow { - Flow::Exit(interrupted) => break interrupted, + Flow::Exit(interrupted) => { + // An open join is undone before the loop stops: a launch + // in flight rolls back what it made, as a cancel does. + self.cancel_join().await; + break interrupted; + } Flow::Quiet => continue, Flow::Continue => {} } @@ -638,71 +647,10 @@ impl Runtime { self.awaiting_requirements = None; self.awaiting_challenge = None; self.awaiting_wallet = None; - match self - .handler - .join_flow( - &mut self.action_rx, - &mut self.interrupt_rx, - &mut self.state, - &self.tdk, - &mut self.config, - self.admin_vta.as_ref(), - self.handler.profile.as_str(), - Some(&self.didcomm_service), - entry, - ) - .await - { - Ok(join_flow::JoinExit::Returned(joined)) => { - self.state.active_page = state::ActivePage::Main; - // R-B-5 / D11: bring the new community's session up live - // so the VTC's async receipt is received now, not only - // after a restart. - if let Some(joined) = joined { - super::register_joined_session( - &mut self.session_manager, - &self.didcomm_service, - &self.tdk, - &self.config, - joined, - &mut self.state, - ) - .await; - // What the new community's persona wears, now rather than at the - // next sweep — until then its row could only say it had not - // been read. - super::dispatch_binding_refresh( - &self.config, - self.admin_vta.as_ref(), - &mut self.in_flight, - &self.dispatch_tx, - ); - } - } - Ok(join_flow::JoinExit::AwaitRequirements(awaiting)) => { - self.awaiting_requirements = Some(awaiting); - } - Ok(join_flow::JoinExit::AwaitChallenge(awaiting)) => { - self.awaiting_challenge = Some(awaiting); - } - Ok(join_flow::JoinExit::AwaitWallet(awaiting)) => { - self.awaiting_wallet = Some(awaiting); - } - // F10 on a join page lands here. Like every other exit it - // must tell the UI to stop: the UI leaves its loop only on - // an interrupt, so breaking without one stopped this loop - // and left the screen up reading keys nobody acted on — - // the "join page freezes, even F10 and Esc" report. - Ok(join_flow::JoinExit::Exit(interrupted)) => { - if let Err(e) = self.terminator.terminate(interrupted.clone()) { - debug!("Failed to send terminate signal: {e}"); - } - return Some(interrupted); - } - Err(e) => { - self.state.main_page.log_error("Join flow failed", &e); - self.state.active_page = state::ActivePage::Main; - } + let (join, mut ctx) = self.join_parts(); + let step = join.enter(&mut ctx, entry).await; + if let Some(interrupted) = self.apply_join_step(step).await { + return Some(interrupted); } } // Keep the working-context selection valid against any account change @@ -730,6 +678,105 @@ impl Runtime { None } + /// Do what a join step asks of this loop. `Some` when it ends the + /// application. + pub(super) async fn apply_join_step( + &mut self, + step: join_flow::JoinStep, + ) -> Option { + match step { + join_flow::JoinStep::Continue => {} + join_flow::JoinStep::Returned(joined) => { + self.state.active_page = state::ActivePage::Main; + // R-B-5 / D11: bring the new community's session up live so the + // VTC's async receipt is received now, not only after a restart. + if let Some(joined) = joined { + super::register_joined_session( + &mut self.session_manager, + &self.didcomm_service, + &self.tdk, + &self.config, + joined, + &mut self.state, + ) + .await; + // What the new community's persona wears, now rather than at + // the next sweep — until then its row could only say it had + // not been read. + super::dispatch_binding_refresh( + &self.config, + self.admin_vta.as_ref(), + &mut self.in_flight, + &self.dispatch_tx, + ); + } + } + join_flow::JoinStep::AwaitRequirements(awaiting) => { + self.awaiting_requirements = Some(awaiting); + } + join_flow::JoinStep::AwaitChallenge(awaiting) => { + self.awaiting_challenge = Some(awaiting); + } + join_flow::JoinStep::AwaitWallet(awaiting) => { + self.awaiting_wallet = Some(awaiting); + } + // F10 on a join page lands here. Like every other exit it must tell + // the UI to stop: the UI leaves its loop only on an interrupt, so + // breaking without one stopped this loop and left the screen up + // reading keys nobody acted on — the "join page freezes, even F10 + // and Esc" report. + join_flow::JoinStep::Exit(interrupted) => { + if let Err(e) = self.terminator.terminate(interrupted.clone()) { + debug!("Failed to send terminate signal: {e}"); + } + return Some(interrupted); + } + } + None + } + + /// End an open join, undoing a launch in flight — the loop is stopping. + async fn cancel_join(&mut self) { + if !self.join.is_active() { + return; + } + let (join, mut ctx) = self.join_parts(); + join.cancel(&mut ctx).await; + let _ = self.handler.state_tx.publish(&self.state); + } + + /// The join driver, and everything it acts on, borrowed apart. + pub(super) fn join_parts(&mut self) -> (&mut join_flow::JoinDriver, join_flow::JoinCtx<'_>) { + let Runtime { + join, + state, + config, + tdk, + admin_vta, + didcomm_service, + save, + dispatch_tx, + in_flight, + handler, + .. + } = self; + ( + join, + join_flow::JoinCtx { + state, + config, + tdk, + admin_vta: admin_vta.as_ref(), + messaging: Some(didcomm_service), + profile: handler.profile.as_str(), + save, + dispatch_tx, + in_flight, + state_tx: &handler.state_tx, + }, + ) + } + /// Everything the loop owns, borrowed for an action handler. pub(super) fn action_ctx(&mut self) -> runtime_actions::ActionCtx<'_> { runtime_actions::ActionCtx { diff --git a/openvtc/src/state_handler/runtime_actions.rs b/openvtc/src/state_handler/runtime_actions.rs index a6693afd..21e0eeb0 100644 --- a/openvtc/src/state_handler/runtime_actions.rs +++ b/openvtc/src/state_handler/runtime_actions.rs @@ -13,8 +13,9 @@ //! //! Three arms stay in the loop, because they are not about acting on state: //! `Exit` and `UXError` signal the terminator and `break` with the loop's own -//! outcome type, and `StartJoin` drives the join flow's own action loop with the -//! receiver this one is selecting on. +//! outcome type, and `StartJoin` opens the join, which the loop then drives +//! (`join_flow::JoinDriver`): while a join is open every action is the +//! driver's, and never reaches this module. use crate::state_handler::main_page::status::Status; #[allow(unused_imports)] @@ -1317,8 +1318,8 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha | Action::TokenWriteKeys(..) => {} // Owned by the loop: `Exit` and `UXError` signal the terminator and - // break with its outcome type, and the two `StartJoin` verbs drive the - // join flow's own action loop using the receiver this one selects on. + // break with its outcome type, and the two `StartJoin` verbs open the + // join the loop then drives (`join_flow::JoinDriver`). // Listed so the match stays exhaustive — the guarantee is that a NEW // variant cannot be added without a decision here, and these already // have one. diff --git a/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs b/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs index 9e3a8b2c..d3836660 100644 --- a/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs +++ b/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs @@ -32,8 +32,8 @@ pub struct VtcEnterDid; impl VtcEnterDid { pub fn handle_key_event(state: &mut JoinFlow, key: KeyEvent) { // Input is locked while the background sequence runs, except F10: a - // quit must always be heard. It is acted on once the in-flight - // resolve ends (each step is bounded by a timeout). + // quit must always be heard. The loop hears it at once — a resolve or + // a community read runs off the loop, and is aborted. if state.props.state.processing && key.code != KeyCode::F(10) { return; } From e95ce15a7609c849959092318887c3480ac0f4c0 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 19:46:00 +1000 Subject: [PATCH 17/27] test(join): a launch failed at each saga stage, cancelled mid-stage, and a resolve queued behind and aborted (#529) Signed-off-by: Glenn Gore --- openvtc/src/state_handler/join_flow/driver.rs | 12 + .../state_handler/join_flow/driver/tests.rs | 769 ++++++++++++++++++ openvtc/src/state_handler/runtime/tests.rs | 56 ++ 3 files changed, 837 insertions(+) create mode 100644 openvtc/src/state_handler/join_flow/driver/tests.rs diff --git a/openvtc/src/state_handler/join_flow/driver.rs b/openvtc/src/state_handler/join_flow/driver.rs index 3994b272..18b17526 100644 --- a/openvtc/src/state_handler/join_flow/driver.rs +++ b/openvtc/src/state_handler/join_flow/driver.rs @@ -58,6 +58,9 @@ use crate::state_handler::{ setup_sequence::SetupState, }; +#[cfg(test)] +mod tests; + /// A boxed future, as [`JoinIo`] returns them. pub(crate) type IoFuture<'a, T> = Pin + Send + 'a>>; @@ -3135,6 +3138,15 @@ impl JoinDriver { } ctx.state.join.processing = false; } + + /// The saga of the launch in progress — for tests. + #[cfg(test)] + fn saga(&self) -> Option<&JoinSaga> { + self.session + .as_ref() + .and_then(|s| s.launch.as_ref()) + .map(|l| &l.saga) + } } /// What a job that panicked leaves on the join page. diff --git a/openvtc/src/state_handler/join_flow/driver/tests.rs b/openvtc/src/state_handler/join_flow/driver/tests.rs new file mode 100644 index 00000000..bd1e3909 --- /dev/null +++ b/openvtc/src/state_handler/join_flow/driver/tests.rs @@ -0,0 +1,769 @@ +//! The join driver, offline (#529). +//! +//! Everything the join does that touches the network, the disk or a live +//! messaging account goes through [`JoinIo`]; [`Fake`] stands in for it, so a +//! whole launch runs on an offline TDK, a listener-less messaging service and a +//! bare config — and fails wherever a test says, which is what shows the saga +//! undoing exactly what it made. + +use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, +}; +use std::time::Duration; + +use openvtc_core::config::account::PersonaRecord; +use openvtc_core::didcomm::DIDCommEvent; +use tokio::sync::{mpsc, oneshot}; + +use super::*; +use crate::state_handler::dispatch_util::{test_config, test_tdk}; + +const COMMUNITY: &str = "did:web:community.example"; +const MEDIATOR: &str = "did:web:mediator.example"; +const MINTED_DID: &str = "did:webvh:minted.example"; +const REUSED_DID: &str = "did:webvh:reused.example"; + +/// Where a fake launch stops. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Fail { + Nowhere, + /// The community offers no messaging transport. + NoTransport, + Context, + Mint, + /// The answers from the face are refused. + Answers, + HiddenRefusal, + SubmitSend, + SubmitSigner, + /// The invitation stage never finishes — for a cancel to land in. + HangInInvite, +} + +struct Fake { + fail: Fail, + /// Listeners the compensation removed. + removed: Mutex>, + saves: AtomicUsize, + /// A resolve that waits for the test to answer it. + resolve_gate: Mutex>>>, +} + +impl Fake { + fn new(fail: Fail) -> Arc { + Arc::new(Self { + fail, + removed: Mutex::new(Vec::new()), + saves: AtomicUsize::new(0), + resolve_gate: Mutex::new(None), + }) + } + + fn removed(&self) -> Vec { + self.removed.lock().unwrap().clone() + } +} + +impl JoinIo for Fake { + fn resolve( + &self, + _resolver: DIDCacheClient, + _name: String, + ) -> IoFuture<'static, Result> { + let gate = self.resolve_gate.lock().unwrap().take(); + Box::pin(async move { + match gate { + Some(gate) => gate.await.unwrap_or_else(|_| Err("gate dropped".into())), + None => Ok(COMMUNITY.to_string()), + } + }) + } + + fn discover(&self, req: DiscoverReq) -> IoFuture<'static, Discovered> { + Box::pin(async move { + Discovered { + invitations: req.invitations.map(|_| Vec::new()), + manifest: req.manifest.then(|| { + Err(NotLearned::Unresolvable( + "it could not be resolved (offline)".into(), + )) + }), + document: None, + } + }) + } + + fn reach(&self, _req: ReachReq, _progress: Progress) -> IoFuture<'static, Reached> { + let fail = self.fail; + Box::pin(async move { + Reached { + display_name: None, + transports: PeerTransports { + tsp_mediator: None, + didcomm_mediator: (fail != Fail::NoTransport).then(|| MEDIATOR.to_string()), + }, + } + }) + } + + fn context(&self, _req: ContextReq) -> IoFuture<'static, Result> { + let fail = self.fail; + Box::pin(async move { + if fail == Fail::Context { + Err("the VTA refused".into()) + } else { + Ok(true) + } + }) + } + + fn mint(&self, _req: MintReq, _progress: Progress) -> IoFuture<'static, MintedParts> { + let fail = self.fail; + Box::pin(async move { + MintedParts { + failure: (fail == Fail::Mint) + .then(|| "Failed to create persona keys: the VTA refused".to_string()), + ..MintedParts::default() + } + }) + } + + fn invite(&self, req: InviteReq, _progress: Progress) -> IoFuture<'static, Invited> { + let fail = self.fail; + Box::pin(async move { + if fail == Fail::HangInInvite { + std::future::pending::<()>().await; + } + Invited { + listener_up: req.listener.as_ref().map(|_| true), + presentable: None, + } + }) + } + + fn present(&self, _req: PresentReq, _progress: Progress) -> IoFuture<'static, Presented> { + let fail = self.fail; + Box::pin(async move { + let failure = match fail { + Fail::Answers => Some("Could not put your face on this persona: refused".into()), + Fail::HiddenRefusal => Some(NO_ATTESTATION.to_string()), + _ => None, + }; + Presented { + linkage: None, + attributes: Vec::new(), + proof: None, + failure, + } + }) + } + + fn submit(&self, req: SubmitReq, _progress: Progress) -> IoFuture<'static, Submitted> { + let fail = self.fail; + Box::pin(async move { + let request = match req.prepared { + Err(e) => Err(e), + Ok(_) if fail == Fail::SubmitSend => Err(SubmitError::Send(OpenVTCError::Config( + "our mediator refused the frame".into(), + ))), + Ok(_) => Ok(uuid::Uuid::new_v4()), + }; + Submitted { + transport: MessagingTransport::DidComm, + online: true, + request, + } + }) + } + + fn persist_mint<'a>( + &'a self, + config: &'a mut Config, + _setup: &'a SetupState, + _tdk: &'a TDK, + _profile: &'a str, + ) -> IoFuture<'a, anyhow::Result> { + Box::pin(async move { Ok(seed_persona(config, MINTED_DID, "")) }) + } + + fn can_message(&self, _tdk: &TDK) -> bool { + true + } + + fn identity(&self, config: &Config, persona: PersonaId) -> Option<(String, String)> { + config + .account + .personas + .get(&persona) + .map(|p| (p.did.clone(), MEDIATOR.to_string())) + } + + fn listener_spec<'a>( + &'a self, + messaging: Option<&'a Messaging>, + _config: &'a Config, + _tdk: &'a TDK, + _persona: PersonaId, + did: &'a str, + ) -> IoFuture<'a, Option> { + Box::pin(async move { + messaging.map(|_| ListenerSpec { + id: openvtc_core::didcomm::persona_listener_id(did), + did: did.to_string(), + mediator_did: MEDIATOR.to_string(), + label: "applicant".into(), + secrets: Vec::new(), + collection: None, + }) + }) + } + + fn prepare_submit<'a>( + &'a self, + _config: &'a Config, + _tdk: &'a TDK, + _persona: PersonaId, + ) -> IoFuture<'a, Result, SubmitError>> { + let fail = self.fail; + Box::pin(async move { + if fail == Fail::SubmitSigner { + Err(SubmitError::Signer(OpenVTCError::Secret( + "the key could not be read".into(), + ))) + } else { + Ok(None) + } + }) + } + + fn remove_listener<'a>(&'a self, _messaging: &'a Messaging, id: &'a str) -> IoFuture<'a, ()> { + self.removed.lock().unwrap().push(id.to_string()); + Box::pin(async {}) + } + + fn save<'a>( + &'a self, + _save: &'a mut SaveScheduler, + _config: &'a Config, + ) -> IoFuture<'a, Result<(), OpenVTCError>> { + self.saves.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(()) }) + } +} + +fn seed_persona(config: &mut Config, did: &str, origin_context_id: &str) -> PersonaId { + let persona_id = PersonaId::new(); + config.account.personas.insert( + persona_id, + PersonaRecord { + extra: serde_json::Map::new(), + persona_id, + did: did.into(), + did_document: None, + did_locator: None, + key_refs: vec![], + mediator_did: None, + origin_context_id: origin_context_id.into(), + created_at: Utc::now(), + label: None, + }, + ); + persona_id +} + +/// The loop a join is driven from, played by the test. +struct Harness { + state: State, + config: Config, + tdk: TDK, + admin: VtaClient, + messaging: Messaging, + save: SaveScheduler, + dispatch_tx: UnboundedSender, + dispatch_rx: UnboundedReceiver, + in_flight: InFlight, + state_tx: watch::Sender>, + driver: JoinDriver, + fake: Arc, + _events: mpsc::UnboundedReceiver, +} + +impl Harness { + async fn new(fail: Fail) -> Self { + let fake = Fake::new(fail); + let (event_tx, events) = mpsc::unbounded_channel(); + let messaging = openvtc_core::didcomm::start_empty_service( + event_tx, + tokio_util::sync::CancellationToken::new(), + ); + let (dispatch_tx, dispatch_rx) = mpsc::unbounded_channel(); + let (state_tx, _) = watch::channel(Arc::new(State::default())); + let mut config = test_config(); + // A VTA account with a mediator, which a mint requires. + config.key_backend = openvtc_core::config::KeyBackend::Vta { + credential_bundle: secrecy::SecretString::new("bundle".into()), + credential_did: "did:key:admin".into(), + credential_private_key: secrecy::SecretString::new("key".into()), + vta_did: "did:web:vta.example".into(), + vta_url: String::new(), + mediator_did: Some(MEDIATOR.into()), + encryption_seed: secrecy::SecretBox::new(Box::new(vec![0u8; 32])), + }; + Self { + state: State::default(), + config, + tdk: test_tdk().await, + // Never dialled: every VTA call goes through the fake. + admin: VtaClient::new("http://127.0.0.1:1"), + messaging, + // Never flushed either: saves go through the fake too. + save: SaveScheduler::new("join-driver-test"), + dispatch_tx, + dispatch_rx, + in_flight: InFlight::default(), + state_tx, + driver: JoinDriver::with_io(true, fake.clone()), + fake, + _events: events, + } + } + + fn parts(&mut self) -> (&mut JoinDriver, JoinCtx<'_>) { + let Harness { + state, + config, + tdk, + admin, + messaging, + save, + dispatch_tx, + in_flight, + state_tx, + driver, + .. + } = self; + ( + driver, + JoinCtx { + state, + config, + tdk, + admin_vta: Some(admin), + messaging: Some(messaging), + profile: "join-driver-test", + save, + dispatch_tx, + in_flight, + state_tx, + }, + ) + } + + async fn enter(&mut self, entry: JoinEntry) -> JoinStep { + let (driver, mut ctx) = self.parts(); + driver.enter(&mut ctx, entry).await + } + + async fn act(&mut self, action: Action) -> JoinStep { + let (driver, mut ctx) = self.parts(); + driver.on_action(&mut ctx, action).await + } + + async fn cancel(&mut self) { + let (driver, mut ctx) = self.parts(); + driver.cancel(&mut ctx).await; + } + + fn job_in_flight(&self) -> bool { + self.driver + .session + .as_ref() + .is_some_and(|s| s.job.is_some()) + } + + /// Apply outcomes until no join job is in flight. + async fn run_jobs(&mut self) -> JoinStep { + let mut step = JoinStep::Continue; + while self.job_in_flight() { + let outcome = tokio::time::timeout(Duration::from_secs(5), self.dispatch_rx.recv()) + .await + .expect("a join job finishes") + .expect("the dispatch channel is open"); + match outcome { + DispatchOutcome::Join(outcome) => { + let (driver, mut ctx) = self.parts(); + step = driver.on_outcome(&mut ctx, *outcome).await; + } + DispatchOutcome::Progress(ProgressUpdate::Join { generation, line }) => { + self.driver.on_progress(&mut self.state, generation, line); + } + _ => {} + } + } + step + } + + /// Run the launch stages one at a time until `stop` says the launch is + /// parked where the test wants it. + async fn run_until(&mut self, stop: impl Fn(&Harness) -> bool) { + while self.job_in_flight() && !stop(self) { + let outcome = tokio::time::timeout(Duration::from_secs(5), self.dispatch_rx.recv()) + .await + .expect("a join job finishes") + .expect("the dispatch channel is open"); + if let DispatchOutcome::Join(outcome) = outcome { + let (driver, mut ctx) = self.parts(); + driver.on_outcome(&mut ctx, *outcome).await; + } + } + } + + /// Open the join and launch it as `pick`, with the registry question + /// answered — the last page before anything is sent. + async fn launch(&mut self, pick: IdentityPick) { + assert!(matches!( + self.enter(JoinEntry::Fresh).await, + JoinStep::Continue + )); + self.state.join.registry = Some(RegistryChoice { + publish: false, + community: COMMUNITY.to_string(), + community_name: None, + confirmed: false, + parked: Some((pick, COMMUNITY.to_string(), "acct/community".to_string())), + review: None, + directory: None, + }); + assert!(matches!( + self.act(Action::JoinRegistryConfirm).await, + JoinStep::Continue + )); + assert!( + self.job_in_flight(), + "the launch's first stage runs off the loop" + ); + assert!(self.state.join.processing, "input is locked while it runs"); + } + + fn last_error(&self) -> Option<&str> { + self.state.join.messages.iter().rev().find_map(|m| match m { + MessageType::Error(e) => Some(e.as_str()), + MessageType::Info(_) => None, + }) + } + + fn said(&self, needle: &str) -> bool { + self.state.join.messages.iter().any(|m| match m { + MessageType::Info(t) | MessageType::Error(t) => t.contains(needle), + }) + } + + fn persona(&self, did: &str) -> bool { + self.config.account.personas.values().any(|p| p.did == did) + } + + /// The launch is over and the join page is unlocked, with the domain free. + fn assert_settled(&self) { + assert!(!self.job_in_flight()); + assert!(self.driver.saga().is_none(), "the launch has ended"); + assert!(!self.state.join.processing, "input unlocks"); + assert!(!self.in_flight.is_busy(DispatchDomain::Join)); + assert!(self.driver.is_active(), "the join page stays up"); + } +} + +fn listener_of(did: &str) -> String { + openvtc_core::didcomm::persona_listener_id(did) +} + +// ---- The launch saga, failed at each stage ---- + +#[tokio::test] +async fn no_transport_fails_before_anything_is_made() { + let mut h = Harness::new(Fail::NoTransport).await; + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert!( + h.last_error() + .is_some_and(|e| e.contains("advertises no messaging transport") + && e.contains("Nothing was created")) + ); + assert!(h.config.account.personas.is_empty()); + assert!(h.fake.removed().is_empty()); + assert_eq!(h.fake.saves.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn a_context_failure_stops_before_the_mint() { + let mut h = Harness::new(Fail::Context).await; + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert_eq!( + h.last_error(), + Some("Could not prepare context acct/community: the VTA refused. Nothing was created.") + ); + assert!(h.said("Community reachable over")); + assert!(h.config.account.personas.is_empty()); + assert_eq!(h.fake.saves.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn a_mint_failure_leaves_no_persona() { + let mut h = Harness::new(Fail::Mint).await; + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert_eq!( + h.last_error(), + Some("Failed to create persona keys: the VTA refused") + ); + assert!(h.said("Created context acct/community.")); + assert!(h.config.account.personas.is_empty()); + assert!(h.fake.removed().is_empty()); +} + +#[tokio::test] +async fn refused_answers_undo_the_minted_persona_and_its_listener() { + let mut h = Harness::new(Fail::Answers).await; + h.config.public.friendly_name = "before the join".into(); + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert_eq!( + h.last_error(), + Some("Could not put your face on this persona: refused") + ); + assert!(!h.persona(MINTED_DID), "the minted persona is rolled back"); + assert_eq!(h.fake.removed(), vec![listener_of(MINTED_DID)]); + assert_eq!(h.config.public.friendly_name, "before the join"); + assert_eq!( + h.fake.saves.load(Ordering::SeqCst), + 1, + "the rollback is saved at once" + ); +} + +#[tokio::test] +async fn a_hidden_refusal_undoes_the_minted_persona_and_its_listener() { + let mut h = Harness::new(Fail::HiddenRefusal).await; + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert_eq!(h.last_error(), Some(NO_ATTESTATION)); + assert!(!h.persona(MINTED_DID)); + assert_eq!(h.fake.removed(), vec![listener_of(MINTED_DID)]); +} + +#[tokio::test] +async fn a_failed_send_undoes_a_minted_persona_and_its_listener() { + let mut h = Harness::new(Fail::SubmitSend).await; + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert!( + h.last_error() + .is_some_and(|e| e.starts_with("Failed to submit join request:")) + ); + assert!(!h.persona(MINTED_DID)); + assert_eq!(h.fake.removed(), vec![listener_of(MINTED_DID)]); + assert!(h.config.account.memberships().next().is_none()); +} + +/// The one deliberate change (#529): a failed send as a *reused* persona used +/// to delete that persona from the account. The saga never records a reused +/// persona as minted, so only the listener this launch opened is undone. +#[tokio::test] +async fn a_failed_send_never_removes_a_reused_persona() { + let mut h = Harness::new(Fail::SubmitSend).await; + let reused = seed_persona(&mut h.config, REUSED_DID, ""); + h.launch(IdentityPick::Reuse(reused)).await; + h.run_jobs().await; + h.assert_settled(); + assert!( + h.last_error() + .is_some_and(|e| e.starts_with("Failed to submit join request:")) + ); + assert!(h.said(&format!("Reusing persona {REUSED_DID}…"))); + assert!(h.persona(REUSED_DID), "a reused persona is never forgotten"); + assert_eq!(h.fake.removed(), vec![listener_of(REUSED_DID)]); + assert_eq!(h.fake.saves.load(Ordering::SeqCst), 0, "nothing to save"); +} + +/// An unreadable signing key undoes nothing — the persona and its listener +/// stay, as they always did on this path. +#[tokio::test] +async fn a_signer_failure_keeps_the_persona_and_its_listener() { + let mut h = Harness::new(Fail::SubmitSigner).await; + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert!( + h.last_error() + .is_some_and(|e| e.starts_with("Couldn't sign the application:")) + ); + assert!(h.persona(MINTED_DID)); + assert!(h.fake.removed().is_empty()); +} + +#[tokio::test] +async fn a_sent_join_is_recorded_and_nothing_is_undone() { + let mut h = Harness::new(Fail::Nowhere).await; + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert!(h.last_error().is_none()); + assert!(matches!(h.state.join.completed, Completion::CompletedOK)); + let record = h + .state + .join + .created_community + .clone() + .expect("the pending membership is on the page"); + assert_eq!(record.vtc_did, COMMUNITY); + assert!( + h.config + .account + .memberships() + .any(|m| m.vtc_did == COMMUNITY) + ); + assert!(h.persona(MINTED_DID)); + assert!(h.fake.removed().is_empty()); + assert_eq!( + h.fake.saves.load(Ordering::SeqCst), + 1, + "the membership is saved" + ); + // Enter on the finished page leaves with the joined session. + let step = h.act(Action::JoinCancel).await; + assert!(matches!(step, JoinStep::Returned(Some(_)))); + assert!(!h.driver.is_active()); +} + +/// A cancel lands mid-stage — the listener is claimed and connecting — and +/// undoes the launch from its saga, at once. +#[tokio::test] +async fn a_cancel_mid_stage_undoes_the_launch() { + let mut h = Harness::new(Fail::HangInInvite).await; + h.launch(IdentityPick::Mint).await; + h.run_until(|h| h.driver.saga().is_some_and(|s| s.listener().is_some())) + .await; + let saga = h.driver.saga().cloned().expect("the launch is in flight"); + assert!(saga.minted_persona().is_some()); + assert!(h.job_in_flight(), "parked in the invitation stage"); + + let step = h.act(Action::Exit).await; + assert!(matches!(step, JoinStep::Exit(Interrupted::UserInt))); + assert!(!h.driver.is_active()); + assert!(!h.in_flight.is_busy(DispatchDomain::Join)); + assert!(!h.persona(MINTED_DID), "the minted persona is rolled back"); + assert_eq!(h.fake.removed(), vec![listener_of(MINTED_DID)]); + assert!(h.said( + "Join cancelled. Any partially-minted persona was rolled back; a sub-context may remain at the VTA." + )); + assert!( + h.state + .main_page + .activity_log + .iter() + .any(|e| e.summary.contains("Join cancelled by user.")) + ); +} + +/// The loop stopping (an interrupt) cancels the same way, and a reused +/// persona survives it. +#[tokio::test] +async fn an_interrupt_mid_stage_keeps_a_reused_persona() { + let mut h = Harness::new(Fail::HangInInvite).await; + let reused = seed_persona(&mut h.config, REUSED_DID, ""); + h.launch(IdentityPick::Reuse(reused)).await; + h.run_until(|h| h.driver.saga().is_some_and(|s| s.listener().is_some())) + .await; + h.cancel().await; + assert!(!h.driver.is_active()); + assert!(h.persona(REUSED_DID)); + assert_eq!(h.fake.removed(), vec![listener_of(REUSED_DID)]); +} + +// ---- Resolve: off the loop, cancellable, keys queued behind it ---- + +#[tokio::test] +async fn keys_pressed_while_a_name_resolves_are_queued_and_replayed() { + let mut h = Harness::new(Fail::Nowhere).await; + let (answer, gate) = oneshot::channel(); + *h.fake.resolve_gate.lock().unwrap() = Some(gate); + h.enter(JoinEntry::Fresh).await; + h.act(Action::JoinSubmitVtc("example.com/@acme".into())) + .await; + assert!(h.job_in_flight(), "the name resolves off the loop"); + assert!(h.said("Resolving example.com/@acme…")); + + let before = h.state.join.new_persona_did_method; + h.act(Action::JoinNewPersonaMethodCycle).await; + assert_eq!( + h.state.join.new_persona_did_method, before, + "held back while the job runs" + ); + + answer.send(Ok(COMMUNITY.to_string())).unwrap(); + h.run_jobs().await; + // Resolved, then read: the fake community does not resolve. + assert_eq!(h.state.join.page, JoinPage::Vetting); + assert_eq!(h.state.join.pending_vtc.as_deref(), Some(COMMUNITY)); + + // Due now, ahead of anything new on the channel. + let (_tx, mut rx) = mpsc::unbounded_channel(); + let replay = h + .driver + .next_action(&mut rx) + .await + .expect("the held-back key is replayed"); + h.act(replay).await; + assert_eq!( + h.state.join.new_persona_did_method, + next_did_method(before), + "and acted on" + ); +} + +#[tokio::test] +async fn esc_while_a_name_resolves_aborts_it() { + let mut h = Harness::new(Fail::Nowhere).await; + let (answer, gate) = oneshot::channel::>(); + *h.fake.resolve_gate.lock().unwrap() = Some(gate); + h.enter(JoinEntry::Fresh).await; + h.act(Action::JoinSubmitVtc("example.com/@acme".into())) + .await; + assert!(h.in_flight.is_busy(DispatchDomain::Join)); + + let step = h.act(Action::JoinCancel).await; + assert!(matches!(step, JoinStep::Returned(None))); + assert!(matches!(h.state.active_page, ActivePage::Main)); + assert!(!h.driver.is_active()); + assert!(!h.in_flight.is_busy(DispatchDomain::Join)); + // The resolve was dropped where it waited. + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(answer.is_closed(), "the aborted resolve no longer listens"); + assert!(h.dispatch_rx.try_recv().is_err(), "and reports nothing"); +} + +#[tokio::test] +async fn a_stale_outcome_is_ignored() { + let mut h = Harness::new(Fail::Nowhere).await; + h.enter(JoinEntry::Fresh).await; + let (driver, mut ctx) = h.parts(); + let step = driver + .on_outcome( + &mut ctx, + JoinOutcome { + generation: 99, + result: JobResult::Resolved(Ok(COMMUNITY.into())), + }, + ) + .await; + assert!(matches!(step, JoinStep::Continue)); + assert_eq!(h.state.join.page, JoinPage::EnterDid); + assert!(!h.job_in_flight()); +} diff --git a/openvtc/src/state_handler/runtime/tests.rs b/openvtc/src/state_handler/runtime/tests.rs index d0e92e23..f339884e 100644 --- a/openvtc/src/state_handler/runtime/tests.rs +++ b/openvtc/src/state_handler/runtime/tests.rs @@ -258,3 +258,59 @@ async fn the_pending_expiry_sweep_over_an_empty_account_changes_nothing() { "nothing changed, so nothing to save" ); } + +/// The join no longer parks the loop (#529): with its screen up, the other +/// arms still run, and its actions are the join's. +#[tokio::test] +async fn an_open_join_leaves_the_other_arms_running() { + let (mut rt, _h) = runtime().await; + assert!(matches!( + rt.on_action(Action::StartJoin).await, + Flow::Continue + )); + assert!(rt.end_of_iteration().await.is_none()); + assert!(rt.join.is_active()); + assert!(matches!(rt.state.active_page, state::ActivePage::Join)); + + let domain = background_dispatch::DispatchDomain::AgentName; + assert!(rt.in_flight.try_begin(domain)); + rt.on_dispatch_outcome(background_dispatch::DispatchOutcome::AgentName(Vec::new())) + .await; + assert!(!rt.in_flight.is_busy(domain), "another arm ran mid-join"); + + assert!(matches!( + rt.on_action(Action::JoinCancel).await, + Flow::Continue + )); + assert!(!rt.join.is_active()); + assert!(matches!(rt.state.active_page, state::ActivePage::Main)); +} + +#[tokio::test] +async fn an_interrupt_with_a_join_open_stops_the_loop() { + let (mut rt, h) = runtime().await; + rt.on_action(Action::StartJoin).await; + rt.end_of_iteration().await; + assert!(rt.join.is_active()); + h.interrupt_tx.send(Interrupted::UserInt).unwrap(); + let outcome = tokio::time::timeout(Duration::from_secs(10), rt.run()) + .await + .expect("the loop stops on an interrupt with a join open") + .expect("the loop returns its outcome"); + assert!(matches!(outcome, Interrupted::UserInt)); +} + +#[tokio::test] +async fn f10_on_a_join_page_stops_the_loop_and_tells_the_terminator() { + let (mut rt, mut h) = runtime().await; + rt.on_action(Action::StartJoin).await; + rt.end_of_iteration().await; + assert!(matches!( + rt.on_action(Action::Exit).await, + Flow::Exit(Interrupted::UserInt) + )); + assert!(matches!( + h.interrupt_seen.try_recv(), + Ok(Interrupted::UserInt) + )); +} From 0a8b123e4ef6c4d671b652643b29925e61eef7e0 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 19:46:00 +1000 Subject: [PATCH 18/27] docs(join): the join is a driven state machine, not a nested loop (#529) Signed-off-by: Glenn Gore --- docs/design/tui-architecture.md | 35 ++++++++++++++++++++++++++++++++- docs/design/vetting-process.md | 11 +++++------ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/design/tui-architecture.md b/docs/design/tui-architecture.md index 2b09ce65..ea6e9581 100644 --- a/docs/design/tui-architecture.md +++ b/docs/design/tui-architecture.md @@ -79,7 +79,8 @@ the real work is decomposed into focused modules (declared at `mod.rs:50`): | `persona_actions.rs` | The identity pane. `apply(&mut State, &PersonaAction)` is pure and returns a `PersonaEffect` naming any network work; the loop spawns it and folds the result back through `PersonaOutcome::apply`. Reads and writes share one `DispatchDomain`. | | `didcomm.rs` | `DIDCommService` integration: service start, listener config/ids, the `DIDCommEvent` enum, lifecycle logging. | | `main_page/` | Maps `Config` → display state (`sync_from_config`), menu/content models. | -| `setup_wizard.rs`, `setup_sequence/`, `setup_*_actions.rs`, `join.rs`, `join_flow.rs` | The setup and join sub-flows (each runs its own scoped `select!` loop, then returns control to `main_loop`). | +| `setup_wizard.rs`, `setup_sequence/`, `setup_*_actions.rs` | The setup sub-flow (runs its own scoped `select!` loop, then returns control to `main_loop`). | +| `join.rs`, `join_flow.rs`, `join_flow/driver.rs` | The join flow. `join.rs` is its page state; `join_flow.rs` its pure page logic and the bounded sends it makes while waiting on a community; `driver.rs` the `JoinDriver` state machine the runtime loop (and the State-A loop) drives — no loop of its own (see §4). | | `dispatch_util.rs` | Shared helpers + test fixtures (`test_config`). | **Protocol logic does not live here.** The pure validators, handlers (join-receipt, @@ -130,6 +131,38 @@ DID: the busy-guard is per-domain). Inside the job the DIDs resolve with bounded concurrency (a sliding window of 4). The job does I/O only; `apply_outcome` folds the verified `(did, name)` results into `Config::agent_names` on the loop thread. +**The join flow** ([`join_flow/driver.rs`](../../openvtc/src/state_handler/join_flow/driver.rs)) — +the largest user of the pattern, and the one that used to break the rule: it +ran its own nested `select!` loop that the runtime loop awaited inline, so +saves, VTA supervision, inbound messages and timers all stopped while the join +screen was up (#529). It is now a `JoinDriver` state machine the loop drives: + +1. While a join is open the loop's action arm hands every action to + `JoinDriver::on_action`; page logic runs there, on the loop. +2. Network work — resolving an agent name, reading a community (invitations, + manifest, DID document), and each stage of a launch — runs as one job at a + time on `DispatchDomain::Join`. The result comes back as + `DispatchOutcome::Join` and progress lines as `ProgressUpdate::Join`, both + carrying the job's generation; the loop gives them to the driver *before* + `apply_outcome`, and the driver ignores any that are not for the job it is + waiting on (an aborted one). +3. Join actions that arrive while a job is in flight are queued and replayed + in order when it finishes. `JoinCancel`, `Exit` and `UXError` are not: they + abort the job at once (`AbortHandle`; the driver frees the domain). +4. A launch is a saga (`openvtc_core::join::saga::JoinSaga`): context + registered → persona minted → listener up → submitted → persisted, with + on-loop applies between off-loop stages. Any failure, cancel or interrupt + undoes the launch from `JoinSaga::compensations` — remove the listener it + installed, forget the persona it minted (never a reused one) — rather than + from wherever a dropped future happened to stop. + +Short, bounded sends (asking a community for its requirements, a challenge, a +wallet session — each behind a 15 s timeout) and the VTA reads behind the +Answers page stay inline awaits, as elsewhere in the runtime. Everything the +join does over the network, to disk or through a live messaging account goes +through the `JoinIo` trait, so tests drive whole launches offline and fail them +at each stage. + ## 5. Recipes ### Add a keybinding diff --git a/docs/design/vetting-process.md b/docs/design/vetting-process.md index d36a6aa8..7990e003 100644 --- a/docs/design/vetting-process.md +++ b/docs/design/vetting-process.md @@ -1412,12 +1412,11 @@ Following the existing patterns in - **cancel**. - An application that meets the published requirements joins as its own persona, and the page says its statements are presented with the join. - - When the book does not know, the community is asked. The join flow cannot - hear the answer itself: its loop selects only on actions and interrupts, and - inbound messages are read by the runtime loop's own arm, which is parked - while the flow runs. + - When the book does not know, the community is asked. The join flow does + not hear the answer itself: its driver takes actions and its own jobs' + results, and inbound messages are read by the runtime loop's own arm. - So the flow sends `manifest/0.2` as the active persona, with the send - bounded at 15 seconds, and returns `JoinExit::AwaitRequirements`. + bounded at 15 seconds, and returns `JoinStep::AwaitRequirements`. - The runtime loop keeps the join screen up in an *asking* state and goes on reading inbound messages. It enters the flow again when one of these happens: @@ -1654,7 +1653,7 @@ modern-signature subgraph only (O7). | D23 | Which criterion applies | The one whose `requirementsDigest` the applicant names in `extensions`; otherwise the first vetting criterion | | D24 | Session binding details | Match-code tag `vetting-session-match/v1\0`, distinct from `vtc-personhood-match/v1\0`. `eligibilityVp` carries `nonce` = the `vetting/request` document `id` and `domain` = the applicant's `joinDid` | | D25 | Answers to questions put to a community | **Matched by request document id, in memory only.** A directory page, a stored profile, a resent grant or a manifest reaches whoever asked. A `trust-task-error` is claimed only when it answers one of these questions, so the join handler keeps every other error. A question unanswered after 30 s is reported (R1.2) | -| D26 | How the join flow learns whether a community vets | **From the book, else by asking and handing the wait to the runtime loop** (`JoinExit::AwaitRequirements`, 15 s). The flow's own loop cannot hear inbound messages, so it never waits there, and every wait ends on a message or a deadline | +| D26 | How the join flow learns whether a community vets | **From the book, else by asking and handing the wait to the runtime loop** (`JoinStep::AwaitRequirements`, 15 s). The join driver does not hear inbound messages, so it never waits there, and every wait ends on a message or a deadline | | D27 | Vetter profile | **Opt-in listing; the last profile sent is kept as JSON with the community's answer.** A first profile is unlisted and accepts what `VetterPolicy` accepts. The SDK's `check_shape` is the only validator | | D28 | Checking a vetter's grant for revocation | **After a verified acceptance, as a background job, https-only with finite timeouts.** Recorded per request as active, revoked or unknown, and unknown is never shown as unrevoked. Advisory; the community checks again when it decides | | D29 | Community branding | **Kept from every manifest; presentation only.** The accent is drawn as a literal-colour swatch. The display name is used only when there is no membership name or verified agent name. The logo is not fetched | From 0022da929d8a35eece3b9ba2e3d28b7c7f0c684a Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 18:45:41 +1000 Subject: [PATCH 19/27] feat(core): PendingReplies, a runtime-owned table of replies awaited by thread One in-memory table (thread -> community, purpose, when) for the replies admitted only because we asked: renewals, community profiles, and election answers from a community we hold no membership with. Each kind keeps the window, cap, thread normalisation and community comparison of the process-global static it replaces (#534). Signed-off-by: Glenn Gore --- openvtc-core/src/lib.rs | 1 + openvtc-core/src/pending_replies.rs | 438 ++++++++++++++++++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 openvtc-core/src/pending_replies.rs diff --git a/openvtc-core/src/lib.rs b/openvtc-core/src/lib.rs index 1dae3849..ff79d34f 100644 --- a/openvtc-core/src/lib.rs +++ b/openvtc-core/src/lib.rs @@ -56,6 +56,7 @@ mod net_guard; #[cfg(feature = "openpgp-card")] pub mod openpgp_card; pub mod operational; +pub mod pending_replies; pub mod persona; pub mod personhood; pub mod presentation; diff --git a/openvtc-core/src/pending_replies.rs b/openvtc-core/src/pending_replies.rs new file mode 100644 index 00000000..db4c1383 --- /dev/null +++ b/openvtc-core/src/pending_replies.rs @@ -0,0 +1,438 @@ +//! Replies we are waiting for from communities, by thread. +//! +//! Some answers are admitted only because we asked for them: a community's +//! answer to a renewal, its profile, or — from a community this persona holds +//! no membership with — an election answer (VTI-NTF-061, VTI-NTF-063). Each +//! request is recorded here, against the community it went to, before it is +//! sent; the inbound gate ([`crate::inbound`]) admits and takes an answer only +//! on a thread recorded here, from that community, while it is still waited +//! for. +//! +//! The table is **in memory and owned by whoever drives inbound handling**: +//! the TUI's runtime loop holds one for its lifetime, and each headless command +//! holds its own. It is passed explicitly to the send paths that record and to +//! the dispatcher that consumes — never a process-global — so the single +//! mutator owns it, and tests each use their own. A request is this process's: +//! an answer arriving after a restart is not taken (the member asks again). +//! +//! Each [`ReplyKind`] keeps the window, capacity, thread normalisation and +//! community comparison it has always had: +//! +//! | kind | waits | at most | thread key | community | +//! |-------------------------------|--------|---------|---------------------------------|---------------| +//! | [`ReplyKind::Renewal`] | 600 s | 64 | the UUID, `urn:uuid:` optional | exact | +//! | [`ReplyKind::CommunityProfile`] | 600 s | 256 | the thread id exactly | exact | +//! | [`ReplyKind::ElectionAnswer`] | 120 s | 64 | `urn:uuid:` prefix optional | same DID ([`vta_sdk::did_scid::same_reference`]) | + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use uuid::Uuid; + +/// What a recorded request was, and so how its answer is matched. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ReplyKind { + /// `vtc/members/renew/0.1` ([`crate::renewal`]). + Renewal, + /// `vtc/community/profile/show/0.1` ([`crate::join::send_community_profile_show`]). + CommunityProfile, + /// A `vtc/elections/*` request to a community this persona may hold no + /// membership with ([`crate::elections`]). + ElectionAnswer, +} + +impl ReplyKind { + /// How long a request of this kind waits for its answer. + #[must_use] + pub const fn ttl(self) -> Duration { + match self { + ReplyKind::Renewal | ReplyKind::CommunityProfile => Duration::from_secs(600), + ReplyKind::ElectionAnswer => Duration::from_secs(120), + } + } + + /// The most requests of this kind outstanding at once. A request past the + /// cap is not recorded, and its answer is not taken. + #[must_use] + pub const fn capacity(self) -> usize { + match self { + ReplyKind::Renewal | ReplyKind::ElectionAnswer => 64, + ReplyKind::CommunityProfile => 256, + } + } + + /// The key a thread id is recorded and looked up under, or `None` for a + /// thread this kind can never answer. + /// + /// A renewal is sent with its document id as the DIDComm message id + /// (`urn:uuid:`), and a community may thread on either form, so it is + /// keyed by the UUID. An election answer may thread on either form too. A + /// profile answer threads on the document id exactly. + fn key(self, thid: &str) -> Option { + let bare = || thid.strip_prefix("urn:uuid:").unwrap_or(thid); + match self { + ReplyKind::Renewal => Uuid::parse_str(bare()).ok().map(|id| id.to_string()), + ReplyKind::CommunityProfile => Some(thid.to_string()), + ReplyKind::ElectionAnswer => Some(bare().to_string()), + } + } + + /// Whether `from` is the community a request of this kind was sent to. + /// An election's community may name itself with the `?src=` it is resolved + /// through (VTI-KEY-163), so it is compared by DID. + fn same_community(self, recorded: &str, from: &str) -> bool { + match self { + ReplyKind::Renewal | ReplyKind::CommunityProfile => recorded == from, + ReplyKind::ElectionAnswer => vta_sdk::did_scid::same_reference(recorded, from), + } + } +} + +/// What a request was for, recorded with it and handed back when its answer +/// is taken. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Purpose { + /// A renewal `member_did` asked for. + Renewal { member_did: String }, + /// A question for the community's profile. + CommunityProfile, + /// An election request. + ElectionAnswer, +} + +impl Purpose { + /// Its kind. + #[must_use] + pub const fn kind(&self) -> ReplyKind { + match self { + Purpose::Renewal { .. } => ReplyKind::Renewal, + Purpose::CommunityProfile => ReplyKind::CommunityProfile, + Purpose::ElectionAnswer => ReplyKind::ElectionAnswer, + } + } +} + +#[derive(Debug)] +struct Entry { + community: String, + purpose: Purpose, + at: Instant, +} + +impl Entry { + fn live(&self, kind: ReplyKind, now: Instant) -> bool { + now.saturating_duration_since(self.at) < kind.ttl() + } +} + +/// The requests we wait on an answer for: thread → community, purpose, and +/// when it was sent. See the [module docs](self). +#[derive(Debug, Default)] +pub struct PendingReplies { + entries: HashMap<(ReplyKind, String), Entry>, +} + +impl PendingReplies { + /// An empty table. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Record that the request threaded on `thid` was sent to `community`, so + /// its answer is admitted. Done before the send, so an answer racing it is + /// still recognised. Returns whether it was recorded: not for a thread the + /// kind cannot be answered on, nor past the kind's capacity. + pub fn record(&mut self, thid: &str, community: &str, purpose: Purpose) -> bool { + self.record_at(thid, community, purpose, Instant::now()) + } + + fn record_at(&mut self, thid: &str, community: &str, purpose: Purpose, now: Instant) -> bool { + let kind = purpose.kind(); + let Some(key) = kind.key(thid) else { + return false; + }; + self.entries.retain(|(k, _), e| e.live(*k, now)); + if self.entries.keys().filter(|(k, _)| *k == kind).count() >= kind.capacity() { + return false; + } + self.entries.insert( + (kind, key), + Entry { + community: community.to_string(), + purpose, + at: now, + }, + ); + true + } + + /// Whether `thid` answers a request of `kind` sent to `community`, still + /// waited for. Does not consume it: an answer is [taken](Self::take) only + /// once it has been verified, so a forged one cannot cancel the genuine + /// answer still on its way. + #[must_use] + pub fn is_awaited(&self, kind: ReplyKind, community: &str, thid: &str) -> bool { + self.is_awaited_at(kind, community, thid, Instant::now()) + } + + fn is_awaited_at(&self, kind: ReplyKind, community: &str, thid: &str, now: Instant) -> bool { + self.live_entry(kind, community, thid, now).is_some() + } + + /// Take the request of `kind` that `thid` answers, if it was sent to + /// `community` and is still waited for. Consumes it: a request is answered + /// once. An answer from any other community leaves it waiting. + pub fn take(&mut self, kind: ReplyKind, community: &str, thid: &str) -> Option { + self.take_at(kind, community, thid, Instant::now()) + } + + fn take_at( + &mut self, + kind: ReplyKind, + community: &str, + thid: &str, + now: Instant, + ) -> Option { + let key = self.live_entry(kind, community, thid, now)?; + self.entries.remove(&(kind, key)).map(|e| e.purpose) + } + + /// Stop waiting on `thid`: its send failed, its view gave up on it, or its + /// answer was taken elsewhere. + pub fn forget(&mut self, kind: ReplyKind, thid: &str) { + if let Some(key) = kind.key(thid) { + self.entries.remove(&(kind, key)); + } + } + + /// The key of the live entry of `kind` on `thid` sent to `community`. + fn live_entry( + &self, + kind: ReplyKind, + community: &str, + thid: &str, + now: Instant, + ) -> Option { + let key = kind.key(thid)?; + let entry = self.entries.get(&(kind, key.clone()))?; + (entry.live(kind, now) && kind.same_community(&entry.community, community)).then_some(key) + } + + /// How many requests are waited on (expired ones included until the next + /// [`record`](Self::record) prunes them). + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether nothing is waited on. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VTC: &str = "did:webvh:QmVtc:vtc.example"; + const MEMBER: &str = "did:example:member"; + + fn renewal() -> Purpose { + Purpose::Renewal { + member_did: MEMBER.into(), + } + } + + /// A renewal answer is taken only from the community asked, only once, and + /// on either form of the thread id. + #[test] + fn a_renewal_is_bound_to_the_request_it_answers() { + let mut t = PendingReplies::new(); + let id = Uuid::new_v4(); + assert!(t.record(&id.to_string(), VTC, renewal())); + assert!(!t.is_awaited(ReplyKind::Renewal, "did:example:other", &id.to_string())); + assert!(t.is_awaited(ReplyKind::Renewal, VTC, &format!("urn:uuid:{id}"))); + assert_eq!( + t.take(ReplyKind::Renewal, VTC, &id.to_string()), + Some(renewal()) + ); + assert!( + t.take(ReplyKind::Renewal, VTC, &id.to_string()).is_none(), + "answered once" + ); + } + + /// A renewal is keyed by its UUID, so a thread that is not one never + /// matches — and an upper-case rendering of the same UUID does. + #[test] + fn a_renewal_thread_is_normalised_to_its_uuid() { + let mut t = PendingReplies::new(); + let id = Uuid::new_v4(); + assert!(!t.record("not-a-uuid", VTC, renewal())); + assert!(t.record(&format!("urn:uuid:{id}"), VTC, renewal())); + let upper = id.to_string().to_uppercase(); + assert!(t.is_awaited(ReplyKind::Renewal, VTC, &upper)); + assert!(!t.is_awaited(ReplyKind::Renewal, VTC, "garbage")); + } + + /// A profile answer threads on the document id exactly: no prefix + /// stripping. + #[test] + fn a_profile_answer_is_taken_once_and_only_from_the_community_asked() { + let mut t = PendingReplies::new(); + t.record("urn:uuid:q1", "did:webvh:vtc", Purpose::CommunityProfile); + assert!( + t.take( + ReplyKind::CommunityProfile, + "did:webvh:mallory", + "urn:uuid:q1" + ) + .is_none() + ); + assert!( + t.take( + ReplyKind::CommunityProfile, + "did:webvh:vtc", + "urn:uuid:other" + ) + .is_none() + ); + assert!( + t.take(ReplyKind::CommunityProfile, "did:webvh:vtc", "q1") + .is_none(), + "exact thread only" + ); + assert_eq!( + t.take(ReplyKind::CommunityProfile, "did:webvh:vtc", "urn:uuid:q1"), + Some(Purpose::CommunityProfile) + ); + assert!( + t.take(ReplyKind::CommunityProfile, "did:webvh:vtc", "urn:uuid:q1") + .is_none(), + "answered once" + ); + } + + /// An election answer is admitted only on a thread sent to that community, + /// on either form of the thread, and no longer once forgotten. + #[test] + fn an_election_answer_is_admitted_only_on_its_thread() { + let mut t = PendingReplies::new(); + let thid = "urn:uuid:00000000-0000-4000-8000-0000000000bb"; + assert!(!t.is_awaited(ReplyKind::ElectionAnswer, VTC, thid)); + t.record(thid, VTC, Purpose::ElectionAnswer); + assert!(t.is_awaited(ReplyKind::ElectionAnswer, VTC, thid)); + assert!(t.is_awaited( + ReplyKind::ElectionAnswer, + VTC, + "00000000-0000-4000-8000-0000000000bb" + )); + assert!(!t.is_awaited(ReplyKind::ElectionAnswer, "did:key:z6MkSomeoneElse", thid)); + t.forget(ReplyKind::ElectionAnswer, thid); + assert!(!t.is_awaited(ReplyKind::ElectionAnswer, VTC, thid)); + } + + /// An election's community is compared by DID, so a `?src=` on the name it + /// answers under does not make it someone else. + #[test] + fn an_election_community_is_compared_by_did() { + let mut t = PendingReplies::new(); + let scid = "did:scid:vh:1:QmScid"; + t.record("t1", scid, Purpose::ElectionAnswer); + assert!(t.is_awaited( + ReplyKind::ElectionAnswer, + &format!("{scid}?src=vtc.example"), + "t1" + )); + // Renewals compare exactly. + let id = Uuid::new_v4().to_string(); + t.record(&id, scid, renewal()); + assert!(!t.is_awaited(ReplyKind::Renewal, &format!("{scid}?src=vtc.example"), &id)); + } + + /// Each kind waits its own window. + #[test] + fn each_kind_expires_on_its_own_window() { + let mut t = PendingReplies::new(); + let now = Instant::now(); + let id = Uuid::new_v4().to_string(); + t.record_at(&id, VTC, renewal(), now); + t.record_at("p", VTC, Purpose::CommunityProfile, now); + t.record_at("e", VTC, Purpose::ElectionAnswer, now); + + let before_election = now + Duration::from_secs(119); + assert!(t.is_awaited_at(ReplyKind::ElectionAnswer, VTC, "e", before_election)); + let after_election = now + Duration::from_secs(120); + assert!(!t.is_awaited_at(ReplyKind::ElectionAnswer, VTC, "e", after_election)); + assert!(t.is_awaited_at(ReplyKind::Renewal, VTC, &id, after_election)); + assert!(t.is_awaited_at(ReplyKind::CommunityProfile, VTC, "p", after_election)); + + let after_all = now + Duration::from_secs(600); + assert!(!t.is_awaited_at(ReplyKind::Renewal, VTC, &id, after_all)); + assert!( + t.take_at(ReplyKind::CommunityProfile, VTC, "p", after_all) + .is_none(), + "an expired request is not taken" + ); + } + + /// Each kind has its own cap, and a request past it is not recorded. + /// Expired requests make room. + #[test] + fn each_kind_is_capped_on_its_own() { + let mut t = PendingReplies::new(); + let now = Instant::now(); + for i in 0..64 { + assert!(t.record_at(&format!("e{i}"), VTC, Purpose::ElectionAnswer, now)); + } + assert!(!t.record_at("e-over", VTC, Purpose::ElectionAnswer, now)); + assert!(!t.is_awaited_at(ReplyKind::ElectionAnswer, VTC, "e-over", now)); + // Another kind is not crowded out. + for i in 0..256 { + assert!(t.record_at(&format!("p{i}"), VTC, Purpose::CommunityProfile, now)); + } + assert!(!t.record_at("p-over", VTC, Purpose::CommunityProfile, now)); + for _ in 0..64 { + assert!(t.record_at(&Uuid::new_v4().to_string(), VTC, renewal(), now)); + } + assert!(!t.record_at(&Uuid::new_v4().to_string(), VTC, renewal(), now)); + + // Once the election window passes, its requests are pruned on the next + // record and there is room again. + let later = now + ReplyKind::ElectionAnswer.ttl(); + assert!(t.record_at("e-later", VTC, Purpose::ElectionAnswer, later)); + } + + /// A take or check from the wrong community leaves the request waiting + /// for the right one; kinds do not answer for each other. + #[test] + fn the_wrong_community_or_kind_takes_nothing() { + let mut t = PendingReplies::new(); + let id = Uuid::new_v4().to_string(); + t.record(&id, VTC, renewal()); + assert!( + t.take(ReplyKind::Renewal, "did:example:mallory", &id) + .is_none() + ); + assert!(t.take(ReplyKind::ElectionAnswer, VTC, &id).is_none()); + assert!(t.take(ReplyKind::CommunityProfile, VTC, &id).is_none()); + assert!(t.take(ReplyKind::Renewal, VTC, &id).is_some()); + assert!(t.is_empty()); + } + + /// Forgetting removes only that kind's request on that thread. + #[test] + fn forgetting_is_per_kind() { + let mut t = PendingReplies::new(); + let id = Uuid::new_v4().to_string(); + t.record(&id, VTC, renewal()); + t.record(&id, VTC, Purpose::ElectionAnswer); + assert_eq!(t.len(), 2); + t.forget(ReplyKind::Renewal, &format!("urn:uuid:{id}")); + assert!(!t.is_awaited(ReplyKind::Renewal, VTC, &id)); + assert!(t.is_awaited(ReplyKind::ElectionAnswer, VTC, &id)); + } +} From 3741c257bb42c09cf54f83eaeae66d558d78a775 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 19:00:24 +1000 Subject: [PATCH 20/27] refactor: replace the reply-correlation statics with the runtime's PendingReplies renewal.rs PENDING, join.rs PROFILE_QUERIES and elections.rs AWAITING are gone. The runtime loop owns one PendingReplies and passes it explicitly: the send paths record into it on the loop thread before spawning (a renewal's id and a profile question's id are now chosen there), and inbound dispatch (verification_job, the community-reply gate, the renewal, refusal and profile handlers) consumes from it. Elections records and forgets through its Loop, its outcome and its reply path; a failed renewal send forgets its request in CommunityOutcome::apply. Headless commands (join, repos) hold their own table for apply_inline, and the core tests each use a local one instead of sharing process globals (#534). Signed-off-by: Glenn Gore --- openvtc-core/src/elections.rs | 54 +------ openvtc-core/src/elections/tests.rs | 14 -- openvtc-core/src/inbound/mod.rs | 57 +++++-- openvtc-core/src/inbound/tests.rs | 101 ++++++++++--- openvtc-core/src/join.rs | 74 +++------ openvtc-core/src/renewal.rs | 143 ++++-------------- openvtc-core/tests/inbound_dispatch_e2e.rs | 4 + openvtc/src/repos_cmd.rs | 6 + .../src/state_handler/background_dispatch.rs | 21 ++- .../src/state_handler/community_actions.rs | 124 ++++++++++++--- .../state_handler/community_profile_poll.rs | 93 +++++++----- .../src/state_handler/elections_actions.rs | 35 ++++- .../state_handler/elections_actions/tests.rs | 75 ++++++++- .../src/state_handler/inbound_glue_tests.rs | 1 + openvtc/src/state_handler/join_cmd.rs | 14 +- openvtc/src/state_handler/mod.rs | 6 + openvtc/src/state_handler/runtime/arms.rs | 12 +- .../src/state_handler/runtime/inbound_arm.rs | 6 +- openvtc/src/state_handler/runtime/mod.rs | 11 ++ openvtc/src/state_handler/runtime/timers.rs | 12 +- openvtc/src/state_handler/runtime_actions.rs | 23 +++ openvtc/src/state_handler/settings_actions.rs | 1 + 22 files changed, 548 insertions(+), 339 deletions(-) diff --git a/openvtc-core/src/elections.rs b/openvtc-core/src/elections.rs index 9a10c70c..a2bca91c 100644 --- a/openvtc-core/src/elections.rs +++ b/openvtc-core/src/elections.rs @@ -968,53 +968,13 @@ impl BallotDraft { // Answers from a community this persona is not a member of // **************************************************************************** -/// How long an answer from a community this persona holds no membership with is waited for. -const AWAITING_TTL: Duration = Duration::from_secs(120); - -/// The most such requests outstanding at once. -const MAX_AWAITING: usize = 64; - -/// Requests sent to a community this persona holds no membership with, by thread: an election -/// put to the members of a party the persona belongs to, held by a community it does not -/// (VTI-NTF-061, VTI-NTF-063). The inbound gate admits that community's answer only on a -/// thread recorded here; any other document from it has no standing. -static AWAITING: std::sync::LazyLock< - std::sync::Mutex>, -> = std::sync::LazyLock::new(Default::default); - -fn thread_key(thid: &str) -> &str { - thid.strip_prefix("urn:uuid:").unwrap_or(thid) -} - -/// Record that `thid` was sent to `community`, so its answer is admitted. -pub fn expect_answer(community: &str, thid: &str) { - if let Ok(mut a) = AWAITING.lock() { - a.retain(|_, (_, at)| at.elapsed() < AWAITING_TTL); - if a.len() < MAX_AWAITING { - a.insert( - thread_key(thid).to_string(), - (community.to_string(), std::time::Instant::now()), - ); - } - } -} - -/// Whether `thid` answers a request sent to `community` and still waited for. -#[must_use] -pub fn is_awaited(community: &str, thid: &str) -> bool { - AWAITING.lock().is_ok_and(|a| { - a.get(thread_key(thid)).is_some_and(|(c, at)| { - at.elapsed() < AWAITING_TTL && vta_sdk::did_scid::same_reference(c, community) - }) - }) -} - -/// The answer on `thid` was taken, or is no longer waited for. -pub fn answered(thid: &str) { - if let Ok(mut a) = AWAITING.lock() { - a.remove(thread_key(thid)); - } -} +// Requests sent to a community this persona holds no membership with — an +// election put to the members of a party the persona belongs to, held by a +// community it does not (VTI-NTF-061, VTI-NTF-063) — are recorded by thread in +// the caller's [`crate::pending_replies::PendingReplies`] (kind +// `ElectionAnswer`, 120 s, at most 64). The inbound gate admits that +// community's answer only on a thread recorded there; any other document from +// it has no standing. // **************************************************************************** // The member's VTA diff --git a/openvtc-core/src/elections/tests.rs b/openvtc-core/src/elections/tests.rs index 9dadb5c0..ff98e6c8 100644 --- a/openvtc-core/src/elections/tests.rs +++ b/openvtc-core/src/elections/tests.rs @@ -876,17 +876,3 @@ fn vti_vot_018_an_indeterminate_answer_says_to_try_again() { ); } } - -/// An answer from a community this persona is not a member of is admitted only on a thread -/// sent to it, and only from it. -#[test] -fn an_answer_from_a_community_we_do_not_belong_to_is_admitted_only_on_its_thread() { - let thid = "urn:uuid:00000000-0000-4000-8000-0000000000bb"; - assert!(!is_awaited(VTC, thid)); - expect_answer(VTC, thid); - assert!(is_awaited(VTC, thid)); - assert!(is_awaited(VTC, "00000000-0000-4000-8000-0000000000bb")); - assert!(!is_awaited("did:key:z6MkSomeoneElse", thid)); - answered(thid); - assert!(!is_awaited(VTC, thid)); -} diff --git a/openvtc-core/src/inbound/mod.rs b/openvtc-core/src/inbound/mod.rs index 1b6d9a2a..2b3a195c 100644 --- a/openvtc-core/src/inbound/mod.rs +++ b/openvtc-core/src/inbound/mod.rs @@ -22,6 +22,7 @@ use crate::messaging::{ is_trust_task_error_type, read_problem_report, require_thid, validate_did, verify_inbound_vrc, vet_vrc_issued, }; +use crate::pending_replies::{PendingReplies, ReplyKind}; use crate::personhood::{PERSONHOOD_ASSERT_RESPONSE_TYPE, PERSONHOOD_CHALLENGE_RESPONSE_TYPE}; use crate::vetting::protocol::JoinProtocol; use crate::{ @@ -461,6 +462,7 @@ impl VerifyJob { /// closed without a check result). fn verification_job( config: &Config, + pending: &PendingReplies, message: &Message, from_did: &str, recipient_did: &str, @@ -521,7 +523,7 @@ fn verification_job( let asked = message .thid .as_deref() - .is_some_and(|thid| crate::renewal::is_pending(from_did, thid)); + .is_some_and(|thid| pending.is_awaited(ReplyKind::Renewal, from_did, thid)); (member && asked).then(|| VerifyJob::Renewal { document: message.body.clone(), sender: from_did.to_string(), @@ -538,7 +540,7 @@ fn verification_job( // (VTI-NTF-063), which may be one we hold no membership with: only on // a thread we sent it. Family::ElectionsReply | Family::TrustTaskError if !member => { - awaited_election_answer(message, from_did).then(operational) + awaited_election_answer(pending, message, from_did).then(operational) } Family::CapabilityReply | Family::GitNsReply @@ -682,14 +684,14 @@ fn relationship_request_admissible( /// Whether `message` answers an elections request we sent `from_did` and still wait for — the /// only standing a community we hold no membership with has here. -fn awaited_election_answer(message: &Message, from_did: &str) -> bool { +fn awaited_election_answer(pending: &PendingReplies, message: &Message, from_did: &str) -> bool { let thread = message.thid.as_deref().or_else(|| { message .body .get("threadId") .and_then(serde_json::Value::as_str) }); - thread.is_some_and(|t| crate::elections::is_awaited(from_did, t)) + thread.is_some_and(|t| pending.is_awaited(ReplyKind::ElectionAnswer, from_did, t)) } /// An operational document that was not checked. @@ -726,6 +728,7 @@ fn community_reply_proven( from_did: &str, recipient_did: &str, cached: &mut Option, + pending: &PendingReplies, ) -> bool { if let Some(answer) = *cached { return answer; @@ -745,7 +748,7 @@ fn community_reply_proven( Ok(_) if config.account.memberships_for(from_did).is_empty() && !(crate::elections::is_reply_type(&message.typ) - && awaited_election_answer(message, from_did)) => + && awaited_election_answer(pending, message, from_did)) => { warn!(typ = %message.typ, "reply from a community we hold no membership with — ignored"); false @@ -825,6 +828,7 @@ pub async fn process_inbound_message( tdk: &TDK, service: &Messaging, seen: &mut SeenMessages, + pending: &mut PendingReplies, message: &Message, authenticated: Option<&str>, effects: &mut InboundEffects, @@ -839,6 +843,7 @@ pub async fn process_inbound_message( tdk, service, seen, + pending, message, authenticated, effects, @@ -854,6 +859,7 @@ async fn process_inbound( tdk: &TDK, service: &Messaging, seen: &mut SeenMessages, + pending: &mut PendingReplies, message: &Message, authenticated: Option<&str>, effects: &mut InboundEffects, @@ -971,7 +977,7 @@ async fn process_inbound( let recheck = matches!(arrival, Arrival::Returning(PreVerified::Barrier)); if !returning || recheck { let job = match ( - verification_job(config, message, &from_did, &recipient_did), + verification_job(config, pending, message, &from_did, &recipient_did), &arrival, ) { (Some(job), _) => Some(job), @@ -1005,6 +1011,7 @@ async fn process_inbound( let mut cx = Cx { tdk, service, + pending, message, from_did, recipient_did, @@ -1058,6 +1065,9 @@ async fn process_inbound( struct Cx<'a> { tdk: &'a TDK, service: &'a Messaging, + /// The requests we wait on an answer for: the gate for answers admitted + /// only because we asked (a renewal, a profile, an election). + pending: &'a mut PendingReplies, /// The message as routed (opened out of its binding envelope). message: &'a Message, /// The authenticated sender. @@ -1147,6 +1157,7 @@ fn capability_reply( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { capability_replies.push((from_did.to_string(), thid, reply)); @@ -1185,6 +1196,7 @@ fn git_ns_reply( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { // The sender and the document's issuer travel with the answer: the @@ -1228,6 +1240,7 @@ fn rooms_reply( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { rooms_replies.push(ThreadedReply { @@ -1271,6 +1284,7 @@ fn elections_reply( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { elections_replies.push(ThreadedReply { @@ -1811,7 +1825,7 @@ async fn renewal_response( // else answers nothing the member is waiting on. The request is // not consumed — a forged reply must not be able to cancel the // genuine one still on its way. - if crate::renewal::is_pending(&from_did, thid) { + if cx.pending.is_awaited(ReplyKind::Renewal, &from_did, thid) { config .account .note_credential_refused(&from_did, Some(&e.to_string())); @@ -1834,8 +1848,9 @@ async fn renewal_response( warn!(vtc = %from_did, reason = %e, "renewal reply refused"); return Ok(false); } - match crate::renewal::take_pending(&from_did, thid) { - Some(member) if member == verified.member_did() => {} + match cx.pending.take(ReplyKind::Renewal, &from_did, thid) { + Some(crate::pending_replies::Purpose::Renewal { member_did }) + if member_did == verified.member_did() => {} _ => { warn!(vtc = %from_did, "renewal reply answers no renewal of ours — ignored"); return Ok(false); @@ -1957,6 +1972,7 @@ async fn trust_task_error( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { capability_replies.push((from_did.to_string(), thid, reply)); @@ -1980,6 +1996,7 @@ async fn trust_task_error( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { // The sender and the document's issuer travel with the answer: the @@ -2008,6 +2025,7 @@ async fn trust_task_error( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { rooms_replies.push(ThreadedReply { @@ -2034,6 +2052,7 @@ async fn trust_task_error( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { elections_replies.push(ThreadedReply { @@ -2133,7 +2152,7 @@ async fn trust_task_error( // refusal of a join and drop it for matching no pending request. if is_trust_task_error_type(&message.typ) && let Some(thid) = message.thid.as_deref() - && crate::renewal::is_pending(&from_did, thid) + && cx.pending.is_awaited(ReplyKind::Renewal, &from_did, thid) { if !community_reply_proven( config, @@ -2142,10 +2161,11 @@ async fn trust_task_error( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { return Ok(false); } - let _ = crate::renewal::take_pending(&from_did, thid); + let _ = cx.pending.take(ReplyKind::Renewal, &from_did, thid); let outcome = crate::renewal::RenewalOutcome::Refused( crate::renewal::RenewalRefusal::read(&message.body), ); @@ -2268,10 +2288,11 @@ fn community_profile_response(config: &mut Config, cx: &mut Cx<'_>) -> Result, ) -> bool { @@ -2936,6 +2960,7 @@ pub async fn apply_inline( tdk, messaging, seen, + pending, &message, authenticated, &mut effects, diff --git a/openvtc-core/src/inbound/tests.rs b/openvtc-core/src/inbound/tests.rs index 6356be9f..205cf7e4 100644 --- a/openvtc-core/src/inbound/tests.rs +++ b/openvtc-core/src/inbound/tests.rs @@ -110,6 +110,7 @@ async fn a_credential_is_verified_off_the_loop_before_anything_is_applied() { let service = crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); let mut seen = SeenMessages::new(); + let mut pending = PendingReplies::default(); // A forged (unsigned) credential: deferred, then refused. let mut config = pending_config(&vtc); @@ -122,6 +123,7 @@ async fn a_credential_is_verified_off_the_loop_before_anything_is_applied() { &tdk, &service, &mut seen, + &mut pending, &m, m.from.as_deref(), &mut effects, @@ -143,6 +145,7 @@ async fn a_credential_is_verified_off_the_loop_before_anything_is_applied() { &tdk, &service, &mut seen, + &mut pending, &deferred.message, deferred.message.from.as_deref(), &mut effects, @@ -174,6 +177,7 @@ async fn a_credential_is_verified_off_the_loop_before_anything_is_applied() { &tdk, &service, &mut seen, + &mut pending, &m, m.from.as_deref(), &mut effects, @@ -194,6 +198,7 @@ async fn a_credential_is_verified_off_the_loop_before_anything_is_applied() { &tdk, &service, &mut seen, + &mut pending, &deferred.message, deferred.message.from.as_deref(), &mut effects, @@ -219,6 +224,7 @@ async fn a_hidden_vetting_answer_reaches_the_question_it_answers() { let service = crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); let mut seen = SeenMessages::new(); + let mut pending = PendingReplies::default(); let mut config = pending_config(&vtc); let persona = *config.account.personas.keys().next().unwrap(); @@ -264,6 +270,7 @@ async fn a_hidden_vetting_answer_reaches_the_question_it_answers() { &tdk, &service, &mut seen, + &mut pending, &answer, answer.from.as_deref(), &mut effects, @@ -292,6 +299,7 @@ async fn a_check_result_for_another_credential_is_not_taken() { let service = crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); let mut seen = SeenMessages::new(); + let mut pending = PendingReplies::default(); let mut config = pending_config(&vtc); let other = issue(&vtc, &key, membership_vmc(&vtc, &key).await).await; @@ -301,6 +309,7 @@ async fn a_check_result_for_another_credential_is_not_taken() { &tdk, &service, &mut seen, + &mut pending, &other, other.from.as_deref(), &mut effects, @@ -320,6 +329,7 @@ async fn a_check_result_for_another_credential_is_not_taken() { &tdk, &service, &mut seen, + &mut pending, &m, m.from.as_deref(), &mut effects, @@ -343,6 +353,7 @@ async fn a_removal_notice_waits_for_its_check() { let service = crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); let mut seen = SeenMessages::new(); + let mut pending = PendingReplies::default(); let mut config = pending_config(&vtc); let m = Message::build( uuid::Uuid::new_v4().to_string(), @@ -358,6 +369,7 @@ async fn a_removal_notice_waits_for_its_check() { &tdk, &service, &mut seen, + &mut pending, &m, m.from.as_deref(), &mut effects, @@ -487,7 +499,13 @@ async fn a_renewal_reply_is_checked_then_stored_clearing_the_retired_notices() { let tdk = test_tdk().await; let mut config = retired_config(&vtc); let request = uuid::Uuid::new_v4(); - crate::renewal::record_request(request, &vtc, PERSONA); + let mut pending = PendingReplies::default(); + assert!(crate::renewal::record_request( + &mut pending, + request, + &vtc, + PERSONA + )); let vmc = membership_vmc(&vtc, &key).await; let vac = role_vac(&vtc, &key).await; @@ -506,7 +524,7 @@ async fn a_renewal_reply_is_checked_then_stored_clearing_the_retired_notices() { ) .await; - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + let effects = dispatch_waiting(&mut config, &mut pending, &tdk, &m, Arrival::FRESH).await; let deferred = effects.deferred.expect("set aside for its check"); assert!(matches!(deferred.job, VerifyJob::Renewal { .. })); assert!( @@ -518,8 +536,9 @@ async fn a_renewal_reply_is_checked_then_stored_clearing_the_retired_notices() { let pre = deferred.job.run(tdk.clone()).await; assert!(matches!(&pre, PreVerified::Renewal(Ok(_)))); - let effects = dispatch( + let effects = dispatch_waiting( &mut config, + &mut pending, &tdk, &deferred.message, Arrival::Returning(pre), @@ -554,7 +573,8 @@ async fn a_renewal_reply_is_checked_then_stored_clearing_the_retired_notices() { assert!(outcome.describe().contains("press m")); // Answered once: the same reply again is not taken. - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + let effects = dispatch_waiting(&mut config, &mut pending, &tdk, &m, Arrival::FRESH).await; + assert!(pending.is_empty(), "the reply took its request"); assert!(effects.deferred.is_none()); assert!(effects.renewals.is_empty()); } @@ -597,7 +617,13 @@ async fn a_renewal_refused_as_not_a_member_is_reported() { let tdk = test_tdk().await; let mut config = retired_config(&vtc); let request = uuid::Uuid::new_v4(); - crate::renewal::record_request(request, &vtc, PERSONA); + let mut pending = PendingReplies::default(); + assert!(crate::renewal::record_request( + &mut pending, + request, + &vtc, + PERSONA + )); let m = community_reply( &vtc, &key, @@ -611,16 +637,7 @@ async fn a_renewal_refused_as_not_a_member_is_reported() { ) .await; - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; - let deferred = effects.deferred.expect("set aside for its check"); - let pre = deferred.job.run(tdk.clone()).await; - let effects = dispatch( - &mut config, - &tdk, - &deferred.message, - Arrival::Returning(pre), - ) - .await; + let effects = dispatch_checked_waiting(&mut config, &mut pending, &tdk, &m).await; let [(_, outcome)] = effects.renewals.as_slice() else { panic!("one renewal outcome"); @@ -632,7 +649,10 @@ async fn a_renewal_refused_as_not_a_member_is_reported() { let record = &config.account.memberships_for(&vtc)[0]; assert!(record.status.is_active(), "a refused renewal ends nothing"); assert_eq!(record.retired_credentials.len(), 2); - assert!(!crate::renewal::is_pending(&vtc, &request.to_string())); + assert!( + !pending.is_awaited(ReplyKind::Renewal, &vtc, &request.to_string()), + "the refusal took the request" + ); } /// Collects what is logged at `WARN` and above while it is the default @@ -669,10 +689,27 @@ impl Warnings { /// Dispatch `m` once it has been through its off-loop check, as the loop /// does: on arrival it is set aside, then handled with the check's result. async fn dispatch_checked(config: &mut Config, tdk: &TDK, m: &Message) -> InboundEffects { - let effects = dispatch(config, tdk, m, Arrival::FRESH).await; + dispatch_checked_waiting(config, &mut PendingReplies::default(), tdk, m).await +} + +/// [`dispatch_checked`], with the requests in `pending` waited on. +async fn dispatch_checked_waiting( + config: &mut Config, + pending: &mut PendingReplies, + tdk: &TDK, + m: &Message, +) -> InboundEffects { + let effects = dispatch_waiting(config, pending, tdk, m, Arrival::FRESH).await; let deferred = effects.deferred.expect("set aside for its check"); let pre = deferred.job.run(tdk.clone()).await; - dispatch(config, tdk, &deferred.message, Arrival::Returning(pre)).await + dispatch_waiting( + config, + pending, + tdk, + &deferred.message, + Arrival::Returning(pre), + ) + .await } /// A witness's answer reaches the witnessing waiting on it whoever sent it — a witness is @@ -908,20 +945,35 @@ async fn an_election_answer_from_a_non_member_community_needs_a_thread_we_sent() // On a thread we sent it: checked, then handed to the Elections panel. let thid = format!("urn:uuid:{}", uuid::Uuid::new_v4()); - crate::elections::expect_answer(&holder, &thid); + let mut pending = PendingReplies::default(); + pending.record( + &thid, + &holder, + crate::pending_replies::Purpose::ElectionAnswer, + ); let m = answer(thid.clone()).await; - let effects = dispatch_checked(&mut config, &tdk, &m).await; + let effects = dispatch_checked_waiting(&mut config, &mut pending, &tdk, &m).await; assert_eq!(effects.elections_replies.len(), 1); assert_eq!(effects.elections_replies[0].thid, thid); - crate::elections::answered(&thid); } -/// Dispatch `m` as `arrival`, returning the effects. +/// Dispatch `m` as `arrival`, returning the effects. Nothing is waited on. async fn dispatch( config: &mut Config, tdk: &TDK, m: &Message, arrival: Arrival<'_>, +) -> InboundEffects { + dispatch_waiting(config, &mut PendingReplies::default(), tdk, m, arrival).await +} + +/// [`dispatch`], with the requests in `pending` waited on. +async fn dispatch_waiting( + config: &mut Config, + pending: &mut PendingReplies, + tdk: &TDK, + m: &Message, + arrival: Arrival<'_>, ) -> InboundEffects { let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); let service = @@ -933,6 +985,7 @@ async fn dispatch( tdk, &service, &mut seen, + pending, m, m.from.as_deref(), &mut effects, @@ -1268,12 +1321,14 @@ mod sender_binding { let service = crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); let mut seen = SeenMessages::new(); + let mut pending = PendingReplies::default(); let mut effects = InboundEffects::default(); process_inbound_message( config, &tdk, &service, &mut seen, + &mut pending, m, authenticated, &mut effects, @@ -1467,6 +1522,7 @@ mod sender_binding { let service = crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); let mut seen = SeenMessages::new(); + let mut pending = PendingReplies::default(); let mut config = config_with(RelationshipState::Established, "rel"); let m = message( protocol_urls::VRC_REQUEST, @@ -1481,6 +1537,7 @@ mod sender_binding { &tdk, &service, &mut seen, + &mut pending, &m, authenticated, &mut effects, diff --git a/openvtc-core/src/join.rs b/openvtc-core/src/join.rs index c593d210..f2b4eb83 100644 --- a/openvtc-core/src/join.rs +++ b/openvtc-core/src/join.rs @@ -249,44 +249,23 @@ async fn send_to_community( .await } -/// Profile questions we have outstanding: document id → (community, when). -/// In memory: a question is this process's, and its answer after a restart is -/// simply not taken (the next launch asks again). -static PROFILE_QUERIES: std::sync::LazyLock< - std::sync::Mutex>, -> = std::sync::LazyLock::new(Default::default); - -/// How long a profile question waits for its answer. -const PROFILE_QUERY_TTL: std::time::Duration = std::time::Duration::from_secs(600); -/// Most profile questions outstanding at once. -const MAX_PROFILE_QUERIES: usize = 256; - -fn remember_profile_query(document_id: &str, vtc_did: &str) { - if let Ok(mut q) = PROFILE_QUERIES.lock() { - q.retain(|_, (_, at)| at.elapsed() < PROFILE_QUERY_TTL); - if q.len() < MAX_PROFILE_QUERIES { - q.insert( - document_id.to_string(), - (vtc_did.to_string(), std::time::Instant::now()), - ); - } - } -} - -/// Whether `thid` answers a profile question we put to `vtc_did` (and is not -/// yet answered). Consumes it: a question is answered once. -#[must_use] -pub fn take_profile_query(vtc_did: &str, thid: &str) -> bool { - let Ok(mut q) = PROFILE_QUERIES.lock() else { - return false; - }; - match q.get(thid) { - Some((vtc, at)) if vtc == vtc_did && at.elapsed() < PROFILE_QUERY_TTL => { - q.remove(thid); - true - } - _ => false, - } +/// Open a profile question to `vtc_did`: a fresh document id, recorded in +/// `pending` (kind `CommunityProfile`) so the answer threading on it is taken. +/// Done on the thread that owns `pending`, before the send +/// ([`send_community_profile_show`]) is spawned, so an answer racing the send +/// is still recognised. In memory: a question is this process's, and its answer +/// after a restart is simply not taken (the next launch asks again). +pub fn open_profile_query( + pending: &mut crate::pending_replies::PendingReplies, + vtc_did: &str, +) -> String { + let document_id = format!("urn:uuid:{}", Uuid::new_v4()); + pending.record( + &document_id, + vtc_did, + crate::pending_replies::Purpose::CommunityProfile, + ); + document_id } /// Ask a community for its profile (`vtc/community/profile/show/0.1`) so we can @@ -300,6 +279,9 @@ pub fn take_profile_query(vtc_did: &str, thid: &str) -> bool { /// awaited here. Background, best-effort — a send failure is the caller's to log, /// not surface (the value only seeds a form default, and its absence is a valid, /// pairwise-defaulting state). +/// +/// `document_id` is the question's id, from [`open_profile_query`]. +#[allow(clippy::too_many_arguments)] pub async fn send_community_profile_show( atm: &ATM, profile: &Arc, @@ -308,10 +290,8 @@ pub async fn send_community_profile_show( vtc_did: &str, mediator_did: &str, tsp_mediator_did: Option<&str>, + document_id: String, ) -> Result<(), OpenVTCError> { - let document_id = format!("urn:uuid:{}", Uuid::new_v4()); - // Remembered before the send, so an answer racing it is still recognised. - remember_profile_query(&document_id, vtc_did); // The show payload carries only an optional extension bag; an empty object is // the request. The VTC reads the body as a Trust Task document, so it must be // wrapped (a bare payload is rejected `malformedRequest`). @@ -1367,18 +1347,6 @@ mod tests { assert!(err.contains("revocation status"), "{err}"); } - #[test] - fn a_profile_answer_is_taken_once_and_only_from_the_community_asked() { - remember_profile_query("urn:uuid:q1", "did:webvh:vtc"); - assert!(!take_profile_query("did:webvh:mallory", "urn:uuid:q1")); - assert!(!take_profile_query("did:webvh:vtc", "urn:uuid:other")); - assert!(take_profile_query("did:webvh:vtc", "urn:uuid:q1")); - assert!( - !take_profile_query("did:webvh:vtc", "urn:uuid:q1"), - "answered once" - ); - } - #[test] fn validate_accepts_a_complete_vic() { assert!(validate_invitation_credential(&complete_vic()).is_ok()); diff --git a/openvtc-core/src/renewal.rs b/openvtc-core/src/renewal.rs index ffadbbfc..07de2b6c 100644 --- a/openvtc-core/src/renewal.rs +++ b/openvtc-core/src/renewal.rs @@ -43,10 +43,6 @@ //! [`crate::members`]); the community drops it on renewal. The caller sends a //! fresh one for the new grant, as the join flow does on admission. -use std::collections::HashMap; -use std::sync::{LazyLock, Mutex}; -use std::time::{Duration, Instant}; - use crate::specs::vtc::members::renew::v0_1 as renew; use affinidi_did_resolver_cache_sdk::DIDCacheClient; use affinidi_tdk::secrets_resolver::secrets::Secret; @@ -75,87 +71,30 @@ pub const MEMBERS_RENEW_RESPONSE_TYPE: &str = pub const RENEW_NOT_MEMBER: &str = renew::error_codes::NOT_MEMBER.code; // ─── Outstanding requests ──────────────────────────────────────────────── - -/// Renewals we have asked for: request id → who asked whom, and when. -/// -/// In memory, like the community-profile questions in [`crate::join`]: a -/// renewal is this process's, and an answer arriving after a restart is not -/// taken — the member asks again, which is harmless (renewal is idempotent on -/// the community's side, re-minting into the same status-list slot). -static PENDING: LazyLock>> = LazyLock::new(Default::default); - -struct Pending { - vtc_did: String, - member_did: String, - at: Instant, -} - -/// How long a renewal waits for its answer. -const PENDING_TTL: Duration = Duration::from_secs(600); - -/// Most renewals outstanding at once. -const MAX_PENDING: usize = 64; - -/// The request id a reply's `thid` names. The request is sent with its -/// document id as the DIDComm message id (`urn:uuid:`), and a community -/// may thread on either form. -fn request_id_of(thid: &str) -> Option { - Uuid::parse_str(thid.strip_prefix("urn:uuid:").unwrap_or(thid)).ok() -} - -/// Record that `member_did` asked `vtc_did` to renew, under `request_id` — the -/// id the reply threads on. [`request_renewal`] does this itself; it is exposed -/// for a caller that delivers the document by another route (and for tests of -/// the reply's handling). -pub fn record_request(request_id: Uuid, vtc_did: &str, member_did: &str) { - if let Ok(mut pending) = PENDING.lock() { - pending.retain(|_, p| p.at.elapsed() < PENDING_TTL); - if pending.len() < MAX_PENDING { - pending.insert( - request_id, - Pending { - vtc_did: vtc_did.to_string(), - member_did: member_did.to_string(), - at: Instant::now(), - }, - ); - } - } -} - -fn forget(request_id: Uuid) { - if let Ok(mut pending) = PENDING.lock() { - pending.remove(&request_id); - } -} - -/// Whether `thid` answers a renewal we asked `vtc_did` for, still unanswered. -/// Does not consume it: a reply is taken ([`take_pending`]) only once it has -/// been verified. -#[must_use] -pub fn is_pending(vtc_did: &str, thid: &str) -> bool { - let Some(id) = request_id_of(thid) else { - return false; - }; - PENDING.lock().is_ok_and(|pending| { - pending - .get(&id) - .is_some_and(|p| p.vtc_did == vtc_did && p.at.elapsed() < PENDING_TTL) - }) -} - -/// Take the renewal `thid` answers, if it is one we asked `vtc_did` for. -/// Returns the member DID that asked. Consumes it: a renewal is answered once. -#[must_use] -pub fn take_pending(vtc_did: &str, thid: &str) -> Option { - let id = request_id_of(thid)?; - let mut pending = PENDING.lock().ok()?; - match pending.get(&id) { - Some(p) if p.vtc_did == vtc_did && p.at.elapsed() < PENDING_TTL => { - pending.remove(&id).map(|p| p.member_did) - } - _ => None, - } +// +// Renewals we have asked for are recorded in the caller's +// [`crate::pending_replies::PendingReplies`] (kind `Renewal`): request id → +// who asked whom, and when. In memory — a renewal is this process's, and an +// answer arriving after a restart is not taken; the member asks again, which +// is harmless (renewal is idempotent on the community's side, re-minting into +// the same status-list slot). + +/// Record in `pending` that `member_did` asked `vtc_did` to renew, under +/// `request_id` — the id the reply threads on. Returns whether it was recorded +/// (not past the table's cap for renewals). +pub fn record_request( + pending: &mut crate::pending_replies::PendingReplies, + request_id: Uuid, + vtc_did: &str, + member_did: &str, +) -> bool { + pending.record( + &request_id.to_string(), + vtc_did, + crate::pending_replies::Purpose::Renewal { + member_did: member_did.to_string(), + }, + ) } // ─── The request ───────────────────────────────────────────────────────── @@ -169,25 +108,20 @@ pub fn take_pending(vtc_did: &str, thid: &str) -> Option { /// asked for them. It rides the DIDComm binding envelope like every other /// member verb ([`crate::members::submit_member_vmc`]). /// -/// Returns the request id the community's reply threads on. Nothing is awaited: +/// `request_id` is the id the community's reply threads on. The caller records +/// it ([`record_request`]) in the table its inbound handling consumes from +/// *before* calling this — so a reply quicker than the return from the send +/// still finds its request — and forgets it if this fails. Nothing is awaited: /// the reply arrives asynchronously and is verified by /// [`verify_renewal_response`]. pub async fn request_renewal( route: &crate::members::Delivery<'_>, signer: &Secret, -) -> Result { - let request_id = Uuid::new_v4(); + request_id: Uuid, +) -> Result<(), OpenVTCError> { let document_id = format!("urn:uuid:{request_id}"); let body = build_renew_request(route.member_did, route.vtc_did, &document_id, signer).await?; - - // Remembered before the send, so a reply quicker than the return from - // the send still finds its request. - record_request(request_id, route.vtc_did, route.member_did); - if let Err(e) = crate::community_send::send_document(route, document_id, body).await { - forget(request_id); - return Err(e); - } - Ok(request_id) + crate::community_send::send_document(route, document_id, body).await } /// Build the signed `vtc/members/renew/0.1` document — split out from @@ -716,21 +650,6 @@ mod tests { assert_eq!(doc["proof"]["proofPurpose"], "authentication"); } - /// A reply is taken only for a renewal we asked that community for, and - /// only once — on either form of the thread id. - #[test] - fn a_reply_is_bound_to_the_request_it_answers() { - let id = Uuid::new_v4(); - record_request(id, "did:example:vtc", MEMBER); - assert!(!is_pending("did:example:other", &id.to_string())); - assert!(is_pending("did:example:vtc", &format!("urn:uuid:{id}"))); - assert_eq!( - take_pending("did:example:vtc", &id.to_string()).as_deref(), - Some(MEMBER) - ); - assert!(take_pending("did:example:vtc", &id.to_string()).is_none()); - } - /// **Success.** A verified renewal stores both credentials, clears the /// pre-v1 notices they answer, and reports the roles. #[tokio::test] diff --git a/openvtc-core/tests/inbound_dispatch_e2e.rs b/openvtc-core/tests/inbound_dispatch_e2e.rs index c37e4e8a..4eb15c1e 100644 --- a/openvtc-core/tests/inbound_dispatch_e2e.rs +++ b/openvtc-core/tests/inbound_dispatch_e2e.rs @@ -36,6 +36,7 @@ use openvtc_core::config::{ use openvtc_core::didcomm::{Messaging, routes_inbound_type}; use openvtc_core::inbound::{Arrival, InboundEffects, process_inbound_message}; use openvtc_core::messaging::SeenMessages; +use openvtc_core::pending_replies::PendingReplies; use openvtc_core::protocol_urls; use openvtc_core::relationships::{ BindingRole, Relationship, RelationshipRequestBody, RelationshipState, did_binding_proofs, @@ -234,6 +235,7 @@ async fn dispatch(config: &mut Config, tdk: &TDK, received: &Received) -> (bool, let service: Messaging = openvtc_core::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); let mut seen = SeenMessages::new(); + let mut pending = PendingReplies::default(); let nobody = |_: &str| false; let mut arrival = Arrival::New { waiting: &nobody }; let mut message = received.message.clone(); @@ -245,6 +247,7 @@ async fn dispatch(config: &mut Config, tdk: &TDK, received: &Received) -> (bool, tdk, &service, &mut seen, + &mut pending, &message, received.authenticated.as_deref(), &mut effects, @@ -463,6 +466,7 @@ async fn a_type_no_handler_claims_is_refused_by_the_gate() { &tdk, &service, &mut SeenMessages::new(), + &mut PendingReplies::default(), &received.message, received.authenticated.as_deref(), &mut effects, diff --git a/openvtc/src/repos_cmd.rs b/openvtc/src/repos_cmd.rs index c441e022..c233a07f 100644 --- a/openvtc/src/repos_cmd.rs +++ b/openvtc/src/repos_cmd.rs @@ -22,6 +22,7 @@ use openvtc_core::didcomm::{self, DIDCommEvent, Messaging}; use openvtc_core::git_ns::{self, GitRight, Reply, Request}; use openvtc_core::git_workspace::{self, CLONE_TIMEOUT, RepoCoords, WorkspaceSettings}; use openvtc_core::messaging::SeenMessages; +use openvtc_core::pending_replies::PendingReplies; use tokio::sync::mpsc; use crate::colors::{CLI_ERROR, CLI_INFO, Themed}; @@ -387,6 +388,10 @@ async fn ask( #[derive(Default)] struct Others { seen: SeenMessages, + /// Replies this command waits on by thread. It asks nothing the inbound + /// gate admits only on a recorded thread, so this stays empty; it is the + /// command's own, as the runtime loop's is its own. + pending: PendingReplies, handled: usize, } @@ -452,6 +457,7 @@ async fn await_reply( tdk, messaging, &mut others.seen, + &mut others.pending, &message, authenticated.as_deref(), ) diff --git a/openvtc/src/state_handler/background_dispatch.rs b/openvtc/src/state_handler/background_dispatch.rs index a4d7a366..72e84d2b 100644 --- a/openvtc/src/state_handler/background_dispatch.rs +++ b/openvtc/src/state_handler/background_dispatch.rs @@ -542,6 +542,7 @@ pub(crate) fn apply_outcome( config: &mut Config, save: &mut SaveScheduler, in_flight: &mut InFlight, + pending_replies: &mut openvtc_core::pending_replies::PendingReplies, outcome: DispatchOutcome, ) -> AfterApply { let domain = outcome.domain(); @@ -629,7 +630,7 @@ pub(crate) fn apply_outcome( } DispatchOutcome::AgentNameManage(outcome) => outcome.apply(state, config, save), DispatchOutcome::Community(outcome) => { - let pending = outcome.apply(state, config, save); + let pending = outcome.apply(state, config, save, pending_replies); in_flight.finish(domain); return match pending { Some((vtc, persona)) => AfterApply::Deregister(vtc, persona), @@ -663,7 +664,7 @@ pub(crate) fn apply_outcome( } DispatchOutcome::Rooms(outcome) => outcome.apply(state), DispatchOutcome::RoomsVta(outcome) => outcome.apply(state), - DispatchOutcome::Elections(outcome) => outcome.apply(state), + DispatchOutcome::Elections(outcome) => outcome.apply(state, pending_replies), DispatchOutcome::ElectionsVta(outcome) => outcome.apply(state), DispatchOutcome::ElectionsCast(outcome) => outcome.apply(state), // Merged, not replaced. A sweep only carries the targets it was given, @@ -821,6 +822,7 @@ mod tests { &mut config, &mut save, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), DispatchOutcome::MediatorReconnect(ReconnectOutcome::Connected), ); @@ -850,6 +852,7 @@ mod tests { &mut config, &mut save, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), DispatchOutcome::MediatorReconnect(ReconnectOutcome::Failed { reason: "dead mediator".into(), class: openvtc_core::errors::ErrorClass::Unreachable, @@ -884,6 +887,7 @@ mod tests { &mut config, &mut save, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), DispatchOutcome::Panicked(DispatchDomain::Relationship), ); @@ -985,7 +989,14 @@ mod tests { } } Some(outcome) = dispatch_rx.recv() => { - apply_outcome(&mut state, &mut config, &mut save, &mut in_flight, outcome); + apply_outcome( + &mut state, + &mut config, + &mut save, + &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), + outcome, + ); } } }; @@ -1023,6 +1034,7 @@ mod tests { &mut config, &mut save, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), DispatchOutcome::AgentName(vec![ ( "did:webvh:example.com:alice".to_string(), @@ -1058,6 +1070,7 @@ mod tests { &mut config, &mut save2, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), DispatchOutcome::AgentName(vec![ ( "did:webvh:example.com:alice".to_string(), @@ -1099,6 +1112,7 @@ mod progress_tests { &mut config, &mut save, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), DispatchOutcome::Progress(ProgressUpdate::PersonaMint("Minting the DID…".into())), ); @@ -1132,6 +1146,7 @@ mod progress_tests { &mut config, &mut save, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), DispatchOutcome::Progress(ProgressUpdate::PersonaMint("Minting…".into())), ); diff --git a/openvtc/src/state_handler/community_actions.rs b/openvtc/src/state_handler/community_actions.rs index 87a8467d..12c36abc 100644 --- a/openvtc/src/state_handler/community_actions.rs +++ b/openvtc/src/state_handler/community_actions.rs @@ -26,6 +26,7 @@ use crate::state_handler::save_coalesce::SaveScheduler; use crate::state_handler::state::State; use openvtc_core::config::Config; use openvtc_core::config::account::PersonaId; +use openvtc_core::pending_replies::{PendingReplies, ReplyKind}; /// Which document a job sends to the community. pub(crate) enum Verb { @@ -77,8 +78,13 @@ pub(crate) enum Verb { /// [`openvtc_core::inbound`]. /// /// `document_signer` is the persona's authentication key: the task - /// declares `proof` REQUIRED. - Renew { document_signer: Box }, + /// declares `proof` REQUIRED. `request_id` is the id the reply threads on, + /// recorded in the runtime's [`PendingReplies`] on the loop thread before + /// this job is spawned ([`openvtc_core::renewal::record_request`]). + Renew { + document_signer: Box, + request_id: uuid::Uuid, + }, } /// What a job did, for the apply path to report. @@ -205,19 +211,28 @@ impl CommunityJob { ) .await .map(|_| ()), - Verb::Renew { document_signer } => openvtc_core::renewal::request_renewal( - &openvtc_core::members::Delivery { - atm: &self.atm, - profile: &self.profile, - member_did: &self.member_did, - vtc_did: &self.vtc_did, - mediator_did: &self.mediator, - tsp_mediator_did: tsp_mediator.as_deref(), - }, + Verb::Renew { document_signer, - ) - .await - .map(|_| ()), + request_id, + } => { + openvtc_core::renewal::request_renewal( + &openvtc_core::members::Delivery { + atm: &self.atm, + profile: &self.profile, + member_did: &self.member_did, + vtc_did: &self.vtc_did, + mediator_did: &self.mediator, + tsp_mediator_did: tsp_mediator.as_deref(), + }, + document_signer, + *request_id, + ) + .await + } + }; + let renewal_request = match &self.verb { + Verb::Renew { request_id, .. } => Some(*request_id), + _ => None, }; CommunityOutcome { vtc_did: self.vtc_did, @@ -225,6 +240,7 @@ impl CommunityJob { performed, error: result.err().map(|e| format!("{e}")), issued, + renewal_request, } } } @@ -240,6 +256,9 @@ pub(crate) struct CommunityOutcome { /// own acknowledgement. Carried back rather than written by the job, because /// the job holds no `Config`: every record change happens on the apply path. issued: Option, + /// The renewal's request id, waited on in the runtime's [`PendingReplies`] + /// from before the send: a send that failed is no longer waited on. + renewal_request: Option, } impl CommunityOutcome { @@ -254,7 +273,14 @@ impl CommunityOutcome { state: &mut State, config: &mut Config, save: &mut SaveScheduler, + pending_replies: &mut PendingReplies, ) -> Option<(String, PersonaId)> { + // A renewal that never left answers nothing. + if self.error.is_some() + && let Some(id) = self.renewal_request + { + pending_replies.forget(ReplyKind::Renewal, &id.to_string()); + } // Set through a local rather than a long-lived `&mut` into `state`: // two of the arms also want to log, which needs `main_page` again. fn status(state: &mut State, msg: Status) { @@ -393,6 +419,7 @@ mod tests { performed, error: error.map(ToString::to_string), issued: None, + renewal_request: None, } } @@ -415,7 +442,12 @@ mod tests { let mut config = test_config(); let mut save = SaveScheduler::new("test"); - let deregister = outcome(Performed::Leave, None).apply(&mut state, &mut config, &mut save); + let deregister = outcome(Performed::Leave, None).apply( + &mut state, + &mut config, + &mut save, + &mut PendingReplies::default(), + ); assert_eq!(deregister.map(|(v, _)| v).as_deref(), Some(VTC)); assert!(save.is_pending(), "the record change must be persisted"); @@ -433,6 +465,7 @@ mod tests { &mut state, &mut config, &mut save, + &mut PendingReplies::default(), ); assert!(deregister.is_none()); @@ -456,8 +489,12 @@ mod tests { let mut config = test_config(); let mut save = SaveScheduler::new("test"); - let deregister = - outcome(Performed::IssueVmc, None).apply(&mut state, &mut config, &mut save); + let deregister = outcome(Performed::IssueVmc, None).apply( + &mut state, + &mut config, + &mut save, + &mut PendingReplies::default(), + ); assert!(deregister.is_none()); assert!(!save.is_pending(), "nothing to persist"); @@ -487,6 +524,7 @@ mod tests { &mut state, &mut config, &mut save, + &mut PendingReplies::default(), ); assert!(deregister.is_none()); @@ -513,6 +551,7 @@ mod tests { &mut state, &mut config, &mut save, + &mut PendingReplies::default(), ); let msg = status_of(&state).expect("a status line"); @@ -534,7 +573,12 @@ mod tests { let mut config = test_config(); let mut save = SaveScheduler::new("test"); - outcome(Performed::AssertPersonhood, None).apply(&mut state, &mut config, &mut save); + outcome(Performed::AssertPersonhood, None).apply( + &mut state, + &mut config, + &mut save, + &mut PendingReplies::default(), + ); let msg = status_of(&state).expect("a status line"); assert!( @@ -551,7 +595,12 @@ mod tests { let mut config = test_config(); let mut save = SaveScheduler::new("test"); - let deregister = outcome(Performed::Renew, None).apply(&mut state, &mut config, &mut save); + let deregister = outcome(Performed::Renew, None).apply( + &mut state, + &mut config, + &mut save, + &mut PendingReplies::default(), + ); assert!(deregister.is_none()); assert!(!save.is_pending()); @@ -562,6 +611,36 @@ mod tests { ); } + /// A renewal whose send failed is no longer waited on: an answer threading + /// on it later is not taken. One that was sent still is. + #[test] + fn a_renewal_that_never_left_is_no_longer_awaited() { + let vtc = VTC.to_string(); + for (error, awaited) in [(Some("peer unreachable"), false), (None, true)] { + let mut pending = PendingReplies::default(); + let id = uuid::Uuid::new_v4(); + assert!(openvtc_core::renewal::record_request( + &mut pending, + id, + &vtc, + "did:example:member" + )); + let mut sent = outcome(Performed::Renew, error); + sent.renewal_request = Some(id); + sent.apply( + &mut State::default(), + &mut test_config(), + &mut SaveScheduler::new("test"), + &mut pending, + ); + assert_eq!( + pending.is_awaited(ReplyKind::Renewal, &vtc, &id.to_string()), + awaited, + "error: {error:?}" + ); + } + } + /// Each verb reports its own failure. With the previous `leaving: bool` /// this could not be expressed — two of the four would have had to borrow /// another's wording and tell the member the wrong thing went wrong. @@ -581,7 +660,12 @@ mod tests { let mut config = test_config(); let mut save = SaveScheduler::new("test"); - outcome(performed, Some("peer unreachable")).apply(&mut state, &mut config, &mut save); + outcome(performed, Some("peer unreachable")).apply( + &mut state, + &mut config, + &mut save, + &mut PendingReplies::default(), + ); let msg = status_of(&state).expect("a status line"); assert!( diff --git a/openvtc/src/state_handler/community_profile_poll.rs b/openvtc/src/state_handler/community_profile_poll.rs index 204a9d02..23350f98 100644 --- a/openvtc/src/state_handler/community_profile_poll.rs +++ b/openvtc/src/state_handler/community_profile_poll.rs @@ -32,6 +32,7 @@ use affinidi_tdk::messaging::ATM; use openvtc_core::config::Config; use openvtc_core::config::account::{PersonaId, VtcDid}; use openvtc_core::didcomm::MessagingTransport; +use openvtc_core::pending_replies::PendingReplies; use tracing::debug; /// Cap on profile asks emitted per tick (R1.4 — bound the fan-out). @@ -145,44 +146,68 @@ impl ProfilePacer { } } -/// Send each ask, one at a time. The reply is asynchronous — it arrives on the -/// persona's listener and is applied by inbound dispatch — so nothing is awaited -/// beyond the send, and a failure is logged rather than surfaced: this is -/// background metadata the operator did not initiate, and the value only seeds a -/// form default whose absence is harmless. -pub(crate) async fn send_all(atm: ATM, asks: Vec) { - for ask in asks { - // Never sent unsigned: an ask with no key is dropped. - let Some(signer) = ask.signer.as_ref() else { - debug!(vtc = %ask.vtc_did, "no key to sign the profile ask; skipped"); - continue; - }; - let tsp_mediator = if ask.over_tsp { - openvtc_core::config::peer_tsp_mediator(&ask.vtc_did).await - } else { - None - }; - match openvtc_core::join::send_community_profile_show( - &atm, - &ask.profile, - &ask.persona_did, - signer, - &ask.vtc_did, - &ask.mediator_did, - tsp_mediator.as_deref(), - ) - .await - { - Ok(()) => debug!(vtc = %ask.vtc_did, "asked the community for its profile"), - Err(e) => debug!( - vtc = %ask.vtc_did, - error = %e, - "could not ask the community for its profile; will retry next launch" - ), +/// Open each ask's question in `pending` — here, on the loop thread that owns +/// the table, so an answer racing the send is still recognised — and return the +/// sends, one at a time, for the caller to spawn. The reply is asynchronous — it +/// arrives on the persona's listener and is applied by inbound dispatch — so +/// nothing is awaited beyond the send, and a failure is logged rather than +/// surfaced: this is background metadata the operator did not initiate, and the +/// value only seeds a form default whose absence is harmless. +pub(crate) fn send_all( + atm: ATM, + asks: Vec, + pending: &mut PendingReplies, +) -> impl std::future::Future + Send + 'static { + let opened: Vec<(Ask, String)> = asks + .into_iter() + .filter_map(|ask| { + // Never sent unsigned: an ask with no key is dropped. + if ask.signer.is_none() { + debug!(vtc = %ask.vtc_did, "no key to sign the profile ask; skipped"); + return None; + } + let document_id = openvtc_core::join::open_profile_query(pending, &ask.vtc_did); + Some((ask, document_id)) + }) + .collect(); + async move { + for (ask, document_id) in opened { + send_one(&atm, ask, document_id).await; } } } +/// Send one ask whose question is already open. +async fn send_one(atm: &ATM, ask: Ask, document_id: String) { + let Some(signer) = ask.signer.as_ref() else { + return; + }; + let tsp_mediator = if ask.over_tsp { + openvtc_core::config::peer_tsp_mediator(&ask.vtc_did).await + } else { + None + }; + match openvtc_core::join::send_community_profile_show( + atm, + &ask.profile, + &ask.persona_did, + signer, + &ask.vtc_did, + &ask.mediator_did, + tsp_mediator.as_deref(), + document_id, + ) + .await + { + Ok(()) => debug!(vtc = %ask.vtc_did, "asked the community for its profile"), + Err(e) => debug!( + vtc = %ask.vtc_did, + error = %e, + "could not ask the community for its profile; will retry next launch" + ), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/openvtc/src/state_handler/elections_actions.rs b/openvtc/src/state_handler/elections_actions.rs index da4884bd..bbc6a311 100644 --- a/openvtc/src/state_handler/elections_actions.rs +++ b/openvtc/src/state_handler/elections_actions.rs @@ -37,6 +37,7 @@ use openvtc_core::elections::{ Voter, }; use openvtc_core::notifications::NotifiedElection; +use openvtc_core::pending_replies::{PendingReplies, ReplyKind}; use tokio::sync::mpsc::UnboundedSender; use vta_sdk::client::VtaClient; @@ -196,6 +197,9 @@ pub(crate) struct Loop<'a> { pub(crate) vta: Option<&'a VtaClient>, pub(crate) dispatch_tx: &'a UnboundedSender, pub(crate) in_flight: &'a mut InFlight, + /// Where a request to a community this persona may not belong to is + /// recorded, so the inbound gate admits its answer. + pub(crate) pending_replies: &'a mut PendingReplies, } impl Loop<'_> { @@ -292,7 +296,11 @@ impl Loop<'_> { return false; } // Its answer is admitted from this community on this thread, member or not. - elections::expect_answer(&to, &thid); + self.pending_replies.record( + &thid, + &to, + openvtc_core::pending_replies::Purpose::ElectionAnswer, + ); if let Some(view) = view_mut(self.state) { view.pending = Some(Pending { thid, @@ -490,6 +498,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: Act) { vta: ctx.admin_vta, dispatch_tx: ctx.dispatch_tx, in_flight: &mut *ctx.in_flight, + pending_replies: &mut *ctx.pending_replies, }; match action { Act::Open(_) => { @@ -880,7 +889,8 @@ pub(crate) async fn tick(lp: &mut Loop<'_>) { .is_some_and(|p| now.duration_since(p.sent_at) > REPLY_WINDOW) && let Some(p) = view.pending.take() { - elections::answered(&p.thid); + lp.pending_replies + .forget(ReplyKind::ElectionAnswer, &p.thid); let secs = REPLY_WINDOW.as_secs(); match p.purpose { Purpose::List if view.elections.is_empty() => { @@ -1017,11 +1027,11 @@ pub(crate) struct ElectionsOutcome { impl ElectionsOutcome { /// Say the send never left — while the view still waits on this request. - pub(crate) fn apply(self, state: &mut State) { + pub(crate) fn apply(self, state: &mut State, pending_replies: &mut PendingReplies) { let Err(e) = self.result else { return; }; - elections::answered(&self.thid); + pending_replies.forget(ReplyKind::ElectionAnswer, &self.thid); let Some(view) = view_mut(state) else { return; }; @@ -1043,7 +1053,11 @@ impl ElectionsOutcome { } /// Apply the community's answers. Returns whether any was taken, so the loop [`advance`]s. -pub(crate) fn apply_replies(state: &mut State, replies: Vec) -> bool { +pub(crate) fn apply_replies( + state: &mut State, + pending_replies: &mut PendingReplies, + replies: Vec, +) -> bool { let mut taken = false; for inbound in replies { // The community the outstanding request went to: the panel's own, or the one holding a @@ -1067,12 +1081,17 @@ pub(crate) fn apply_replies(state: &mut State, replies: Vec) -> bo tracing::warn!(from = %inbound.from, thid = %inbound.thid, "elections reply not from the community the view is asking — dropped"); continue; } - taken |= apply_reply(state, &inbound.thid, inbound.reply); + taken |= apply_reply(state, pending_replies, &inbound.thid, inbound.reply); } taken } -fn apply_reply(state: &mut State, thid: &str, reply: Reply) -> bool { +fn apply_reply( + state: &mut State, + pending_replies: &mut PendingReplies, + thid: &str, + reply: Reply, +) -> bool { let Some(view) = view_mut(state) else { return false; }; @@ -1085,7 +1104,7 @@ fn apply_reply(state: &mut State, thid: &str, reply: Reply) -> bool { let Some(pending) = view.pending.take() else { return false; }; - elections::answered(&pending.thid); + pending_replies.forget(ReplyKind::ElectionAnswer, &pending.thid); match (reply, pending.purpose) { (Reply::List(page), Purpose::List) => { let next = page.next_cursor.as_ref().map(|c| c.as_str().to_string()); diff --git a/openvtc/src/state_handler/elections_actions/tests.rs b/openvtc/src/state_handler/elections_actions/tests.rs index 65fb5714..c3dfdac6 100644 --- a/openvtc/src/state_handler/elections_actions/tests.rs +++ b/openvtc/src/state_handler/elections_actions/tests.rs @@ -104,6 +104,7 @@ fn the_list_is_read_a_page_at_a_time_and_ordered_by_what_can_be_done() { arm(&mut state, Purpose::List); assert!(apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(list_page( json!([item("old", "tallied")]), Some("c1") @@ -118,6 +119,7 @@ fn the_list_is_read_a_page_at_a_time_and_ordered_by_what_can_be_done() { arm(&mut state, Purpose::List); apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(list_page(json!([item("now", "voting")]), None))], ); let v = view(&mut state); @@ -132,10 +134,18 @@ fn an_answer_from_anyone_else_is_dropped() { arm(&mut state, Purpose::List); let mut forged = inbound(list_page(json!([]), None)); forged.from = "did:key:zMallory".into(); - assert!(!apply_replies(&mut state, vec![forged])); + assert!(!apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![forged] + )); let mut forged = inbound(list_page(json!([]), None)); forged.issuer = Some("did:key:zMallory".into()); - assert!(!apply_replies(&mut state, vec![forged])); + assert!(!apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![forged] + )); assert!(view(&mut state).pending.is_some()); } @@ -156,7 +166,11 @@ fn a_vote_reads_the_election_again_then_registers() { intent: Intent::Vote, }, ); - apply_replies(&mut state, vec![inbound(Reply::Show(shown("voting")))]); + apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![inbound(Reply::Show(shown("voting")))], + ); let v = view(&mut state); assert!(matches!(v.next, Some(Next::Register))); assert_eq!(v.flow.as_ref().unwrap().step, Step::Registering); @@ -177,7 +191,11 @@ fn a_closed_election_takes_no_ticket() { intent: Intent::Ticket, }, ); - apply_replies(&mut state, vec![inbound(Reply::Show(shown("closed")))]); + apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![inbound(Reply::Show(shown("closed")))], + ); let v = view(&mut state); assert!(v.flow.is_none() && v.next.is_none()); assert!(v.status.as_ref().unwrap().text.contains("closed")); @@ -215,6 +233,7 @@ fn voting_without_a_ticket_takes_it_and_stops() { let issued = json!({ "electionId": "agm", "label": "voter/agm", "preCredential": "zPre" }); apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(Reply::TicketIssued { typed: Box::new(serde_json::from_value(issued.clone()).unwrap()), raw: issued.clone(), @@ -352,6 +371,7 @@ fn a_community_refusal_ends_the_flow_in_words() { ); apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(Reply::Refused(Refusal { code: "vtc/elections/tickets/issue:notEligible".into(), message: None, @@ -401,6 +421,7 @@ fn vti_ntf_063_the_list_carries_notified_elections_once() { arm(&mut state, Purpose::List); apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(list_page(json!([item("agm", "voting")]), None))], ); vta( @@ -447,7 +468,11 @@ fn vti_vot_016_a_membership_is_presented_only_once_the_member_chooses() { // From the community the read went to — not the panel's own. refusal.from = HOLDER.into(); refusal.issuer = Some(HOLDER.into()); - assert!(apply_replies(&mut state, vec![refusal])); + assert!(apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![refusal] + )); let v = view(&mut state); assert_eq!(v.screen, Screen::Disclose); assert!(v.presenting.is_none(), "nothing presented yet"); @@ -527,11 +552,49 @@ fn an_answer_is_taken_from_the_community_the_request_went_to() { // The panel's own community is not the one asked. assert!(!apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(Reply::Show(shown("voting")))] )); let mut from_holder = inbound(Reply::Show(shown("voting"))); from_holder.from = HOLDER.into(); from_holder.issuer = Some(HOLDER.into()); - assert!(apply_replies(&mut state, vec![from_holder])); + assert!(apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![from_holder] + )); assert!(view(&mut state).shown.is_some()); } + +/// Once the view has taken its answer, or its send failed, the inbound gate no longer admits an +/// answer on that thread from a community this persona does not belong to. +#[test] +fn an_answer_taken_or_failed_is_no_longer_awaited_at_the_gate() { + let election = openvtc_core::pending_replies::Purpose::ElectionAnswer; + // Taken. + let mut state = state_with_view(); + arm(&mut state, Purpose::List); + let mut pending = PendingReplies::default(); + pending.record("t1", VTC, election.clone()); + assert!(apply_replies( + &mut state, + &mut pending, + vec![inbound(list_page(json!([]), None))] + )); + assert!(!pending.is_awaited(ReplyKind::ElectionAnswer, VTC, "t1")); + + // Its send failed. + let mut state = state_with_view(); + arm(&mut state, Purpose::List); + pending.record("t1", VTC, election); + let persona = view(&mut state).persona; + ElectionsOutcome { + vtc_did: VTC.into(), + persona, + thid: "t1".into(), + result: Err("unreachable".into()), + } + .apply(&mut state, &mut pending); + assert!(!pending.is_awaited(ReplyKind::ElectionAnswer, VTC, "t1")); + assert!(view(&mut state).pending.is_none()); +} diff --git a/openvtc/src/state_handler/inbound_glue_tests.rs b/openvtc/src/state_handler/inbound_glue_tests.rs index b7e2a317..dcf19e3e 100644 --- a/openvtc/src/state_handler/inbound_glue_tests.rs +++ b/openvtc/src/state_handler/inbound_glue_tests.rs @@ -90,6 +90,7 @@ async fn dispatch( tdk, &service, &mut seen, + &mut openvtc_core::pending_replies::PendingReplies::default(), m, m.from.as_deref(), &mut effects, diff --git a/openvtc/src/state_handler/join_cmd.rs b/openvtc/src/state_handler/join_cmd.rs index 889e0b28..d1e1c819 100644 --- a/openvtc/src/state_handler/join_cmd.rs +++ b/openvtc/src/state_handler/join_cmd.rs @@ -21,6 +21,7 @@ use openvtc_core::config::{ use openvtc_core::didcomm::{DIDCommEvent, Messaging, MessagingTransport}; use openvtc_core::logs::LogFamily; use openvtc_core::messaging::SeenMessages; +use openvtc_core::pending_replies::PendingReplies; use openvtc_core::vetting::disclosure::{Describe, DirectoryStatement}; use openvtc_core::vetting::protocol::JoinProtocol; use serde_json::Value; @@ -624,6 +625,9 @@ impl Job<'_> { let vtc_did = self.vtc_did; let deadline = tokio::time::Instant::now() + wait; let mut seen = SeenMessages::new(); + // This command's own table of replies awaited by thread — it asks + // nothing the gate admits only on a recorded thread, so it stays empty. + let mut pending = PendingReplies::default(); let mut pacer = openvtc_core::join::status_poll::PollPacer::default(); let mut poll = tokio::time::interval_at(tokio::time::Instant::now() + POLL_FIRST, POLL_EVERY); @@ -675,7 +679,13 @@ impl Job<'_> { note_verdict(&message); } if self - .dispatch(config, &mut seen, &message, authenticated.as_deref()) + .dispatch( + config, + &mut seen, + &mut pending, + &message, + authenticated.as_deref(), + ) .await { save(config, self.profile)?; @@ -724,6 +734,7 @@ impl Job<'_> { &self, config: &mut Config, seen: &mut SeenMessages, + pending: &mut PendingReplies, message: &Message, authenticated: Option<&str>, ) -> bool { @@ -732,6 +743,7 @@ impl Job<'_> { self.tdk, self.messaging, seen, + pending, message, authenticated, ) diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index e2ba27fd..7628e5f4 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -1707,6 +1707,8 @@ impl StateHandler { &mut ctx.config, &mut save, &mut in_flight, + // Nothing here waits on a reply by thread. + &mut openvtc_core::pending_replies::PendingReplies::default(), outcome, ); // State A holds no messaging sessions, so a pending @@ -3655,6 +3657,7 @@ async fn restore_kept( tdk: &affinidi_tdk::TDK, service: &didcomm::Messaging, seen: &mut openvtc_core::messaging::SeenMessages, + pending_replies: &mut openvtc_core::pending_replies::PendingReplies, ) -> bool { let mut changed = false; while let Some(kept) = backlog.pop_front() { @@ -3668,6 +3671,7 @@ async fn restore_kept( tdk, service, seen, + pending_replies, &kept.message, Some(&kept.sender), &mut effects, @@ -3893,6 +3897,7 @@ mod tests { &tdk, &service, &mut seen, + &mut openvtc_core::pending_replies::PendingReplies::default(), ) .await; assert_eq!(backlog.len(), 1, "waits for room"); @@ -3915,6 +3920,7 @@ mod tests { &tdk, &service, &mut seen, + &mut openvtc_core::pending_replies::PendingReplies::default(), ) .await; assert!(backlog.is_empty()); diff --git a/openvtc/src/state_handler/runtime/arms.rs b/openvtc/src/state_handler/runtime/arms.rs index e819ade7..7c54de8b 100644 --- a/openvtc/src/state_handler/runtime/arms.rs +++ b/openvtc/src/state_handler/runtime/arms.rs @@ -177,9 +177,17 @@ impl Runtime { witness_router, ping_sent_at, handler, + pending_replies, .. } = self; - let after = background_dispatch::apply_outcome(state, config, save, in_flight, outcome); + let after = background_dispatch::apply_outcome( + state, + config, + save, + in_flight, + pending_replies, + outcome, + ); // A VTA answer or a failed send may call for the Rooms panel's next // step, and frees the lane it waits on. rooms_actions::advance(&mut rooms_actions::Loop { @@ -199,6 +207,7 @@ impl Runtime { vta: admin_vta.as_ref(), dispatch_tx, in_flight, + pending_replies, }) .await; let settled = settle_after_apply(after, state, config, tdk, handler.profile.as_str()).await; @@ -242,6 +251,7 @@ impl Runtime { state_tx: &handler.state_tx, profile: handler.profile.as_str(), witness_router, + pending_replies, }; vetting_actions::apply_pcs_outcome(&mut ctx, *outcome).await; } diff --git a/openvtc/src/state_handler/runtime/inbound_arm.rs b/openvtc/src/state_handler/runtime/inbound_arm.rs index 7b8b7367..a9d02af2 100644 --- a/openvtc/src/state_handler/runtime/inbound_arm.rs +++ b/openvtc/src/state_handler/runtime/inbound_arm.rs @@ -25,6 +25,7 @@ impl Runtime { verifier, restore_backlog, seen_messages, + pending_replies, witness_router, ping_sent_at, join_entry, @@ -72,6 +73,7 @@ impl Runtime { tdk, didcomm_service, seen_messages, + pending_replies, &message, authenticated.as_deref(), &mut effects, @@ -156,6 +158,7 @@ impl Runtime { tdk, didcomm_service, seen_messages, + pending_replies, ) .await { @@ -317,7 +320,7 @@ impl Runtime { // Election answers for the Elections panel; each may call for // the next step of a ticket or a vote. if !elections_replies.is_empty() - && elections_actions::apply_replies(state, elections_replies) + && elections_actions::apply_replies(state, pending_replies, elections_replies) { elections_actions::advance(&mut elections_actions::Loop { state, @@ -326,6 +329,7 @@ impl Runtime { vta: admin_vta.as_ref(), dispatch_tx, in_flight, + pending_replies, }) .await; } diff --git a/openvtc/src/state_handler/runtime/mod.rs b/openvtc/src/state_handler/runtime/mod.rs index 3164338b..2020a258 100644 --- a/openvtc/src/state_handler/runtime/mod.rs +++ b/openvtc/src/state_handler/runtime/mod.rs @@ -123,6 +123,12 @@ pub(crate) struct Runtime { // ── Inbound ── pub(super) seen_messages: openvtc_core::messaging::SeenMessages, + /// Replies awaited by thread — a renewal's, a community profile's, an + /// election answer from a community we hold no membership with. The + /// inbound gate admits those only on a thread recorded here. Owned by + /// this loop alone: send paths record into it on this thread before + /// spawning, and inbound dispatch consumes from it. + pub(super) pending_replies: openvtc_core::pending_replies::PendingReplies, pub(super) verifier: verify_queue::Verifier, pub(super) restore_backlog: VecDeque, pub(super) witness_router: openvtc_core::witness::ReplyRouter, @@ -255,6 +261,8 @@ impl Runtime { // Process-lifetime LRU of inbound message IDs. Backstop for replay // and mediator-pickup duplicates beyond what the TDK already filters. let mut seen_messages = openvtc_core::messaging::SeenMessages::new(); + // Replies awaited by thread (see the `pending_replies` field). + let mut pending_replies = openvtc_core::pending_replies::PendingReplies::default(); // Forward lifecycle events (connect/disconnect/restart) to the activity log let (lifecycle_log_tx, lifecycle_log_rx) = @@ -398,6 +406,7 @@ impl Runtime { &tdk, &didcomm_service, &mut seen_messages, + &mut pending_replies, ) .await { @@ -428,6 +437,7 @@ impl Runtime { _listener_install: listener_install, _lifecycle_handle: lifecycle_handle, seen_messages, + pending_replies, verifier, restore_backlog, // Hands a witness's answers from the inbound arm to the witnessing @@ -793,6 +803,7 @@ impl Runtime { state_tx: &self.handler.state_tx, profile: self.handler.profile.as_str(), witness_router: &self.witness_router, + pending_replies: &mut self.pending_replies, } } diff --git a/openvtc/src/state_handler/runtime/timers.rs b/openvtc/src/state_handler/runtime/timers.rs index 29c544ac..d927ba2f 100644 --- a/openvtc/src/state_handler/runtime/timers.rs +++ b/openvtc/src/state_handler/runtime/timers.rs @@ -25,6 +25,7 @@ impl Runtime { dispatch_tx, session_manager, witness_router, + pending_replies, ping_sent_at, join_entry, awaiting_requirements, @@ -61,6 +62,7 @@ impl Runtime { vta: admin_vta.as_ref(), dispatch_tx, in_flight, + pending_replies, }) .await; // Questions to communities have a reply window too (R1.2). @@ -86,6 +88,7 @@ impl Runtime { state_tx: &handler.state_tx, profile: handler.profile.as_str(), witness_router, + pending_replies, }; vetting_actions::start_pcs_work(&mut ctx, Vec::new()); } @@ -106,6 +109,7 @@ impl Runtime { state_tx: &handler.state_tx, profile: handler.profile.as_str(), witness_router, + pending_replies, }; vetting_actions::dispatch(&mut ctx, actions::VettingAction::RefreshVetterSide).await; } else if vetting_actions::desk_open(state) { @@ -125,6 +129,7 @@ impl Runtime { state_tx: &handler.state_tx, profile: handler.profile.as_str(), witness_router, + pending_replies, }; vetting_actions::refresh_stale_modes(&mut ctx).await; } @@ -386,6 +391,7 @@ impl Runtime { verifier, restore_backlog, seen_messages, + pending_replies, join_status_pacer, .. } = self; @@ -400,6 +406,7 @@ impl Runtime { tdk, didcomm_service, seen_messages, + pending_replies, ) .await { @@ -438,6 +445,7 @@ impl Runtime { dispatch_tx, session_manager, witness_router, + pending_replies, ping_sent_at, hidden_vetting_pacer, handler, @@ -462,6 +470,7 @@ impl Runtime { state_tx: &handler.state_tx, profile: handler.profile.as_str(), witness_router, + pending_replies, }; vetting_actions::dispatch(&mut ctx, actions::VettingAction::RefreshVetterSide).await; let next = hidden_vetting_pacer.schedule( @@ -478,6 +487,7 @@ impl Runtime { config, tdk, community_profile_pacer, + pending_replies, .. } = self; // Ask each Active community, once, for its profile so the @@ -497,7 +507,7 @@ impl Runtime { } } if !asks.is_empty() { - tokio::spawn(community_profile_poll::send_all(atm, asks)); + tokio::spawn(community_profile_poll::send_all(atm, asks, pending_replies)); } } } diff --git a/openvtc/src/state_handler/runtime_actions.rs b/openvtc/src/state_handler/runtime_actions.rs index 21e0eeb0..392bb538 100644 --- a/openvtc/src/state_handler/runtime_actions.rs +++ b/openvtc/src/state_handler/runtime_actions.rs @@ -64,6 +64,10 @@ pub(crate) struct ActionCtx<'a> { pub(crate) profile: &'a str, /// Where the inbound arm hands a witness's answers to a witnessing in progress. pub(crate) witness_router: &'a openvtc_core::witness::ReplyRouter, + /// The replies the runtime waits on by thread — the inbound gate admits + /// them only because we asked. Recorded here, on the loop thread, before + /// a send is spawned. + pub(crate) pending_replies: &'a mut openvtc_core::pending_replies::PendingReplies, } /// What the loop must do after an action was handled. @@ -588,6 +592,21 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha Some(Status::progress( "Asking the community to renew your membership…".to_string(), )); + // Waited on from before the send, so a reply quicker than + // the send's return still finds it — and only when the + // job will run: a busy dispatcher sends nothing. + let request_id = uuid::Uuid::new_v4(); + if !ctx + .in_flight + .is_busy(background_dispatch::DispatchDomain::Community) + { + openvtc_core::renewal::record_request( + ctx.pending_replies, + request_id, + &vtc, + &member_did, + ); + } spawn_community_job( ctx.dispatch_tx, ctx.in_flight, @@ -606,6 +625,7 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha persona: persona_id, verb: community_actions::Verb::Renew { document_signer: Box::new(keys.authentication.secret.clone()), + request_id, }, }, ) @@ -1515,6 +1535,7 @@ mod tests { ping_sent_at: Option, state_tx: watch::Sender>, witness_router: openvtc_core::witness::ReplyRouter, + pending_replies: openvtc_core::pending_replies::PendingReplies, } impl Harness { @@ -1545,6 +1566,7 @@ mod tests { ping_sent_at: None, state_tx, witness_router: openvtc_core::witness::ReplyRouter::default(), + pending_replies: Default::default(), } } @@ -1563,6 +1585,7 @@ mod tests { state_tx: &self.state_tx, profile: "test", witness_router: &self.witness_router, + pending_replies: &mut self.pending_replies, }; handle_action(&mut ctx, action).await } diff --git a/openvtc/src/state_handler/settings_actions.rs b/openvtc/src/state_handler/settings_actions.rs index 52522f55..b3403215 100644 --- a/openvtc/src/state_handler/settings_actions.rs +++ b/openvtc/src/state_handler/settings_actions.rs @@ -1307,6 +1307,7 @@ mod tests { &mut config, &mut save, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), outcome, ); assert!(!in_flight.is_busy(DispatchDomain::DidGitSign)); From 78239019961b160960cf2293bdcade56a3b7c466 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 19:12:33 +1000 Subject: [PATCH 21/27] refactor(ui): one community_request module for the panels that ask a community Rooms and Elections now share one request/reply engine (state_handler/community_request.rs) behind a small CommunityFeature trait: arming the view before the send is spawned, the send job and its outcome, taking an answer only on its thread from the community asked, the 30 s reply window, job loss, and keeping a step whose lane is busy. Elections' threads are recorded at, and forgotten from, the runtime's PendingReplies by the engine (RECORDS_THREAD). Each panel keeps what is its own: building requests, what an answer, a timeout or a failure says, and its VTA calls. One CommunityLoop replaces the three Loop structs (re-exported as rooms_actions::Loop, elections_actions::Loop, repos_actions::Loop). Repos keeps its two-slot wait (request and link poll, per-purpose windows) but takes the shared loop, Sender/sender/sign_and_send and answered_by from the module; did_part and capitalise exist once. No message, timeout or correlation rule changes (#534). Signed-off-by: Glenn Gore --- openvtc/src/repos_cmd.rs | 8 +- .../src/state_handler/background_dispatch.rs | 14 +- .../src/state_handler/community_request.rs | 812 +++++++++++++++++ .../src/state_handler/elections_actions.rs | 860 +++++++----------- .../state_handler/elections_actions/tests.rs | 20 +- .../src/state_handler/main_page/elections.rs | 12 +- openvtc/src/state_handler/main_page/rooms.rs | 9 +- openvtc/src/state_handler/mod.rs | 1 + .../src/state_handler/notification_actions.rs | 2 +- openvtc/src/state_handler/repos_actions.rs | 363 +++----- openvtc/src/state_handler/repos_workspace.rs | 8 +- openvtc/src/state_handler/rooms_actions.rs | 608 ++++++------- openvtc/src/state_handler/runtime/arms.rs | 1 + .../src/state_handler/runtime/inbound_arm.rs | 25 +- openvtc/src/state_handler/runtime/timers.rs | 3 + 15 files changed, 1593 insertions(+), 1153 deletions(-) create mode 100644 openvtc/src/state_handler/community_request.rs diff --git a/openvtc/src/repos_cmd.rs b/openvtc/src/repos_cmd.rs index c233a07f..8daa2d3f 100644 --- a/openvtc/src/repos_cmd.rs +++ b/openvtc/src/repos_cmd.rs @@ -27,7 +27,7 @@ use tokio::sync::mpsc; use crate::colors::{CLI_ERROR, CLI_INFO, Themed}; use crate::headless::{self, Admin, Membership, progress, save, warn}; -use crate::state_handler::{DeferredLoad, repos_actions}; +use crate::state_handler::{DeferredLoad, community_request}; use openvtc_core::inbound::apply_inline as apply_inbound; /// How long a request waits for the community's answer (as the panel does). @@ -334,7 +334,7 @@ async fn ask( progress("Connecting to the mediator…"); headless::connect_persona(&service, config, &tdk, target.persona, CONNECT_WINDOW).await?; - let sender = repos_actions::sender(config, &tdk, target.persona, &target.vtc_did) + let sender = community_request::sender(config, &tdk, target.persona, &target.vtc_did) .await .map_err(|e| anyhow!(e))?; let mut doc = git_ns::build(&request, &sender.persona_did, &target.vtc_did)?; @@ -344,7 +344,7 @@ async fn ask( request.type_uri().trim_start_matches(git_ns::TYPE_PREFIX), if sender.over_tsp { "TSP" } else { "DIDComm" } )); - repos_actions::sign_and_send(&sender, &target.vtc_did, &mut doc) + community_request::sign_and_send(&sender, &target.vtc_did, &mut doc) .await .map_err(|e| anyhow!("couldn't send the request: {e}"))?; @@ -495,7 +495,7 @@ async fn take_reply( Err(e) => return Some(Err(format!("not accepted from this sender: {e}"))), }; let from = from.as_str(); - if !repos_actions::answered_by(vtc_did, from, doc.issuer.as_deref()) { + if !community_request::answered_by(vtc_did, from, doc.issuer.as_deref()) { return Some(Err(format!( "sent by {from}, not the community it was asked of" ))); diff --git a/openvtc/src/state_handler/background_dispatch.rs b/openvtc/src/state_handler/background_dispatch.rs index 72e84d2b..a1097ec8 100644 --- a/openvtc/src/state_handler/background_dispatch.rs +++ b/openvtc/src/state_handler/background_dispatch.rs @@ -330,11 +330,11 @@ pub(crate) enum DispatchOutcome { info: Option, }, /// A `vtc/rooms/*` request was sent to a community (or failed to send). - Rooms(crate::state_handler::rooms_actions::RoomsOutcome), + Rooms(crate::state_handler::community_request::SendOutcome), /// The member's VTA answered the Rooms panel. RoomsVta(crate::state_handler::rooms_actions::RoomsVtaOutcome), /// A `vtc/elections/*` request was sent to a community (or failed to send). - Elections(crate::state_handler::elections_actions::ElectionsOutcome), + Elections(crate::state_handler::community_request::SendOutcome), /// The member's VTA answered the Elections panel. ElectionsVta(crate::state_handler::elections_actions::ElectionsVtaOutcome), /// A cast reached its ballot box (or did not). @@ -662,9 +662,15 @@ pub(crate) fn apply_outcome( DispatchOutcome::DidGitSign { persona_did, info } => { crate::state_handler::settings_actions::apply_did_git_sign(state, persona_did, info); } - DispatchOutcome::Rooms(outcome) => outcome.apply(state), + DispatchOutcome::Rooms(outcome) => crate::state_handler::community_request::send_failed::< + crate::state_handler::rooms_actions::Rooms, + >(state, pending_replies, outcome), DispatchOutcome::RoomsVta(outcome) => outcome.apply(state), - DispatchOutcome::Elections(outcome) => outcome.apply(state, pending_replies), + DispatchOutcome::Elections(outcome) => { + crate::state_handler::community_request::send_failed::< + crate::state_handler::elections_actions::Elections, + >(state, pending_replies, outcome) + } DispatchOutcome::ElectionsVta(outcome) => outcome.apply(state), DispatchOutcome::ElectionsCast(outcome) => outcome.apply(state), // Merged, not replaced. A sweep only carries the targets it was given, diff --git a/openvtc/src/state_handler/community_request.rs b/openvtc/src/state_handler/community_request.rs new file mode 100644 index 00000000..ca86b4d7 --- /dev/null +++ b/openvtc/src/state_handler/community_request.rs @@ -0,0 +1,812 @@ +//! One request to a community at a time, and the answer it waits for — the +//! plumbing the panels that speak to a community share. +//! +//! A panel (Rooms, Elections) builds a request, and from here on it is the same +//! for each: the view is armed on the request's thread *before* the send is +//! spawned ([`CommunityLoop::spawn_send`]) — the answer travels the inbound +//! channel, not the dispatch one, and can overtake the send's own outcome; a +//! send that failed ends the wait while the view still waits on it +//! ([`send_failed`]); an answer is taken only from the community the request +//! went to, sent and issued by it, on the thread the view waits on +//! ([`apply_replies`]); a request unanswered within [`REPLY_WINDOW`] ends +//! ([`tick`]); a job that panicked ends what it was part of ([`job_lost`]); and +//! the step a view's last answer called for is taken when its lane is free +//! ([`advance`]). What differs — what a request is, what an answer means, what +//! a timeout or a failure says — is the panel's, behind [`CommunityFeature`]. +//! +//! A panel whose requests may go to a community this persona holds no +//! membership with ([`CommunityFeature::RECORDS_THREAD`]) also records the +//! thread in the runtime's [`PendingReplies`], so the inbound gate admits that +//! community's answer, and forgets it when the view stops waiting. +//! +//! The Repos panel keeps two waits (a request and a link poll) with their own +//! windows, so it uses only the shared pieces below the engine: the loop's +//! borrows ([`CommunityLoop`]), the sender ([`sender`], [`sign_and_send`]) and +//! the sender check ([`answered_by`]). + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use affinidi_tdk::TDK; +use affinidi_tdk::messaging::ATM; +use affinidi_tdk::messaging::profiles::ATMProfile; +use affinidi_tdk::secrets_resolver::secrets::Secret; +use openvtc_core::capabilities::{RequestDocument, correlation_thread}; +use openvtc_core::config::Config; +use openvtc_core::config::account::PersonaId; +use openvtc_core::inbound::ThreadedReply; +use openvtc_core::pending_replies::{self, PendingReplies, ReplyKind}; +use tokio::sync::mpsc::UnboundedSender; +use vta_sdk::client::VtaClient; + +use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; +use crate::state_handler::main_page::repos::Severity; +use crate::state_handler::runtime_actions::ActionCtx; +use crate::state_handler::state::State; + +/// How long a request waits for the community's answer (R1.2). +pub(crate) const REPLY_WINDOW: Duration = Duration::from_secs(30); + +/// The first letter upper-cased. +pub(crate) fn capitalise(s: &str) -> String { + let mut c = s.chars(); + c.next() + .map(|f| f.to_uppercase().chain(c).collect()) + .unwrap_or_default() +} + +/// The DID part of a DID or DID URL. +pub(crate) fn did_part(did: &str) -> &str { + did.split('#').next().unwrap_or(did) +} + +/// Whether an answer is `community`'s own: sent by it and issued by it. A +/// thread id is a correlation key, not a credential — anyone who learned one +/// could otherwise answer for the community. Compared by DID: a did:scid +/// community may name itself with the `?src=` it is resolved through +/// (VTI-KEY-163). +pub(crate) fn answered_by(community: &str, from: &str, issuer: Option<&str>) -> bool { + let is_community = |did: &str| vta_sdk::did_scid::same_reference(did_part(did), community); + is_community(from) && issuer.is_some_and(is_community) +} + +// **************************************************************************** +// The loop's borrows +// **************************************************************************** + +/// What a send, a VTA call or a reply needs from the loop, borrowed. The action +/// path ([`ActionCtx`]) and the loop's own tick, reply and outcome arms each +/// build one. +pub(crate) struct CommunityLoop<'a> { + pub(crate) state: &'a mut State, + pub(crate) config: &'a Config, + pub(crate) tdk: &'a TDK, + pub(crate) vta: Option<&'a VtaClient>, + pub(crate) dispatch_tx: &'a UnboundedSender, + pub(crate) in_flight: &'a mut InFlight, + /// The runtime's replies awaited by thread ([`CommunityFeature::RECORDS_THREAD`]). + pub(crate) pending_replies: &'a mut PendingReplies, +} + +impl<'a> CommunityLoop<'a> { + /// Borrow what the loop lent an action. + pub(crate) fn from_ctx(ctx: &'a mut ActionCtx<'_>) -> Self { + Self { + state: &mut *ctx.state, + config: ctx.config, + tdk: ctx.tdk, + vta: ctx.admin_vta, + dispatch_tx: ctx.dispatch_tx, + in_flight: &mut *ctx.in_flight, + pending_replies: &mut *ctx.pending_replies, + } + } +} + +// **************************************************************************** +// Sending +// **************************************************************************** + +/// Everything a send needs, resolved on the loop thread. +pub(crate) struct Sender { + pub(crate) atm: ATM, + pub(crate) profile: Arc, + pub(crate) persona_did: String, + pub(crate) mediator: String, + /// The membership was joined over TSP, so its requests go over TSP: the + /// community answers on the transport a request arrived on, and a persona + /// that joined over TSP may not collect a DIDComm answer at all. + pub(crate) over_tsp: bool, + pub(crate) signer: Box, +} + +/// Resolve the persona's messaging identity and signing key. The key comes +/// from the TDK secrets resolver, an in-memory store populated at startup — +/// not I/O — which is what lets the job own a plain `Secret`. +pub(crate) async fn sender( + config: &Config, + tdk: &TDK, + persona: PersonaId, + vtc_did: &str, +) -> Result { + let id = config + .identities + .get(&persona) + .ok_or_else(|| "this persona has no messaging identity".to_string())?; + let atm = tdk + .atm + .as_ref() + .ok_or_else(|| "messaging is unavailable".to_string())? + .clone(); + let keys = config + .get_persona_keys_for(persona, tdk) + .await + .map_err(|e| format!("couldn't read this persona's signing key: {e}"))?; + Ok(Sender { + atm, + profile: id.profile().clone(), + persona_did: id.persona_did().to_string(), + mediator: id.mediator_did.clone().unwrap_or_default(), + over_tsp: config + .account + .membership(vtc_did, persona) + .is_some_and(|c| c.joined_over_tsp()), + signer: Box::new(keys.authentication.secret.clone()), + }) +} + +/// Sign `doc` as the persona and send it to `vtc_did`, over TSP when the +/// membership was joined over it. +pub(crate) async fn sign_and_send( + s: &Sender, + vtc_did: &str, + doc: &mut RequestDocument, +) -> Result<(), String> { + async { + openvtc_core::capabilities::sign_document(doc, &s.signer).await?; + let tsp_mediator = + openvtc_core::community_send::tsp_mediator_for(s.over_tsp, vtc_did).await; + openvtc_core::capabilities::send_capability_document( + &openvtc_core::community_send::Delivery { + atm: &s.atm, + profile: &s.profile, + member_did: &s.persona_did, + vtc_did, + mediator_did: &s.mediator, + tsp_mediator_did: tsp_mediator.as_deref(), + }, + doc, + ) + .await + } + .await + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +/// Send a document the VTA signed, unchanged, on the community's transport. +async fn send_signed_document( + s: &Sender, + vtc_did: &str, + doc: &serde_json::Value, +) -> Result<(), String> { + let id = doc + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let tsp_mediator = openvtc_core::community_send::tsp_mediator_for(s.over_tsp, vtc_did).await; + openvtc_core::community_send::send_document( + &openvtc_core::community_send::Delivery { + atm: &s.atm, + profile: &s.profile, + member_did: &s.persona_did, + vtc_did, + mediator_did: &s.mediator, + tsp_mediator_did: tsp_mediator.as_deref(), + }, + id, + doc.clone(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// A document on its way to the community. +pub(crate) enum Doc { + /// Built here: signed as the persona before it goes. + Unsigned(Box), + /// Signed by the VTA as the member, carrying a presentation: sent exactly as it answered. + Signed(serde_json::Value), +} + +impl Doc { + /// The thread its answer carries: the document's id. + pub(crate) fn thread(&self) -> Option { + match self { + Doc::Unsigned(doc) => Some(correlation_thread(doc).to_string()), + Doc::Signed(doc) => doc + .get("id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty()) + .map(str::to_string), + } + } +} + +/// One send, off the loop. +struct SendJob { + sender: Sender, + vtc_did: String, + persona: PersonaId, + doc: Doc, +} + +impl SendJob { + async fn run(mut self) -> SendOutcome { + let thid = self.doc.thread().unwrap_or_default(); + let result = match &mut self.doc { + Doc::Unsigned(doc) => sign_and_send(&self.sender, &self.vtc_did, doc).await, + Doc::Signed(doc) => send_signed_document(&self.sender, &self.vtc_did, doc).await, + }; + SendOutcome { + vtc_did: self.vtc_did, + persona: self.persona, + thid, + result, + } + } +} + +/// What a send did. Applied on the loop by [`send_failed`]. +pub(crate) struct SendOutcome { + /// The community it went to. + pub(crate) vtc_did: String, + pub(crate) persona: PersonaId, + /// The thread the view was armed with for it. + pub(crate) thid: String, + pub(crate) result: Result<(), String>, +} + +// **************************************************************************** +// The engine +// **************************************************************************** + +/// A request awaiting the community's answer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Pending

{ + pub thid: String, + pub sent_at: Instant, + pub purpose: P, + /// The community it went to: the panel's own, or (Elections) the one + /// holding a notified election. + pub to: String, +} + +/// A panel that sends one request to a community at a time and waits for its +/// answer. Everything here is the panel's; the waiting is [this module](self)'s. +pub(crate) trait CommunityFeature: 'static { + type View; + /// What a request was for, and so what its answer means. + type Purpose: std::fmt::Debug; + /// The community's classified answer. + type Reply; + /// The step an answer calls for, taken by [`advance`]. + type Next: Clone; + + /// For log lines (`"rooms"`, `"elections"`). + const NAME: &'static str; + /// The lane its sends take. + const SEND: DispatchDomain; + /// Whether a request's thread is recorded in the runtime's + /// [`PendingReplies`] ([`ReplyKind::ElectionAnswer`]), so the inbound gate + /// admits the answer of a community this persona holds no membership with. + const RECORDS_THREAD: bool; + + fn view_mut(state: &mut State) -> Option<&mut Self::View>; + fn pending_mut(view: &mut Self::View) -> &mut Option>; + fn next_mut(view: &mut Self::View) -> &mut Option; + /// The panel's community, and the persona it speaks as. + fn community(view: &Self::View) -> (&str, PersonaId); + /// Say something on the view's status line. + fn note(view: &mut Self::View, severity: Severity, text: String); + /// Wrap a send's outcome for the dispatch channel. + fn outcome(outcome: SendOutcome) -> DispatchOutcome; + + /// A refusal, which may answer another panel's request too, so one that + /// matches nothing here is dropped without a warning. + fn is_refusal(reply: &Self::Reply) -> bool; + /// Take the answer to the request made for `purpose` (no longer pending). + fn take_reply(view: &mut Self::View, reply: Self::Reply, purpose: Self::Purpose); + /// The request made for `purpose` went unanswered for `secs` seconds. + fn timed_out(view: &mut Self::View, purpose: Self::Purpose, secs: u64); + /// The request made for `purpose` never left, for `why`. + fn failed(view: &mut Self::View, purpose: &Self::Purpose, why: String); + /// End whatever multi-step flow is in progress (a job was lost). + fn end_flow(view: &mut Self::View); + + /// Take `next`. Returns whether it was taken; one that was not (a busy + /// lane) is kept for the next tick. + async fn step(lp: &mut CommunityLoop<'_>, next: Self::Next) -> bool; + /// Anything to do before a step is taken. + fn before_step(_lp: &mut CommunityLoop<'_>) {} +} + +/// Say a request never left, where the view shows it. +pub(crate) fn fail(state: &mut State, purpose: &F::Purpose, why: String) { + if let Some(view) = F::view_mut(state) { + F::failed(view, purpose, why); + } +} + +impl CommunityLoop<'_> { + /// Arm the open view on `doc`'s thread and spawn its send to `to`. Returns + /// whether it was dispatched — or failed in a way the view now shows; `false` + /// only for a busy lane, with nothing armed. + /// + /// Armed before the send is spawned, never from its outcome: a community + /// that answers at once can have its answer applied first — to find nothing + /// pending, be dropped, and leave the view to time out on a request that + /// was answered. + pub(crate) fn spawn_send( + &mut self, + sender: Sender, + to: String, + persona: PersonaId, + doc: Doc, + purpose: F::Purpose, + ) -> bool { + let Some(thid) = doc.thread() else { + fail::( + self.state, + &purpose, + "couldn't send: a request without an id".into(), + ); + return true; + }; + if !self.in_flight.try_begin(F::SEND) { + return false; + } + if F::RECORDS_THREAD { + // Its answer is admitted from this community on this thread, member or not. + self.pending_replies + .record(&thid, &to, pending_replies::Purpose::ElectionAnswer); + } + if let Some(view) = F::view_mut(self.state) { + *F::pending_mut(view) = Some(Pending { + thid, + sent_at: Instant::now(), + purpose, + to: to.clone(), + }); + } + let job = SendJob { + sender, + vtc_did: to, + persona, + doc, + }; + background_dispatch::spawn_dispatch(self.dispatch_tx.clone(), F::SEND, async move { + F::outcome(job.run().await) + }); + true + } +} + +/// Stop waiting on `thid` at the inbound gate, for a panel that records threads. +fn forget(pending_replies: &mut PendingReplies, thid: &str) { + if F::RECORDS_THREAD { + pending_replies.forget(ReplyKind::ElectionAnswer, thid); + } +} + +/// Say the send never left — while the view still waits on this request. A +/// send that did needs nothing: the view was armed before it was spawned, and +/// its answer may already be applied. +pub(crate) fn send_failed( + state: &mut State, + pending_replies: &mut PendingReplies, + outcome: SendOutcome, +) { + let Err(e) = outcome.result else { + return; + }; + forget::(pending_replies, &outcome.thid); + let Some(view) = F::view_mut(state) else { + return; + }; + let (_, persona) = F::community(view); + let awaited = persona == outcome.persona + && F::pending_mut(view) + .as_ref() + .is_some_and(|p| p.thid == outcome.thid && p.to == outcome.vtc_did); + if !awaited { + tracing::debug!( + thid = %outcome.thid, + "{} send failed for a request no longer awaited: {e}", + F::NAME + ); + return; + } + if let Some(p) = F::pending_mut(view).take() { + F::failed( + view, + &p.purpose, + format!("couldn't reach the community: {e}"), + ); + } +} + +/// Apply the community's answers. Returns whether any was taken, so the loop +/// [`advance`]s. +pub(crate) fn apply_replies( + state: &mut State, + pending_replies: &mut PendingReplies, + replies: Vec>, +) -> bool { + let mut taken = false; + for inbound in replies { + // The community the outstanding request went to. + let Some(community) = F::view_mut(state).map(|v| { + let own = F::community(v).0.to_string(); + F::pending_mut(v).as_ref().map_or(own, |p| p.to.clone()) + }) else { + continue; + }; + if !answered_by(&community, &inbound.from, inbound.issuer.as_deref()) { + tracing::warn!( + from = %inbound.from, + thid = %inbound.thid, + "{} reply not from the community the view is asking — dropped", + F::NAME + ); + continue; + } + taken |= apply_reply::(state, pending_replies, &inbound.thid, inbound.reply); + } + taken +} + +fn apply_reply( + state: &mut State, + pending_replies: &mut PendingReplies, + thid: &str, + reply: F::Reply, +) -> bool { + let Some(view) = F::view_mut(state) else { + return false; + }; + let slot = F::pending_mut(view); + if slot.as_ref().is_none_or(|p| p.thid != thid) { + if !F::is_refusal(&reply) { + tracing::warn!(%thid, "{} reply matches no pending request — dropped", F::NAME); + } + return false; + } + let Some(pending) = slot.take() else { + return false; + }; + forget::(pending_replies, &pending.thid); + F::take_reply(view, reply, pending.purpose); + true +} + +/// The loop's periodic pass: the reply window, and any step left waiting on a +/// busy lane. +pub(crate) async fn tick(lp: &mut CommunityLoop<'_>) { + let now = Instant::now(); + if let Some(view) = F::view_mut(lp.state) { + let slot = F::pending_mut(view); + if slot + .as_ref() + .is_some_and(|p| now.duration_since(p.sent_at) > REPLY_WINDOW) + && let Some(p) = slot.take() + { + forget::(lp.pending_replies, &p.thid); + F::timed_out(view, p.purpose, REPLY_WINDOW.as_secs()); + } + } + advance::(lp).await; +} + +/// Take the step the view's last answer called for, if its lane is free. The +/// loop calls this after applying replies and dispatch outcomes, and on its +/// tick. +pub(crate) async fn advance(lp: &mut CommunityLoop<'_>) { + F::before_step(lp); + let Some(next) = F::view_mut(lp.state).and_then(|v| F::next_mut(v).take()) else { + return; + }; + let taken = F::step(lp, next.clone()).await; + // A lane that was busy: keep the step for the next tick. + if !taken && let Some(view) = F::view_mut(lp.state) { + F::next_mut(view).get_or_insert(next); + } +} + +/// A job ended without an outcome (it panicked): end what it was part of. +pub(crate) fn job_lost(state: &mut State, msg: &str) { + if let Some(view) = F::view_mut(state) { + *F::pending_mut(view) = None; + *F::next_mut(view) = None; + F::end_flow(view); + F::note(view, Severity::Error, msg.to_string()); + } +} + +#[cfg(test)] +mod tests { + //! The engine, driven through the real panels: Rooms (whose answers come + //! only from a community the persona belongs to) and Elections (whose + //! threads are recorded at the inbound gate). + + use super::*; + use crate::state_handler::elections_actions::Elections; + use crate::state_handler::main_page::elections::{self as el, ElectionsView}; + use crate::state_handler::main_page::rooms::{self as rm, RoomsView}; + use crate::state_handler::rooms_actions::Rooms; + use openvtc_core::community_rooms::{Refusal, Reply}; + + const VTC: &str = "did:key:zVtc"; + + fn rooms_state() -> State { + let mut state = State::default(); + state.main_page.content_panel.rooms.view = Some(RoomsView::new( + VTC.into(), + PersonaId::default(), + "did:key:zAlice".into(), + "Community".into(), + )); + state + } + + fn rooms(state: &mut State) -> &mut RoomsView { + Rooms::view_mut(state).unwrap() + } + + fn arm_rooms(state: &mut State, thid: &str, sent_at: Instant) { + rooms(state).pending = Some(Pending { + thid: thid.into(), + sent_at, + purpose: rm::Purpose::Renewals, + to: VTC.into(), + }); + } + + fn refused(thid: &str, from: &str) -> ThreadedReply { + ThreadedReply { + from: from.into(), + issuer: Some(from.into()), + thid: thid.into(), + reply: Reply::Refused(Refusal { + code: "vtc/rooms/offers/list:unavailable".into(), + message: None, + }), + } + } + + fn outcome(thid: &str, persona: PersonaId, result: Result<(), String>) -> SendOutcome { + SendOutcome { + vtc_did: VTC.into(), + persona, + thid: thid.into(), + result, + } + } + + fn status(state: &mut State) -> String { + rooms(state) + .status + .as_ref() + .map(|s| s.text.clone()) + .unwrap_or_default() + } + + #[test] + fn capitalise_upper_cases_only_the_first_letter() { + assert_eq!(capitalise("no reply"), "No reply"); + assert_eq!(capitalise("éclair"), "Éclair"); + assert_eq!(capitalise(""), ""); + } + + /// An answer is the community's only when it both sent and issued it, + /// compared by DID — a fragment or a did:scid `?src=` makes no difference. + #[test] + fn an_answer_is_the_communitys_only_when_sent_and_issued_by_it() { + assert!(answered_by(VTC, VTC, Some(&format!("{VTC}#key-1")))); + assert!(!answered_by(VTC, VTC, None)); + assert!(!answered_by(VTC, "did:key:zMallory", Some(VTC))); + assert!(!answered_by(VTC, VTC, Some("did:key:zMallory"))); + let scid = "did:scid:vh:1:QmScid"; + assert!(answered_by( + scid, + &format!("{scid}?src=vtc.example"), + Some(scid) + )); + } + + /// A VTA-signed document threads on its `id`; one without an id has no + /// thread to wait on. + #[test] + fn a_signed_document_threads_on_its_id() { + let doc = Doc::Signed(serde_json::json!({ "id": "urn:uuid:x" })); + assert_eq!(doc.thread().as_deref(), Some("urn:uuid:x")); + assert_eq!(Doc::Signed(serde_json::json!({ "id": "" })).thread(), None); + assert_eq!(Doc::Signed(serde_json::json!({})).thread(), None); + } + + /// The answer on the thread the view waits on, from the community asked, + /// is taken once; one from anyone else, or on another thread, is dropped + /// and the wait goes on. + #[test] + fn an_answer_is_taken_once_and_only_on_its_thread_from_its_community() { + let mut state = rooms_state(); + let mut table = PendingReplies::default(); + arm_rooms(&mut state, "t1", Instant::now()); + + assert!(!apply_replies::( + &mut state, + &mut table, + vec![refused("t1", "did:key:zMallory")] + )); + assert!(!apply_replies::( + &mut state, + &mut table, + vec![refused("t2", VTC)] + )); + assert!(rooms(&mut state).pending.is_some(), "still waiting"); + + assert!(apply_replies::( + &mut state, + &mut table, + vec![refused("t1", VTC)] + )); + assert!(rooms(&mut state).pending.is_none()); + assert!( + !apply_replies::(&mut state, &mut table, vec![refused("t1", VTC)]), + "a second answer on the thread matches nothing" + ); + } + + /// A send's failure ends only the wait it belongs to: not a newer request's, + /// not another persona's — and it says why where the view shows it. + #[test] + fn a_failed_send_ends_only_its_own_wait() { + let mut state = rooms_state(); + let mut table = PendingReplies::default(); + let persona = rooms(&mut state).persona; + arm_rooms(&mut state, "t2", Instant::now()); + + send_failed::( + &mut state, + &mut table, + outcome("t1", persona, Err("down".into())), + ); + assert!(rooms(&mut state).pending.is_some(), "a stale failure"); + send_failed::( + &mut state, + &mut table, + outcome("t2", PersonaId::new(), Err("down".into())), + ); + assert!(rooms(&mut state).pending.is_some(), "another persona's"); + send_failed::(&mut state, &mut table, outcome("t2", persona, Ok(()))); + assert!(rooms(&mut state).pending.is_some(), "a send that left"); + + send_failed::( + &mut state, + &mut table, + outcome("t2", persona, Err("down".into())), + ); + assert!(rooms(&mut state).pending.is_none()); + } + + /// A job that panicked ends the wait, the flow and the next step, and says so. + #[test] + fn a_lost_job_ends_what_it_was_part_of() { + let mut state = rooms_state(); + arm_rooms(&mut state, "t1", Instant::now()); + rooms(&mut state).next = Some(rm::Next::Refresh); + rooms(&mut state).accept = Some(rm::AcceptFlow { + room: "did:key:zR1".into(), + step: rm::AcceptStep::Collecting, + offer: None, + }); + job_lost::(&mut state, "Data room request failed (internal error)"); + let view = rooms(&mut state); + assert!(view.pending.is_none() && view.next.is_none() && view.accept.is_none()); + assert_eq!( + view.status.as_ref().unwrap().severity, + crate::state_handler::main_page::status::Severity::Error + ); + } + + /// Ticks inside the window leave the wait; the first past it ends it, says + /// so in the panel's words, and — for a panel whose threads the gate admits + /// — stops the gate admitting an answer on it. A step whose lane is busy is + /// kept for the next tick. + #[tokio::test] + async fn the_window_closes_a_wait_and_a_busy_step_is_kept() { + let config = crate::state_handler::dispatch_util::test_config(); + let tdk = crate::state_handler::dispatch_util::test_tdk().await; + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let mut in_flight = InFlight::default(); + let mut table = PendingReplies::default(); + + // Rooms: inside the window, then past it. + let mut state = rooms_state(); + arm_rooms(&mut state, "t1", Instant::now()); + // The lane is busy, so the refresh it calls for waits. + assert!(in_flight.try_begin(DispatchDomain::Rooms)); + rooms(&mut state).next = Some(rm::Next::Refresh); + { + let mut lp = CommunityLoop { + state: &mut state, + config: &config, + tdk: &tdk, + vta: None, + dispatch_tx: &tx, + in_flight: &mut in_flight, + pending_replies: &mut table, + }; + tick::(&mut lp).await; + } + assert!(rooms(&mut state).pending.is_some()); + assert_eq!(rooms(&mut state).next, Some(rm::Next::Refresh), "kept"); + + arm_rooms( + &mut state, + "t1", + Instant::now() - REPLY_WINDOW - Duration::from_secs(1), + ); + { + let mut lp = CommunityLoop { + state: &mut state, + config: &config, + tdk: &tdk, + vta: None, + dispatch_tx: &tx, + in_flight: &mut in_flight, + pending_replies: &mut table, + }; + tick::(&mut lp).await; + } + assert!(rooms(&mut state).pending.is_none()); + assert!(status(&mut state).contains("within 30s")); + + // Elections: the gate stops admitting the thread too. + let mut state = State::default(); + state.main_page.content_panel.elections.view = Some(ElectionsView::new( + VTC.into(), + PersonaId::default(), + "did:key:zAlice".into(), + "Community".into(), + )); + Elections::view_mut(&mut state).unwrap().pending = Some(Pending { + thid: "e1".into(), + sent_at: Instant::now() - REPLY_WINDOW - Duration::from_secs(1), + purpose: el::Purpose::Issue { + election: "agm".into(), + }, + to: "did:key:zHolder".into(), + }); + table.record( + "e1", + "did:key:zHolder", + pending_replies::Purpose::ElectionAnswer, + ); + { + let mut lp = CommunityLoop { + state: &mut state, + config: &config, + tdk: &tdk, + vta: None, + dispatch_tx: &tx, + in_flight: &mut in_flight, + pending_replies: &mut table, + }; + tick::(&mut lp).await; + } + assert!(Elections::view_mut(&mut state).unwrap().pending.is_none()); + assert!(!table.is_awaited(ReplyKind::ElectionAnswer, "did:key:zHolder", "e1")); + } +} diff --git a/openvtc/src/state_handler/elections_actions.rs b/openvtc/src/state_handler/elections_actions.rs index bbc6a311..6c5284ed 100644 --- a/openvtc/src/state_handler/elections_actions.rs +++ b/openvtc/src/state_handler/elections_actions.rs @@ -26,10 +26,8 @@ //! its first answer, so an interrupted ticket or vote is finished by starting it again; a cast //! whose answer was lost is resent **unchanged** (`r` on the result), never rebuilt. -use std::time::{Duration, Instant}; +use std::time::Instant; -use affinidi_tdk::TDK; -use openvtc_core::capabilities::{RequestDocument, correlation_thread}; use openvtc_core::config::Config; use openvtc_core::config::account::PersonaId; use openvtc_core::elections::{ @@ -37,17 +35,18 @@ use openvtc_core::elections::{ Voter, }; use openvtc_core::notifications::NotifiedElection; -use openvtc_core::pending_replies::{PendingReplies, ReplyKind}; -use tokio::sync::mpsc::UnboundedSender; -use vta_sdk::client::VtaClient; +use openvtc_core::pending_replies::PendingReplies; use crate::state_handler::actions::ElectionsAction as Act; use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; +pub(crate) use crate::state_handler::community_request::CommunityLoop as Loop; +use crate::state_handler::community_request::{ + self, CommunityFeature, Doc, SendOutcome, capitalise, fail, sender, +}; use crate::state_handler::main_page::elections::{ BallotForm, Disclose, ElectionsPhase, ElectionsView, Flow, Intent, Next, NotifiedPhase, - Pending, Purpose, Row, Screen, Severity, Step, VoteResult, + Purpose, Row, Screen, Severity, Step, VoteResult, }; -use crate::state_handler::repos_actions::{Sender, sender}; use crate::state_handler::runtime_actions::ActionCtx; use crate::state_handler::state::State; @@ -55,17 +54,10 @@ const SEND: DispatchDomain = DispatchDomain::Elections; const VTA: DispatchDomain = DispatchDomain::ElectionsVta; const CAST: DispatchDomain = DispatchDomain::ElectionsCast; -/// How long a request waits for the community's answer (R1.2). -pub(crate) const REPLY_WINDOW: Duration = Duration::from_secs(30); - /// A `vtc/elections/*` answer as it arrived, threaded on the request it answers /// ([`openvtc_core::inbound::ThreadedReply`]). pub(crate) type InboundReply = openvtc_core::inbound::ThreadedReply; -fn did_part(did: &str) -> &str { - did.split('#').next().unwrap_or(did) -} - fn view_mut(state: &mut State) -> Option<&mut ElectionsView> { state.main_page.content_panel.elections.view.as_mut() } @@ -178,271 +170,267 @@ fn review(view: &mut ElectionsView) { } } -fn capitalise(s: &str) -> String { - let mut c = s.chars(); - c.next() - .map(|f| f.to_uppercase().collect::() + c.as_str()) - .unwrap_or_default() -} - // **************************************************************************** // The loop's side // **************************************************************************** -/// What a send or a VTA call needs from the loop. -pub(crate) struct Loop<'a> { - pub(crate) state: &'a mut State, - pub(crate) config: &'a Config, - pub(crate) tdk: &'a TDK, - pub(crate) vta: Option<&'a VtaClient>, - pub(crate) dispatch_tx: &'a UnboundedSender, - pub(crate) in_flight: &'a mut InFlight, - /// Where a request to a community this persona may not belong to is - /// recorded, so the inbound gate admits its answer. - pub(crate) pending_replies: &'a mut PendingReplies, -} +/// The Elections panel, on the shared request/reply plumbing +/// ([`community_request`]). +pub(crate) struct Elections; -impl Loop<'_> { - /// Send `request` to the open view's community, armed on `purpose`. Returns whether it was - /// dispatched; every early return says why in the view. - async fn send(&mut self, request: Request, purpose: Purpose) -> bool { - // The list is always the panel's own community's; one election is read from where it - // is held. - let to_target = !matches!(request, Request::List { .. }); - self.dispatch(Outgoing::Build(request), purpose, to_target) - .await - } +impl CommunityFeature for Elections { + type View = ElectionsView; + type Purpose = Purpose; + type Reply = Reply; + type Next = Next; + const NAME: &'static str = "elections"; + const SEND: DispatchDomain = SEND; + /// A notified election is read from the community holding it, which may be one this + /// persona is not a member of (VTI-NTF-063): its answer is admitted only on a thread + /// recorded for it. + const RECORDS_THREAD: bool = true; - /// Carry a document the VTA signed to the view's target community, unchanged. - async fn send_signed(&mut self, document: serde_json::Value, purpose: Purpose) -> bool { - self.dispatch(Outgoing::Signed(document), purpose, true) - .await + fn view_mut(state: &mut State) -> Option<&mut ElectionsView> { + view_mut(state) + } + fn pending_mut(view: &mut ElectionsView) -> &mut Option> { + &mut view.pending + } + fn next_mut(view: &mut ElectionsView) -> &mut Option { + &mut view.next + } + fn community(view: &ElectionsView) -> (&str, PersonaId) { + (&view.vtc_did, view.persona) + } + fn note(view: &mut ElectionsView, severity: Severity, text: String) { + view.note(severity, text); + } + fn outcome(outcome: SendOutcome) -> DispatchOutcome { + DispatchOutcome::Elections(outcome) + } + fn is_refusal(reply: &Reply) -> bool { + matches!(reply, Reply::Refused(_)) } + fn take_reply(view: &mut ElectionsView, reply: Reply, purpose: Purpose) { + take_reply(view, reply, purpose); + } + fn timed_out(view: &mut ElectionsView, purpose: Purpose, secs: u64) { + timed_out(view, purpose, secs); + } + fn failed(view: &mut ElectionsView, purpose: &Purpose, why: String) { + failed(view, purpose, why); + } + fn end_flow(view: &mut ElectionsView) { + view.flow = None; + } + async fn step(lp: &mut Loop<'_>, next: Next) -> bool { + step(lp, next).await + } + fn before_step(lp: &mut Loop<'_>) { + ask_notified(lp); + } +} - async fn dispatch(&mut self, outgoing: Outgoing, purpose: Purpose, to_target: bool) -> bool { - let Some((vtc_did, target, persona, waiting)) = self - .state - .main_page - .content_panel - .elections - .view - .as_ref() - .map(|v| { - ( - v.vtc_did.clone(), - v.target.clone(), - v.persona, - v.pending.is_some(), - ) - }) - else { - return false; - }; - if waiting { - return false; +/// Send `request` to the open view's community, armed on `purpose`. Returns whether it was +/// dispatched; every early return says why in the view. +async fn send(lp: &mut Loop<'_>, request: Request, purpose: Purpose) -> bool { + // The list is always the panel's own community's; one election is read from where it + // is held. + let to_target = !matches!(request, Request::List { .. }); + send_to(lp, Outgoing::Build(request), purpose, to_target).await +} + +/// Carry a document the VTA signed to the view's target community, unchanged. +async fn send_signed(lp: &mut Loop<'_>, document: serde_json::Value, purpose: Purpose) -> bool { + send_to(lp, Outgoing::Signed(document), purpose, true).await +} + +async fn send_to(lp: &mut Loop<'_>, outgoing: Outgoing, purpose: Purpose, to_target: bool) -> bool { + let Some((vtc_did, target, persona, waiting)) = lp + .state + .main_page + .content_panel + .elections + .view + .as_ref() + .map(|v| { + ( + v.vtc_did.clone(), + v.target.clone(), + v.persona, + v.pending.is_some(), + ) + }) + else { + return false; + }; + if waiting { + return false; + } + let to = if to_target { target } else { vtc_did }; + let mut sender = match sender(lp.config, lp.tdk, persona, &to).await { + Ok(s) => s, + Err(e) => { + fail::(lp.state, &purpose, e); + return true; } - let to = if to_target { target } else { vtc_did }; - let mut sender = match sender(self.config, self.tdk, persona, &to).await { - Ok(s) => s, + }; + // A community this persona is not a member of (one holding a notified election) has + // no membership transport to follow: TSP when the persona speaks it anywhere, so the + // answer comes back on the socket TSP answers arrive on; DIDComm otherwise. + if lp.config.account.membership(&to, persona).is_none() { + sender.over_tsp = lp + .config + .account + .memberships() + .any(|c| c.persona_ref == persona && c.joined_over_tsp()); + } + let doc = match outgoing { + Outgoing::Build(request) => match elections::build(&request, &sender.persona_did, &to) { + Ok(doc) => Doc::Unsigned(Box::new(doc)), Err(e) => { - fail(self.state, &purpose, e); + fail::(lp.state, &purpose, format!("couldn't send: {e}")); return true; } - }; - // A community this persona is not a member of (one holding a notified election) has - // no membership transport to follow: TSP when the persona speaks it anywhere, so the - // answer comes back on the socket TSP answers arrive on; DIDComm otherwise. - if self.config.account.membership(&to, persona).is_none() { - sender.over_tsp = self - .config - .account - .memberships() - .any(|c| c.persona_ref == persona && c.joined_over_tsp()); - } - let doc = match outgoing { - Outgoing::Build(request) => { - match elections::build(&request, &sender.persona_did, &to) { - Ok(doc) => Doc::Unsigned(Box::new(doc)), - Err(e) => { - fail(self.state, &purpose, format!("couldn't send: {e}")); - return true; - } - } - } - Outgoing::Signed(document) => { - let addressed = document - .get("recipient") - .and_then(serde_json::Value::as_str); - if !addressed.is_some_and(|r| vta_sdk::did_scid::same_reference(r, &to)) { - fail( - self.state, - &purpose, - "your VTA signed the request for another community than this one".into(), - ); - return true; - } - Doc::Signed(document) + }, + Outgoing::Signed(document) => { + let addressed = document + .get("recipient") + .and_then(serde_json::Value::as_str); + if !addressed.is_some_and(|r| vta_sdk::did_scid::same_reference(r, &to)) { + fail::( + lp.state, + &purpose, + "your VTA signed the request for another community than this one".into(), + ); + return true; } - }; - let Some(thid) = doc.thread() else { - fail( - self.state, - &purpose, - "couldn't send: a request without an id".into(), - ); - return true; - }; - if !self.in_flight.try_begin(SEND) { - return false; + Doc::Signed(document) } - // Its answer is admitted from this community on this thread, member or not. - self.pending_replies.record( - &thid, - &to, - openvtc_core::pending_replies::Purpose::ElectionAnswer, + }; + lp.spawn_send::(sender, to, persona, doc, purpose) +} + +/// Ask the VTA. Returns whether it was dispatched (or failed in a way the view now shows). +fn ask_vta(lp: &mut Loop<'_>, ask: VtaAsk) -> bool { + let Some((vtc_did, target, persona)) = lp + .state + .main_page + .content_panel + .elections + .view + .as_ref() + .map(|v| (v.vtc_did.clone(), v.target.clone(), v.persona)) + else { + return true; + }; + let Some(vta) = lp.vta.cloned() else { + end_flow( + lp.state, + Severity::Error, + "Voting needs your VTA: it holds the voter keys and the ticket. None is \ + connected.", ); - if let Some(view) = view_mut(self.state) { - view.pending = Some(Pending { - thid, - sent_at: Instant::now(), - purpose, - to: to.clone(), - }); - } - let job = SendJob { - sender, - vtc_did: to, - persona, - doc, - }; - background_dispatch::spawn_dispatch(self.dispatch_tx.clone(), SEND, async move { - DispatchOutcome::Elections(job.run().await) - }); - true + return true; + }; + let Some(context_id) = persona_context(lp.config, persona) else { + end_flow( + lp.state, + Severity::Error, + "This persona has no VTA context to hold its voter keys.", + ); + return true; + }; + if !lp.in_flight.try_begin(VTA) { + return false; } - - /// Ask the VTA. Returns whether it was dispatched (or failed in a way the view now shows). - fn ask_vta(&mut self, ask: VtaAsk) -> bool { - let Some((vtc_did, target, persona)) = self - .state - .main_page - .content_panel - .elections - .view - .as_ref() - .map(|v| (v.vtc_did.clone(), v.target.clone(), v.persona)) - else { - return true; - }; - let Some(vta) = self.vta.cloned() else { - end_flow( - self.state, - Severity::Error, - "Voting needs your VTA: it holds the voter keys and the ticket. None is \ - connected.", - ); - return true; + background_dispatch::spawn_dispatch(lp.dispatch_tx.clone(), VTA, async move { + // The registrar is the community holding the election. + let voter = Voter { + vta: &vta, + context_id: &context_id, + community: &target, }; - let Some(context_id) = persona_context(self.config, persona) else { - end_flow( - self.state, - Severity::Error, - "This persona has no VTA context to hold its voter keys.", - ); - return true; - }; - if !self.in_flight.try_begin(VTA) { - return false; - } - background_dispatch::spawn_dispatch(self.dispatch_tx.clone(), VTA, async move { - // The registrar is the community holding the election. - let voter = Voter { - vta: &vta, - context_id: &context_id, - community: &target, - }; - let answer = ask.run(&voter).await; - DispatchOutcome::ElectionsVta(ElectionsVtaOutcome { - vtc_did, - persona, - answer, - }) - }); - true - } + let answer = ask.run(&voter).await; + DispatchOutcome::ElectionsVta(ElectionsVtaOutcome { + vtc_did, + persona, + answer, + }) + }); + true +} - /// Send the cast to the ballot box, after a random pause. - fn deliver(&mut self, cast: Cast) -> bool { - let Some((vtc_did, persona, ballot_box)) = self - .state - .main_page - .content_panel - .elections - .view - .as_ref() - .and_then(|v| { - v.shown - .as_ref() - .map(|s| (v.vtc_did.clone(), v.persona, s.ballot_box().to_string())) - }) - else { - return true; - }; - if !self.in_flight.try_begin(CAST) { - return false; - } - let resolver = self.tdk.did_resolver().clone(); - background_dispatch::spawn_dispatch(self.dispatch_tx.clone(), CAST, async move { - let result = - elections::deliver_cast(&resolver, &ballot_box, &cast, elections::cast_delay()) - .await; - DispatchOutcome::ElectionsCast(Box::new(ElectionsCastOutcome { - vtc_did, - persona, - cast, - result, - })) - }); - true +/// Send the cast to the ballot box, after a random pause. +fn deliver(lp: &mut Loop<'_>, cast: Cast) -> bool { + let Some((vtc_did, persona, ballot_box)) = lp + .state + .main_page + .content_panel + .elections + .view + .as_ref() + .and_then(|v| { + v.shown + .as_ref() + .map(|s| (v.vtc_did.clone(), v.persona, s.ballot_box().to_string())) + }) + else { + return true; + }; + if !lp.in_flight.try_begin(CAST) { + return false; } + let resolver = lp.tdk.did_resolver().clone(); + background_dispatch::spawn_dispatch(lp.dispatch_tx.clone(), CAST, async move { + let result = + elections::deliver_cast(&resolver, &ballot_box, &cast, elections::cast_delay()).await; + DispatchOutcome::ElectionsCast(Box::new(ElectionsCastOutcome { + vtc_did, + persona, + cast, + result, + })) + }); + true +} - /// Read the list again, and the VTA's held election notifications beside it. - pub(crate) async fn refresh(&mut self) { - match view_mut(self.state) { - Some(view) => { - view.restart_pages(); - view.notified_due = true; - } - None => return, +/// Read the list again, and the VTA's held election notifications beside it. +pub(crate) async fn refresh(lp: &mut Loop<'_>) { + match view_mut(lp.state) { + Some(view) => { + view.restart_pages(); + view.notified_due = true; } - self.send(Request::List { cursor: None }, Purpose::List) - .await; + None => return, } + send(lp, Request::List { cursor: None }, Purpose::List).await; +} - /// Read the open election again — presenting the membership the member chose to show for - /// it, and nothing else (VTI-VOT-016). Returns whether it was dispatched. - async fn read_election(&mut self, election: String, intent: Intent) -> bool { - let presenting = view_mut(self.state).and_then(|v| v.presenting.clone()); - match presenting { - Some(member_of) => { - if let Some(view) = view_mut(self.state) { - view.next = Some(Next::ReadPresenting { - election, - member_of, - intent, - }); - } - true - } - None => { - self.send( - Request::Show { - election_id: election.clone(), - }, - Purpose::Show { election, intent }, - ) - .await +/// Read the open election again — presenting the membership the member chose to show for +/// it, and nothing else (VTI-VOT-016). Returns whether it was dispatched. +async fn read_election(lp: &mut Loop<'_>, election: String, intent: Intent) -> bool { + let presenting = view_mut(lp.state).and_then(|v| v.presenting.clone()); + match presenting { + Some(member_of) => { + if let Some(view) = view_mut(lp.state) { + view.next = Some(Next::ReadPresenting { + election, + member_of, + intent, + }); } + true + } + None => { + send( + lp, + Request::Show { + election_id: election.clone(), + }, + Purpose::Show { election, intent }, + ) + .await } } } @@ -473,10 +461,7 @@ fn end_flow(state: &mut State, severity: Severity, why: impl Into) { } /// Say a request never left, where the view shows it. -fn fail(state: &mut State, purpose: &Purpose, why: String) { - let Some(view) = view_mut(state) else { - return; - }; +fn failed(view: &mut ElectionsView, purpose: &Purpose, why: String) { match purpose { Purpose::List if view.elections.is_empty() => view.phase = ElectionsPhase::Failed(why), Purpose::List | Purpose::Show { .. } | Purpose::Issue { .. } => { @@ -491,21 +476,13 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: Act) { if let Act::Open(index) = action { open(ctx, index); } - let mut lp = Loop { - state: &mut *ctx.state, - config: ctx.config, - tdk: ctx.tdk, - vta: ctx.admin_vta, - dispatch_tx: ctx.dispatch_tx, - in_flight: &mut *ctx.in_flight, - pending_replies: &mut *ctx.pending_replies, - }; + let mut lp = Loop::from_ctx(ctx); match action { Act::Open(_) => { if let Some(view) = view_mut(lp.state) { view.phase = ElectionsPhase::Loading; } - lp.refresh().await; + refresh(&mut lp).await; } Act::Refresh => refresh_screen(&mut lp).await, Act::Show => show_selected(&mut lp).await, @@ -539,12 +516,12 @@ async fn refresh_screen(lp: &mut Loop<'_>) { view.shown.as_ref().map(|s| s.election_id().to_string()), ) { (Screen::Detail, Some(election)) => { - let sent = lp.read_election(election, Intent::View).await; + let sent = read_election(lp, election, Intent::View).await; if !sent && let Some(view) = view_mut(lp.state) { view.note(Severity::Warning, InFlight::busy_message(SEND)); } } - (Screen::List, _) => lp.refresh().await, + (Screen::List, _) => refresh(lp).await, // The first read of this election never arrived: read it again. (Screen::Detail, None) => { view.screen = Screen::List; @@ -592,17 +569,17 @@ async fn show_selected(lp: &mut Loop<'_>) { view.shown = None; view.status = None; view.note(Severity::Progress, "Reading the election…"); - let sent = lp - .send( - Request::Show { - election_id: election.clone(), - }, - Purpose::Show { - election, - intent: Intent::View, - }, - ) - .await; + let sent = send( + lp, + Request::Show { + election_id: election.clone(), + }, + Purpose::Show { + election, + intent: Intent::View, + }, + ) + .await; if !sent && let Some(view) = view_mut(lp.state) { view.screen = Screen::List; view.note(Severity::Warning, InFlight::busy_message(SEND)); @@ -638,7 +615,7 @@ async fn start_flow(lp: &mut Loop<'_>, intent: Intent) { step: Step::Reading, }); view.note(Severity::Progress, "Reading the election again…"); - let sent = lp.read_election(election, intent).await; + let sent = read_election(lp, election, intent).await; if !sent && let Some(view) = view_mut(lp.state) { view.flow = None; view.note(Severity::Warning, InFlight::busy_message(SEND)); @@ -765,20 +742,21 @@ fn open(ctx: &mut ActionCtx<'_>, index: usize) { } /// Take the step the view's last answer called for, if its lane is free. The loop calls this -/// after applying replies and dispatch outcomes, and on its tick. +/// after applying replies and outcomes, and on its tick. pub(crate) async fn advance(lp: &mut Loop<'_>) { - ask_notified(lp); - let Some(next) = view_mut(lp.state).and_then(|v| v.next.take()) else { - return; - }; + community_request::advance::(lp).await; +} + +/// Take one step. Returns whether it was taken (a busy lane keeps it). +async fn step(lp: &mut Loop<'_>, next: Next) -> bool { let shown = view_mut(lp.state).and_then(|v| v.shown.clone()); - let taken = match next.clone() { + match next { Next::Refresh => { if lp.in_flight.is_busy(SEND) || view_mut(lp.state).is_some_and(|v| v.pending.is_some()) { false } else { - lp.refresh().await; + refresh(lp).await; true } } @@ -788,30 +766,31 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { intent, } => { let member = view_mut(lp.state).map(|v| v.me.clone()).unwrap_or_default(); - lp.ask_vta(VtaAsk::ReadPresenting { - member, - election, - member_of, - intent, - }) + ask_vta( + lp, + VtaAsk::ReadPresenting { + member, + election, + member_of, + intent, + }, + ) } Next::SendShowDocument { document, election, intent, - } => { - lp.send_signed(document, Purpose::Show { election, intent }) - .await - } + } => send_signed(lp, document, Purpose::Show { election, intent }).await, Next::SendIssueDocument(document) => { let election = shown .as_ref() .map(|s| s.election_id().to_string()) .unwrap_or_default(); - lp.send_signed(document, Purpose::Issue { election }).await + send_signed(lp, document, Purpose::Issue { election }).await } Next::ListPage { cursor } => { - lp.send( + send( + lp, Request::List { cursor: Some(cursor), }, @@ -822,35 +801,38 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { Next::Register => match shown { Some(shown) => { let member = view_mut(lp.state).map(|v| v.me.clone()).unwrap_or_default(); - lp.ask_vta(VtaAsk::Register { shown, member }) + ask_vta(lp, VtaAsk::Register { shown, member }) } None => true, }, Next::SendIssue(ticket) => { let election = ticket.election_id.clone(); - lp.send(Request::IssueTicket(ticket), Purpose::Issue { election }) - .await + send( + lp, + Request::IssueTicket(ticket), + Purpose::Issue { election }, + ) + .await } Next::Accept { issued } => match shown { - Some(shown) => lp.ask_vta(VtaAsk::Accept { shown, issued }), + Some(shown) => ask_vta(lp, VtaAsk::Accept { shown, issued }), None => true, }, Next::Vote { ballot } => match shown { - Some(shown) => lp.ask_vta(VtaAsk::Vote { shown, ballot }), + Some(shown) => ask_vta(lp, VtaAsk::Vote { shown, ballot }), None => true, }, - Next::Deliver(cast) => lp.deliver(cast), + Next::Deliver(cast) => deliver(lp, cast), Next::Keep(receipt) => match shown { - Some(shown) => lp.ask_vta(VtaAsk::Keep { - election: shown.election_id().to_string(), - receipt, - }), + Some(shown) => ask_vta( + lp, + VtaAsk::Keep { + election: shown.election_id().to_string(), + receipt, + }, + ), None => true, }, - }; - // A lane that was busy: keep the step for the next tick. - if !taken && let Some(view) = view_mut(lp.state) { - view.next.get_or_insert(next); } } @@ -872,7 +854,7 @@ fn ask_notified(lp: &mut Loop<'_>) { if lp.in_flight.is_busy(VTA) { return; } - if lp.ask_vta(VtaAsk::Notified) + if ask_vta(lp, VtaAsk::Notified) && let Some(view) = view_mut(lp.state) { view.notified_due = false; @@ -881,175 +863,46 @@ fn ask_notified(lp: &mut Loop<'_>) { /// The loop's periodic pass: reply windows, and any step left waiting on a busy lane. pub(crate) async fn tick(lp: &mut Loop<'_>) { - let now = Instant::now(); - if let Some(view) = view_mut(lp.state) - && view - .pending - .as_ref() - .is_some_and(|p| now.duration_since(p.sent_at) > REPLY_WINDOW) - && let Some(p) = view.pending.take() - { - lp.pending_replies - .forget(ReplyKind::ElectionAnswer, &p.thid); - let secs = REPLY_WINDOW.as_secs(); - match p.purpose { - Purpose::List if view.elections.is_empty() => { - // A community that does not serve elections still answers, with a refusal - // shown in words, so silence means the request or its answer was lost (R6.4). - view.phase = ElectionsPhase::Failed(format!( - "no reply within {secs}s. A community that does not serve elections says \ + community_request::tick::(lp).await; +} + +/// The request made for `purpose` went unanswered for `secs` seconds. +fn timed_out(view: &mut ElectionsView, purpose: Purpose, secs: u64) { + match purpose { + Purpose::List if view.elections.is_empty() => { + // A community that does not serve elections still answers, with a refusal + // shown in words, so silence means the request or its answer was lost (R6.4). + view.phase = ElectionsPhase::Failed(format!( + "no reply within {secs}s. A community that does not serve elections says \ so, so this is not that: the request or its answer was lost — the \ community's VTC is offline or unreachable, or it answered on a route this \ persona does not collect. Press r to try again." - )); - } - Purpose::List | Purpose::Show { .. } => { - view.flow = None; - view.note( - Severity::Error, - format!("No reply from the community within {secs}s. Press r to try again."), - ); - } - Purpose::Issue { .. } => { - view.flow = None; - view.note( - Severity::Error, - format!( - "No reply to the ticket request within {secs}s. Taking the ticket again \ + )); + } + Purpose::List | Purpose::Show { .. } => { + view.flow = None; + view.note( + Severity::Error, + format!("No reply from the community within {secs}s. Press r to try again."), + ); + } + Purpose::Issue { .. } => { + view.flow = None; + view.note( + Severity::Error, + format!( + "No reply to the ticket request within {secs}s. Taking the ticket again \ is safe: your VTA repeats the same request, and the community answers \ it as before." - ), - ); - } + ), + ); } } - advance(lp).await; } /// A job ended without an outcome (it panicked): end what it was part of. pub(crate) fn job_lost(state: &mut State, msg: &str) { - if let Some(view) = view_mut(state) { - view.pending = None; - view.flow = None; - view.next = None; - view.note(Severity::Error, msg.to_string()); - } -} - -// **************************************************************************** -// Sending to the community -// **************************************************************************** - -/// One `vtc/elections/*` send, off the loop. -pub(crate) struct SendJob { - sender: Sender, - vtc_did: String, - persona: PersonaId, - doc: Doc, -} - -/// A document on its way to the community. -enum Doc { - /// Built here: signed as the persona before it goes. - Unsigned(Box), - /// Signed by the VTA as the member, carrying a presentation: sent exactly as it answered. - Signed(serde_json::Value), -} - -impl Doc { - /// The thread its answer carries: the document's id. - fn thread(&self) -> Option { - match self { - Doc::Unsigned(doc) => Some(correlation_thread(doc).to_string()), - Doc::Signed(doc) => doc - .get("id") - .and_then(serde_json::Value::as_str) - .filter(|id| !id.is_empty()) - .map(str::to_string), - } - } -} - -impl SendJob { - async fn run(mut self) -> ElectionsOutcome { - let thid = self.doc.thread().unwrap_or_default(); - let result = match &mut self.doc { - Doc::Unsigned(doc) => { - crate::state_handler::repos_actions::sign_and_send(&self.sender, &self.vtc_did, doc) - .await - } - Doc::Signed(doc) => send_signed_document(&self.sender, &self.vtc_did, doc).await, - }; - ElectionsOutcome { - vtc_did: self.vtc_did, - persona: self.persona, - thid, - result, - } - } -} - -/// Send a document the VTA signed, unchanged, on the community's transport. -async fn send_signed_document( - s: &Sender, - vtc_did: &str, - doc: &serde_json::Value, -) -> Result<(), String> { - let id = doc - .get("id") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_string(); - let tsp_mediator = openvtc_core::community_send::tsp_mediator_for(s.over_tsp, vtc_did).await; - openvtc_core::community_send::send_document( - &openvtc_core::community_send::Delivery { - atm: &s.atm, - profile: &s.profile, - member_did: &s.persona_did, - vtc_did, - mediator_did: &s.mediator, - tsp_mediator_did: tsp_mediator.as_deref(), - }, - id, - doc.clone(), - ) - .await - .map_err(|e| e.to_string()) -} - -/// What a send did. Applied on the loop. -pub(crate) struct ElectionsOutcome { - vtc_did: String, - persona: PersonaId, - thid: String, - result: Result<(), String>, -} - -impl ElectionsOutcome { - /// Say the send never left — while the view still waits on this request. - pub(crate) fn apply(self, state: &mut State, pending_replies: &mut PendingReplies) { - let Err(e) = self.result else { - return; - }; - pending_replies.forget(ReplyKind::ElectionAnswer, &self.thid); - let Some(view) = view_mut(state) else { - return; - }; - if view.pending.as_ref().is_none_or(|p| p.to != self.vtc_did) - || view.persona != self.persona - || view.pending.as_ref().is_none_or(|p| p.thid != self.thid) - { - tracing::debug!(thid = %self.thid, "elections send failed for a request no longer awaited: {e}"); - return; - } - if let Some(p) = view.pending.take() { - fail( - state, - &p.purpose, - format!("couldn't reach the community: {e}"), - ); - } - } + community_request::job_lost::(state, msg); } /// Apply the community's answers. Returns whether any was taken, so the loop [`advance`]s. @@ -1058,54 +911,12 @@ pub(crate) fn apply_replies( pending_replies: &mut PendingReplies, replies: Vec, ) -> bool { - let mut taken = false; - for inbound in replies { - // The community the outstanding request went to: the panel's own, or the one holding a - // notified election. - let Some(vtc) = view_mut(state).map(|v| { - v.pending - .as_ref() - .map_or_else(|| v.vtc_did.clone(), |p| p.to.clone()) - }) else { - continue; - }; - // A thread id is a correlation key, not a credential: an answer is taken only from the - // community the view is asking — sent by it and issued by it. - let from_vtc = vta_sdk::did_scid::same_reference(did_part(&inbound.from), &vtc); - let issued_by_vtc = inbound - .issuer - .as_deref() - .map(did_part) - .is_some_and(|issuer| vta_sdk::did_scid::same_reference(issuer, &vtc)); - if !(from_vtc && issued_by_vtc) { - tracing::warn!(from = %inbound.from, thid = %inbound.thid, "elections reply not from the community the view is asking — dropped"); - continue; - } - taken |= apply_reply(state, pending_replies, &inbound.thid, inbound.reply); - } - taken + community_request::apply_replies::(state, pending_replies, replies) } -fn apply_reply( - state: &mut State, - pending_replies: &mut PendingReplies, - thid: &str, - reply: Reply, -) -> bool { - let Some(view) = view_mut(state) else { - return false; - }; - if view.pending.as_ref().is_none_or(|p| p.thid != thid) { - if !matches!(reply, Reply::Refused(_)) { - tracing::warn!(%thid, "elections reply matches no pending request — dropped"); - } - return false; - } - let Some(pending) = view.pending.take() else { - return false; - }; - pending_replies.forget(ReplyKind::ElectionAnswer, &pending.thid); - match (reply, pending.purpose) { +/// Take the answer to the request made for `purpose`. +fn take_reply(view: &mut ElectionsView, reply: Reply, purpose: Purpose) { + match (reply, purpose) { (Reply::List(page), Purpose::List) => { let next = page.next_cursor.as_ref().map(|c| c.as_str().to_string()); view.take_page(page.elections, next.is_none()); @@ -1121,7 +932,7 @@ fn apply_reply( Severity::Error, "The community answered about another election.", ); - return true; + return; } view.shown = Some(shown); match intent { @@ -1147,7 +958,7 @@ fn apply_reply( }) => { let Some(n) = view.opening.clone() else { - return true; + return; }; view.flow = None; view.status = None; @@ -1192,7 +1003,6 @@ fn apply_reply( ); } } - true } /// The election was read again for a ticket or a vote: go on, or say why not. diff --git a/openvtc/src/state_handler/elections_actions/tests.rs b/openvtc/src/state_handler/elections_actions/tests.rs index c3dfdac6..19e8d2fc 100644 --- a/openvtc/src/state_handler/elections_actions/tests.rs +++ b/openvtc/src/state_handler/elections_actions/tests.rs @@ -1,5 +1,8 @@ use super::*; +use crate::state_handler::community_request::send_failed; +use crate::state_handler::main_page::elections::Pending; use openvtc_core::elections::{Refusal, TicketRequest}; +use openvtc_core::pending_replies::ReplyKind; use serde_json::{Value, json}; const VTC: &str = "did:key:zVtc"; @@ -588,13 +591,16 @@ fn an_answer_taken_or_failed_is_no_longer_awaited_at_the_gate() { arm(&mut state, Purpose::List); pending.record("t1", VTC, election); let persona = view(&mut state).persona; - ElectionsOutcome { - vtc_did: VTC.into(), - persona, - thid: "t1".into(), - result: Err("unreachable".into()), - } - .apply(&mut state, &mut pending); + send_failed::( + &mut state, + &mut pending, + SendOutcome { + vtc_did: VTC.into(), + persona, + thid: "t1".into(), + result: Err("unreachable".into()), + }, + ); assert!(!pending.is_awaited(ReplyKind::ElectionAnswer, VTC, "t1")); assert!(view(&mut state).pending.is_none()); } diff --git a/openvtc/src/state_handler/main_page/elections.rs b/openvtc/src/state_handler/main_page/elections.rs index 62b4ac57..d49f26e1 100644 --- a/openvtc/src/state_handler/main_page/elections.rs +++ b/openvtc/src/state_handler/main_page/elections.rs @@ -75,15 +75,9 @@ pub enum Purpose { Issue { election: String }, } -/// A request to the community awaiting its answer. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Pending { - pub thid: String, - pub sent_at: Instant, - pub purpose: Purpose, - /// The community it went to: the panel's own, or the one holding a notified election. - pub to: String, -} +/// A request to the community awaiting its answer: to the panel's own community, or the +/// one holding a notified election. +pub type Pending = crate::state_handler::community_request::Pending; /// An election read presenting a membership is offered, waiting for the member's choice. #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/openvtc/src/state_handler/main_page/rooms.rs b/openvtc/src/state_handler/main_page/rooms.rs index 7f9ff05e..35d1c890 100644 --- a/openvtc/src/state_handler/main_page/rooms.rs +++ b/openvtc/src/state_handler/main_page/rooms.rs @@ -6,8 +6,6 @@ //! surfaces too — the member portal, `pnm` — and a record kept here would be the one place //! they disagree ([`openvtc_core::community_rooms`]). -use std::time::Instant; - use openvtc_core::community_rooms::{Offer, Renewal, RoomMembership, Standing}; use openvtc_core::config::account::PersonaId; @@ -36,12 +34,7 @@ pub enum Purpose { } /// A request to the community awaiting its answer. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Pending { - pub thid: String, - pub sent_at: Instant, - pub purpose: Purpose, -} +pub type Pending = crate::state_handler::community_request::Pending; /// One room, as every surface shows it. #[derive(Clone, Debug)] diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index 7628e5f4..3353e2a2 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -272,6 +272,7 @@ mod background_dispatch; mod capability_actions; mod community_actions; mod community_context_actions; +pub(crate) mod community_request; mod create_persona; mod credential_actions; mod elections_actions; diff --git a/openvtc/src/state_handler/notification_actions.rs b/openvtc/src/state_handler/notification_actions.rs index 7a8e56bc..2458af24 100644 --- a/openvtc/src/state_handler/notification_actions.rs +++ b/openvtc/src/state_handler/notification_actions.rs @@ -39,7 +39,7 @@ use tokio::sync::Semaphore; use tracing::{debug, info, warn}; use vta_sdk::client::VtaClient; -use crate::state_handler::repos_actions::{Sender, sender}; +use crate::state_handler::community_request::{Sender, sender}; /// How many hand-overs run at once. One beyond that is not handed over and not answered, so /// its sender delivers it again later — a burst never queues unbounded work here. diff --git a/openvtc/src/state_handler/repos_actions.rs b/openvtc/src/state_handler/repos_actions.rs index c57a1cde..09850d41 100644 --- a/openvtc/src/state_handler/repos_actions.rs +++ b/openvtc/src/state_handler/repos_actions.rs @@ -21,16 +21,11 @@ use std::sync::Arc; use std::time::{Duration, Instant}; -use affinidi_tdk::TDK; -use affinidi_tdk::messaging::ATM; -use affinidi_tdk::messaging::profiles::ATMProfile; -use affinidi_tdk::secrets_resolver::secrets::Secret; use chrono::Utc; use openvtc_core::capabilities::{RequestDocument, correlation_thread}; use openvtc_core::config::Config; use openvtc_core::config::account::PersonaId; use openvtc_core::git_ns::{self, GitRight, Reply, Request}; -use tokio::sync::mpsc::UnboundedSender; use crate::state_handler::actions::ReposAction as Act; use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; @@ -42,10 +37,13 @@ use crate::state_handler::main_page::sanitize_display; use crate::state_handler::runtime_actions::ActionCtx; use crate::state_handler::state::State; -const DOMAIN: DispatchDomain = DispatchDomain::GitNs; +pub(crate) use crate::state_handler::community_request::CommunityLoop as Loop; +pub(crate) use crate::state_handler::community_request::REPLY_WINDOW; +use crate::state_handler::community_request::{ + Sender, answered_by, capitalise, sender, sign_and_send, +}; -/// How long a request waits for the community's answer (R1.2). -pub(crate) const REPLY_WINDOW: Duration = Duration::from_secs(30); +const DOMAIN: DispatchDomain = DispatchDomain::GitNs; /// How long `account/link` waits: the VTC itself waits up to 30 s for its /// bridge to begin the forge's flow, and the answer then has to travel back. @@ -63,19 +61,6 @@ pub(crate) fn reply_window(purpose: &Purpose) -> Duration { /// ([`openvtc_core::inbound::ThreadedReply`]). pub(crate) type InboundReply = openvtc_core::inbound::ThreadedReply; -/// The DID part of a DID or DID URL. -fn did_part(did: &str) -> &str { - did.split('#').next().unwrap_or(did) -} - -/// Whether a `git-ns` answer is the community's own: sent by `vtc` and issued -/// by it. A thread id is a correlation key, not a credential. Compared by DID: -/// a did:scid community may name itself with the `?src=` it is resolved through. -pub(crate) fn answered_by(vtc: &str, from: &str, issuer: Option<&str>) -> bool { - let is_vtc = |did: &str| vta_sdk::did_scid::same_reference(did_part(did), vtc); - is_vtc(from) && issuer.is_some_and(is_vtc) -} - /// Longest text kept in a form field. Past every schema limit (a repository /// name is 100, a DID 2048, a reason 1024), so an over-long paste is refused /// by the schema with a reason rather than trimmed into something else. @@ -524,94 +509,10 @@ fn arm_drift(view: &mut ReposView, action: git_ns::DriftAction) { view.confirm = Some(ArmedChange { summary, request }); } -fn capitalise(s: &str) -> String { - let mut c = s.chars(); - c.next() - .map(|f| f.to_uppercase().chain(c).collect()) - .unwrap_or_default() -} - // **************************************************************************** // Loop-side actions // **************************************************************************** -/// Everything a send needs, resolved on the loop thread. -pub(crate) struct Sender { - pub(crate) atm: ATM, - pub(crate) profile: Arc, - pub(crate) persona_did: String, - pub(crate) mediator: String, - /// The membership was joined over TSP, so its requests go over TSP: the - /// community answers on the transport a request arrived on, and a persona - /// that joined over TSP may not collect a DIDComm answer at all. - pub(crate) over_tsp: bool, - pub(crate) signer: Box, -} - -/// Resolve the persona's messaging identity and signing key. The key comes -/// from the TDK secrets resolver, an in-memory store populated at startup — -/// not I/O — which is what lets the job own a plain `Secret`. -pub(crate) async fn sender( - config: &Config, - tdk: &TDK, - persona: PersonaId, - vtc_did: &str, -) -> Result { - let id = config - .identities - .get(&persona) - .ok_or_else(|| "this persona has no messaging identity".to_string())?; - let atm = tdk - .atm - .as_ref() - .ok_or_else(|| "messaging is unavailable".to_string())? - .clone(); - let keys = config - .get_persona_keys_for(persona, tdk) - .await - .map_err(|e| format!("couldn't read this persona's signing key: {e}"))?; - Ok(Sender { - atm, - profile: id.profile().clone(), - persona_did: id.persona_did().to_string(), - mediator: id.mediator_did.clone().unwrap_or_default(), - over_tsp: config - .account - .membership(vtc_did, persona) - .is_some_and(|c| c.joined_over_tsp()), - signer: Box::new(keys.authentication.secret.clone()), - }) -} - -/// Sign `doc` as the persona and send it to `vtc_did`, over TSP when the -/// membership was joined over it. -pub(crate) async fn sign_and_send( - s: &Sender, - vtc_did: &str, - doc: &mut RequestDocument, -) -> Result<(), String> { - async { - openvtc_core::capabilities::sign_document(doc, &s.signer).await?; - let tsp_mediator = - openvtc_core::community_send::tsp_mediator_for(s.over_tsp, vtc_did).await; - openvtc_core::capabilities::send_capability_document( - &openvtc_core::community_send::Delivery { - atm: &s.atm, - profile: &s.profile, - member_did: &s.persona_did, - vtc_did, - mediator_did: &s.mediator, - tsp_mediator_did: tsp_mediator.as_deref(), - }, - doc, - ) - .await - } - .await - .map(|_| ()) - .map_err(|e| e.to_string()) -} - /// One `git-ns/*` send, off the loop. pub(crate) struct ReposJob { sender: Sender, @@ -743,117 +644,104 @@ impl ReposOutcome { } } -/// The resources a send needs from the loop, borrowed. Both the action path -/// ([`ActionCtx`]) and the loop's own tick and reply arms build one. -pub(crate) struct Loop<'a> { - pub(crate) state: &'a mut State, - pub(crate) config: &'a Config, - pub(crate) tdk: &'a TDK, - pub(crate) dispatch_tx: &'a UnboundedSender, - pub(crate) in_flight: &'a mut InFlight, -} - -impl Loop<'_> { - /// Send `request` for the open view. `quiet` suppresses the busy message, - /// for sends nobody pressed a key for (polls, refresh after a change). - /// Returns whether the send was dispatched; every early return says why - /// in the view, unless `quiet`. - async fn send(&mut self, request: Request, purpose: Purpose, quiet: bool) -> bool { - let Some((vtc_did, persona, waiting)) = self - .state - .main_page - .content_panel - .repos - .view - .as_ref() - .map(|v| (v.vtc_did.clone(), v.persona, v.pending.clone())) - else { - return false; - }; - // One request awaits its answer at a time. A second would take the - // pending slot, and the first answer — a grant, say — would then match - // nothing and be dropped unannounced. Polls have their own slot. - if purpose != Purpose::LinkPoll - && let Some(waiting) = waiting - { - if !quiet && let Some(view) = view_mut(self.state) { - view.note( - Severity::Warning, - match waiting.purpose { - Purpose::Change(what) => { - format!("Still waiting for the community's answer ({what}).") - } - _ => "Still waiting for the community's answer.".into(), - }, - ); - } - return false; - } - let sender = match sender(self.config, self.tdk, persona, &vtc_did).await { - Ok(s) => s, - Err(e) => { - if let Some(view) = view_mut(self.state) { - if view.data.is_none() && purpose == Purpose::View { - view.phase = ReposPhase::Failed(e); - } else { - view.note(Severity::Error, e); +/// Send `request` for the open view. `quiet` suppresses the busy message, +/// for sends nobody pressed a key for (polls, refresh after a change). +/// Returns whether the send was dispatched; every early return says why +/// in the view, unless `quiet`. +async fn send(lp: &mut Loop<'_>, request: Request, purpose: Purpose, quiet: bool) -> bool { + let Some((vtc_did, persona, waiting)) = lp + .state + .main_page + .content_panel + .repos + .view + .as_ref() + .map(|v| (v.vtc_did.clone(), v.persona, v.pending.clone())) + else { + return false; + }; + // One request awaits its answer at a time. A second would take the + // pending slot, and the first answer — a grant, say — would then match + // nothing and be dropped unannounced. Polls have their own slot. + if purpose != Purpose::LinkPoll + && let Some(waiting) = waiting + { + if !quiet && let Some(view) = view_mut(lp.state) { + view.note( + Severity::Warning, + match waiting.purpose { + Purpose::Change(what) => { + format!("Still waiting for the community's answer ({what}).") } + _ => "Still waiting for the community's answer.".into(), + }, + ); + } + return false; + } + let sender = match sender(lp.config, lp.tdk, persona, &vtc_did).await { + Ok(s) => s, + Err(e) => { + if let Some(view) = view_mut(lp.state) { + if view.data.is_none() && purpose == Purpose::View { + view.phase = ReposPhase::Failed(e); + } else { + view.note(Severity::Error, e); } - return false; } - }; - // Built here rather than in the job so its id — the thread the answer - // carries — is known before the send is spawned, and the view can wait - // on it at once (see `arm`). - let doc = match git_ns::build(&request, &sender.persona_did, &vtc_did) { - Ok(doc) => doc, - Err(e) => { - if let Some(view) = view_mut(self.state) { - let e = match &purpose { - Purpose::Change(what) => format!("couldn't send ({what}): {e}"), - _ => format!("couldn't send: {e}"), - }; - if view.data.is_none() && purpose == Purpose::View { - view.phase = ReposPhase::Failed(e); - } else { - view.note(Severity::Error, e); - } + return false; + } + }; + // Built here rather than in the job so its id — the thread the answer + // carries — is known before the send is spawned, and the view can wait + // on it at once (see `arm`). + let doc = match git_ns::build(&request, &sender.persona_did, &vtc_did) { + Ok(doc) => doc, + Err(e) => { + if let Some(view) = view_mut(lp.state) { + let e = match &purpose { + Purpose::Change(what) => format!("couldn't send ({what}): {e}"), + _ => format!("couldn't send: {e}"), + }; + if view.data.is_none() && purpose == Purpose::View { + view.phase = ReposPhase::Failed(e); + } else { + view.note(Severity::Error, e); } - return false; - } - }; - if !self.in_flight.try_begin(DOMAIN) { - if !quiet && let Some(view) = view_mut(self.state) { - view.note(Severity::Warning, InFlight::busy_message(DOMAIN)); } return false; } - if let Some(view) = view_mut(self.state) { - arm( - view, - correlation_thread(&doc).to_string(), - purpose.clone(), - Instant::now(), - ); + }; + if !lp.in_flight.try_begin(DOMAIN) { + if !quiet && let Some(view) = view_mut(lp.state) { + view.note(Severity::Warning, InFlight::busy_message(DOMAIN)); } - let job = ReposJob { - sender, - vtc_did, - persona, - doc, - purpose, - }; - background_dispatch::spawn_dispatch(self.dispatch_tx.clone(), DOMAIN, async move { - DispatchOutcome::Repos(job.run().await) - }); - true + return false; } - - /// Read the view again. - pub(crate) async fn refresh(&mut self, quiet: bool) { - self.send(Request::View { resource: None }, Purpose::View, quiet) - .await; + if let Some(view) = view_mut(lp.state) { + arm( + view, + correlation_thread(&doc).to_string(), + purpose.clone(), + Instant::now(), + ); } + let job = ReposJob { + sender, + vtc_did, + persona, + doc, + purpose, + }; + background_dispatch::spawn_dispatch(lp.dispatch_tx.clone(), DOMAIN, async move { + DispatchOutcome::Repos(job.run().await) + }); + true +} + +/// Read the view again. +pub(crate) async fn refresh(lp: &mut Loop<'_>, quiet: bool) { + send(lp, Request::View { resource: None }, Purpose::View, quiet).await; } /// Service an action that reads the config or sends a task. @@ -875,13 +763,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: Act) { } _ => {} } - let mut lp = Loop { - state: &mut *ctx.state, - config: ctx.config, - tdk: ctx.tdk, - dispatch_tx: ctx.dispatch_tx, - in_flight: &mut *ctx.in_flight, - }; + let mut lp = Loop::from_ctx(ctx); match action { Act::Open(_) | Act::Refresh => { if let Some(view) = view_mut(lp.state) { @@ -890,7 +772,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: Act) { } view.status = None; } - lp.refresh(false).await; + refresh(&mut lp, false).await; } Act::NewSubmit => submit_new(&mut lp).await, Act::AddSubmit => submit_add(&mut lp).await, @@ -898,7 +780,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: Act) { let armed = view_mut(lp.state).and_then(|v| v.confirm.take()); if let Some(armed) = armed { let what = describe(&armed.request); - lp.send(armed.request, Purpose::Change(what), false).await; + send(&mut lp, armed.request, Purpose::Change(what), false).await; } } Act::LinkStart => start_link(&mut lp).await, @@ -1029,7 +911,7 @@ async fn submit_new(lp: &mut Loop<'_>) { return; } let what = describe(&request); - lp.send(request, Purpose::Change(what), false).await; + send(lp, request, Purpose::Change(what), false).await; } /// Add the owner picker's highlighted candidate, or its pasted DID, to the @@ -1119,7 +1001,7 @@ async fn submit_add(lp: &mut Loop<'_>) { return; } let what = describe(&request); - lp.send(request, Purpose::Change(what), false).await; + send(lp, request, Purpose::Change(what), false).await; } async fn start_link(lp: &mut Loop<'_>) { @@ -1147,15 +1029,15 @@ async fn start_link(lp: &mut Loop<'_>) { // The attempt is shown only once its request is actually on its way: a // send refused here (still waiting, busy, no signing key) says why and // leaves no "starting…" line that nothing will ever answer. - if lp - .send( - Request::Link { - forge: forge.clone(), - }, - Purpose::LinkStart, - false, - ) - .await + if send( + lp, + Request::Link { + forge: forge.clone(), + }, + Purpose::LinkStart, + false, + ) + .await && let Some(view) = view_mut(lp.state) { view.link = Some(LinkFlow::starting(forge)); @@ -1576,8 +1458,7 @@ pub(crate) async fn tick(lp: &mut Loop<'_>) { } } if let Some(link_id) = poll { - lp.send(Request::LinkStatus { link_id }, Purpose::LinkPoll, true) - .await; + send(lp, Request::LinkStatus { link_id }, Purpose::LinkPoll, true).await; } super::repos_workspace::probe_if_due(lp.state, lp.dispatch_tx, lp.in_flight); // The did-git-sign install as Help / Status and Settings show it: read off @@ -1592,6 +1473,7 @@ mod tests { use super::*; use openvtc_core::git_ns::{Refusal, view}; + use openvtc_core::pending_replies::PendingReplies; use serde_json::json; const VTC: &str = "did:webvh:QmVtcScid7:acme-vtc.example"; @@ -2478,14 +2360,18 @@ mod tests { let tdk = crate::state_handler::dispatch_util::test_tdk().await; let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); let mut in_flight = InFlight::default(); - Loop { - state: &mut state, - config: &config, - tdk: &tdk, - dispatch_tx: &tx, - in_flight: &mut in_flight, - } - .refresh(false) + refresh( + &mut Loop { + state: &mut state, + config: &config, + tdk: &tdk, + vta: None, + dispatch_tx: &tx, + in_flight: &mut in_flight, + pending_replies: &mut PendingReplies::default(), + }, + false, + ) .await; let v = view(&state); assert_eq!(v.pending.as_ref().unwrap().thid, "grant-1"); @@ -2512,8 +2398,10 @@ mod tests { state: &mut state, config: &config, tdk: &tdk, + vta: None, dispatch_tx: &tx, in_flight: &mut in_flight, + pending_replies: &mut PendingReplies::default(), }) .await; let v = view(&state); @@ -2533,12 +2421,15 @@ mod tests { let tdk = crate::state_handler::dispatch_util::test_tdk().await; let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); let mut in_flight = InFlight::default(); + let mut pending_replies = PendingReplies::default(); let mut lp = Loop { state, config: &config, tdk: &tdk, + vta: None, dispatch_tx: &tx, in_flight: &mut in_flight, + pending_replies: &mut pending_replies, }; f(&mut lp).await; } diff --git a/openvtc/src/state_handler/repos_workspace.rs b/openvtc/src/state_handler/repos_workspace.rs index 68b8861c..962f094f 100644 --- a/openvtc/src/state_handler/repos_workspace.rs +++ b/openvtc/src/state_handler/repos_workspace.rs @@ -52,6 +52,7 @@ use vta_sdk::client::VtaClient; use crate::state_handler::actions::WorkspaceAction as W; use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; +use crate::state_handler::community_request::capitalise; use crate::state_handler::main_page::repos::{ AccountForm, CheckoutView, ReposScreen, ReposView, Severity, SignerHealth, WorkspaceChange, WorkspaceForm, @@ -1420,13 +1421,6 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: W) { }); } -fn capitalise(s: &str) -> String { - let mut c = s.chars(); - c.next() - .map(|f| f.to_uppercase().chain(c).collect()) - .unwrap_or_default() -} - /// How a checkout reads, in a few words, for the list. #[must_use] pub(crate) fn summary(view: &ReposView, resource: &str) -> (String, Severity) { diff --git a/openvtc/src/state_handler/rooms_actions.rs b/openvtc/src/state_handler/rooms_actions.rs index a0af102c..f2c13b8f 100644 --- a/openvtc/src/state_handler/rooms_actions.rs +++ b/openvtc/src/state_handler/rooms_actions.rs @@ -26,41 +26,30 @@ //! replies and outcomes, takes it. After an acceptance ends, either way, the view is read //! again: the community and the VTA are the record. -use std::time::{Duration, Instant}; - -use affinidi_tdk::TDK; -use openvtc_core::capabilities::{RequestDocument, correlation_thread}; use openvtc_core::community_rooms::{ self, Joined, Offer, Prepared, Renewal, Reply, Request, RoomCondition, Standing, }; -use openvtc_core::config::Config; use openvtc_core::config::account::PersonaId; -use tokio::sync::mpsc::UnboundedSender; use vta_sdk::client::VtaClient; use crate::state_handler::actions::RoomsAction as Act; use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; +pub(crate) use crate::state_handler::community_request::CommunityLoop as Loop; +use crate::state_handler::community_request::{self, CommunityFeature, Doc, SendOutcome, sender}; use crate::state_handler::main_page::rooms::{ - AcceptFlow, AcceptStep, Next, Pending, Purpose, RoomsPhase, RoomsView, Severity, + AcceptFlow, AcceptStep, Next, Purpose, RoomsPhase, RoomsView, Severity, }; -use crate::state_handler::repos_actions::{Sender, sender}; use crate::state_handler::runtime_actions::ActionCtx; use crate::state_handler::state::State; +use openvtc_core::pending_replies::PendingReplies; const SEND: DispatchDomain = DispatchDomain::Rooms; const KEYS: DispatchDomain = DispatchDomain::RoomsVta; -/// How long a request waits for the community's answer (R1.2). -pub(crate) const REPLY_WINDOW: Duration = Duration::from_secs(30); - /// A `vtc/rooms/*` answer as it arrived, threaded on the request it answers /// ([`openvtc_core::inbound::ThreadedReply`]). pub(crate) type InboundReply = openvtc_core::inbound::ThreadedReply; -fn did_part(did: &str) -> &str { - did.split('#').next().unwrap_or(did) -} - fn view_mut(state: &mut State) -> Option<&mut RoomsView> { state.main_page.content_panel.rooms.view.as_mut() } @@ -90,139 +79,165 @@ pub(crate) fn reduce(state: &mut State, action: &Act) -> bool { // The loop's side // **************************************************************************** -/// What a send or a VTA call needs from the loop. -pub(crate) struct Loop<'a> { - pub(crate) state: &'a mut State, - pub(crate) config: &'a Config, - pub(crate) tdk: &'a TDK, - pub(crate) vta: Option<&'a VtaClient>, - pub(crate) dispatch_tx: &'a UnboundedSender, - pub(crate) in_flight: &'a mut InFlight, +/// The Rooms panel, on the shared request/reply plumbing +/// ([`community_request`]). +pub(crate) struct Rooms; + +impl CommunityFeature for Rooms { + type View = RoomsView; + type Purpose = Purpose; + type Reply = Reply; + type Next = Next; + const NAME: &'static str = "rooms"; + const SEND: DispatchDomain = SEND; + /// Only a community the persona belongs to answers the Rooms panel. + const RECORDS_THREAD: bool = false; + + fn view_mut(state: &mut State) -> Option<&mut RoomsView> { + view_mut(state) + } + fn pending_mut(view: &mut RoomsView) -> &mut Option> { + &mut view.pending + } + fn next_mut(view: &mut RoomsView) -> &mut Option { + &mut view.next + } + fn community(view: &RoomsView) -> (&str, PersonaId) { + (&view.vtc_did, view.persona) + } + fn note(view: &mut RoomsView, severity: Severity, text: String) { + view.note(severity, text); + } + fn outcome(outcome: SendOutcome) -> DispatchOutcome { + DispatchOutcome::Rooms(outcome) + } + fn is_refusal(reply: &Reply) -> bool { + matches!(reply, Reply::Refused(_)) + } + fn take_reply(view: &mut RoomsView, reply: Reply, purpose: Purpose) { + take_reply(view, reply, purpose); + } + fn timed_out(view: &mut RoomsView, purpose: Purpose, secs: u64) { + timed_out(view, purpose, secs); + } + fn failed(view: &mut RoomsView, purpose: &Purpose, why: String) { + failed(view, purpose, why); + } + fn end_flow(view: &mut RoomsView) { + view.accept = None; + } + async fn step(lp: &mut Loop<'_>, next: Next) -> bool { + step(lp, next).await + } } -impl Loop<'_> { - /// Send `request` to the open view's community, armed on `purpose`. Returns whether - /// it was dispatched; every early return says why in the view. - async fn send(&mut self, request: Request, purpose: Purpose) -> bool { - let Some((vtc_did, persona, waiting)) = self - .state - .main_page - .content_panel - .rooms - .view - .as_ref() - .map(|v| (v.vtc_did.clone(), v.persona, v.pending.is_some())) - else { - return false; - }; - if waiting { - if let Some(view) = view_mut(self.state) { - view.note( - Severity::Warning, - "Still waiting for the community's answer.", - ); - } - return false; +/// Send `request` to the open view's community, armed on `purpose`. Returns whether +/// it was dispatched; every early return says why in the view. +async fn send(lp: &mut Loop<'_>, request: Request, purpose: Purpose) -> bool { + let Some((vtc_did, persona, waiting)) = lp + .state + .main_page + .content_panel + .rooms + .view + .as_ref() + .map(|v| (v.vtc_did.clone(), v.persona, v.pending.is_some())) + else { + return false; + }; + if waiting { + if let Some(view) = view_mut(lp.state) { + view.note( + Severity::Warning, + "Still waiting for the community's answer.", + ); } - let sender = match sender(self.config, self.tdk, persona, &vtc_did).await { - Ok(s) => s, - Err(e) => { - fail(self.state, &purpose, e); - return false; - } - }; - let doc = match community_rooms::build(&request, &sender.persona_did, &vtc_did) { - Ok(doc) => doc, - Err(e) => { - fail(self.state, &purpose, format!("couldn't send: {e}")); - return false; - } - }; - if !self.in_flight.try_begin(SEND) { + return false; + } + let sender = match sender(lp.config, lp.tdk, persona, &vtc_did).await { + Ok(s) => s, + Err(e) => { + community_request::fail::(lp.state, &purpose, e); return false; } - let thid = correlation_thread(&doc).to_string(); - if let Some(view) = view_mut(self.state) { - view.pending = Some(Pending { - thid, - sent_at: Instant::now(), - purpose, - }); + }; + let doc = match community_rooms::build(&request, &sender.persona_did, &vtc_did) { + Ok(doc) => doc, + Err(e) => { + community_request::fail::(lp.state, &purpose, format!("couldn't send: {e}")); + return false; } - let job = SendJob { - sender, + }; + lp.spawn_send::( + sender, + vtc_did, + persona, + Doc::Unsigned(Box::new(doc)), + purpose, + ) +} + +/// Ask the VTA. Returns whether it was dispatched. +fn ask_vta(lp: &mut Loop<'_>, ask: VtaAsk) -> bool { + let Some(vta) = lp.vta.cloned() else { + return false; + }; + let Some((vtc_did, persona)) = lp + .state + .main_page + .content_panel + .rooms + .view + .as_ref() + .map(|v| (v.vtc_did.clone(), v.persona)) + else { + return false; + }; + if !lp.in_flight.try_begin(KEYS) { + return false; + } + background_dispatch::spawn_dispatch(lp.dispatch_tx.clone(), KEYS, async move { + DispatchOutcome::RoomsVta(RoomsVtaOutcome { vtc_did, persona, - doc, - }; - background_dispatch::spawn_dispatch(self.dispatch_tx.clone(), SEND, async move { - DispatchOutcome::Rooms(job.run().await) - }); - true - } + answer: ask.run(&vta).await, + }) + }); + true +} - /// Ask the VTA. Returns whether it was dispatched. - fn ask_vta(&mut self, ask: VtaAsk) -> bool { - let Some(vta) = self.vta.cloned() else { - return false; - }; - let Some((vtc_did, persona)) = self - .state - .main_page - .content_panel - .rooms - .view - .as_ref() - .map(|v| (v.vtc_did.clone(), v.persona)) - else { - return false; - }; - if !self.in_flight.try_begin(KEYS) { - return false; +/// Read the community's record and the VTA's custody again. +pub(crate) async fn refresh(lp: &mut Loop<'_>) { + let me = match view_mut(lp.state) { + Some(view) => { + view.restart_pages(); + view.me.clone() } - background_dispatch::spawn_dispatch(self.dispatch_tx.clone(), KEYS, async move { - DispatchOutcome::RoomsVta(RoomsVtaOutcome { - vtc_did, - persona, - answer: ask.run(&vta).await, - }) - }); - true - } - - /// Read the community's record and the VTA's custody again. - pub(crate) async fn refresh(&mut self) { - let me = match view_mut(self.state) { - Some(view) => { - view.restart_pages(); - view.me.clone() - } - None => return, - }; - if self.vta.is_none() { - if let Some(view) = view_mut(self.state) { - view.held = None; - view.held_error = Some( - "no VTA is connected, so which rooms it holds keys for can't be shown".into(), - ); - } - } else if !self.ask_vta(VtaAsk::Held { member: me }) - && let Some(view) = view_mut(self.state) - { - // Busy with an acceptance step; the record still refreshes. - view.held_error - .get_or_insert_with(|| "your VTA is busy".into()); + None => return, + }; + if lp.vta.is_none() { + if let Some(view) = view_mut(lp.state) { + view.held = None; + view.held_error = + Some("no VTA is connected, so which rooms it holds keys for can't be shown".into()); } - self.send(Request::Memberships { cursor: None }, Purpose::Memberships) - .await; + } else if !ask_vta(lp, VtaAsk::Held { member: me }) + && let Some(view) = view_mut(lp.state) + { + // Busy with an acceptance step; the record still refreshes. + view.held_error + .get_or_insert_with(|| "your VTA is busy".into()); } + send( + lp, + Request::Memberships { cursor: None }, + Purpose::Memberships, + ) + .await; } /// Say a request never left, where the view shows it. -fn fail(state: &mut State, purpose: &Purpose, why: String) { - let Some(view) = view_mut(state) else { - return; - }; +fn failed(view: &mut RoomsView, purpose: &Purpose, why: String) { match purpose { Purpose::Memberships if view.rooms.is_empty() => view.phase = RoomsPhase::Failed(why), Purpose::Memberships => view.note(Severity::Error, why), @@ -240,14 +255,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: Act) { if let Act::Open(index) = action { open(ctx, index); } - let mut lp = Loop { - state: &mut *ctx.state, - config: ctx.config, - tdk: ctx.tdk, - vta: ctx.admin_vta, - dispatch_tx: ctx.dispatch_tx, - in_flight: &mut *ctx.in_flight, - }; + let mut lp = Loop::from_ctx(ctx); match action { Act::Open(_) | Act::Refresh => { if let Some(view) = view_mut(lp.state) { @@ -256,7 +264,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: Act) { } view.status = None; } - lp.refresh().await; + refresh(&mut lp).await; } Act::Accept => start_accept(&mut lp).await, Act::Back | Act::Select(_) => {} @@ -373,9 +381,12 @@ async fn start_accept(lp: &mut Loop<'_>) { community_rooms::display_label(&row.room) ), ); - if !lp - .send(Request::Offers { cursor: None }, Purpose::Offers { room }) - .await + if !send( + lp, + Request::Offers { cursor: None }, + Purpose::Offers { room }, + ) + .await && let Some(view) = view_mut(lp.state) && view.pending.is_none() { @@ -386,12 +397,14 @@ async fn start_accept(lp: &mut Loop<'_>) { } /// Take the step the view's last answer called for, if its lane is free. The loop calls -/// this after applying replies and dispatch outcomes, and on its tick. +/// this after applying replies and outcomes, and on its tick. pub(crate) async fn advance(lp: &mut Loop<'_>) { - let Some(next) = view_mut(lp.state).and_then(|v| v.next.take()) else { - return; - }; - let taken = match next.clone() { + community_request::advance::(lp).await; +} + +/// Take one step. Returns whether it was taken (a busy lane keeps it). +async fn step(lp: &mut Loop<'_>, next: Next) -> bool { + match next { Next::Refresh => { if lp.in_flight.is_busy(SEND) || lp @@ -405,12 +418,13 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { { false } else { - lp.refresh().await; + refresh(lp).await; true } } Next::MembershipsPage { cursor } => { - lp.send( + send( + lp, Request::Memberships { cursor: Some(cursor), }, @@ -419,7 +433,8 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { .await } Next::OffersPage { room, cursor } => { - lp.send( + send( + lp, Request::Offers { cursor: Some(cursor), }, @@ -445,12 +460,13 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { }) }); match ask { - Some(ask) => lp.ask_vta(ask), + Some(ask) => ask_vta(lp, ask), None => true, } } Next::SendAccept { room, key_package } => { - lp.send( + send( + lp, Request::Accept { room_id: room.clone(), key_package, @@ -469,21 +485,21 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { .and_then(|a| a.offer.as_ref()) .map(|o| o.credentials.invitation.to_string()); match invitation { - Some(invitation) => lp.ask_vta(VtaAsk::Join { - room, - invitation, - welcome, - ratchet_tree, - }), + Some(invitation) => ask_vta( + lp, + VtaAsk::Join { + room, + invitation, + welcome, + ratchet_tree, + }, + ), None => true, } } // Without a VTA there is nowhere to store a renewal, so nothing to read. Next::CheckRenewals if lp.vta.is_none() => true, - Next::CheckRenewals => { - lp.send(Request::Offers { cursor: None }, Purpose::Renewals) - .await - } + Next::CheckRenewals => send(lp, Request::Offers { cursor: None }, Purpose::Renewals).await, Next::Renew => { let ask = view_mut(lp.state).map(|v| VtaAsk::Renew { renewals: std::mem::take(&mut v.renewals), @@ -491,10 +507,13 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { }); match ask { Some(VtaAsk::Renew { renewals, member }) if !renewals.is_empty() => { - let taken = lp.ask_vta(VtaAsk::Renew { - renewals: renewals.clone(), - member, - }); + let taken = ask_vta( + lp, + VtaAsk::Renew { + renewals: renewals.clone(), + member, + }, + ); if !taken && let Some(view) = view_mut(lp.state) { view.renewals = renewals; } @@ -503,196 +522,74 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { _ => true, } } - }; - // A lane that was busy: keep the step for the next tick. - if !taken && let Some(view) = view_mut(lp.state) { - view.next.get_or_insert(next); } } /// The loop's periodic pass: reply windows, and any step left waiting on a busy lane. pub(crate) async fn tick(lp: &mut Loop<'_>) { - let now = Instant::now(); - if let Some(view) = view_mut(lp.state) - && view - .pending - .as_ref() - .is_some_and(|p| now.duration_since(p.sent_at) > REPLY_WINDOW) - && let Some(p) = view.pending.take() - { - let secs = REPLY_WINDOW.as_secs(); - match p.purpose { - Purpose::Memberships if view.rooms.is_empty() => { - // A community that does not serve the record still answers, with a - // refusal shown in words, so silence means the request or its answer was - // lost (R6.4). - view.phase = RoomsPhase::Failed(format!( - "no reply within {secs}s. A community that does not serve data rooms says \ + community_request::tick::(lp).await; +} + +/// The request made for `purpose` went unanswered for `secs` seconds. +fn timed_out(view: &mut RoomsView, purpose: Purpose, secs: u64) { + match purpose { + Purpose::Memberships if view.rooms.is_empty() => { + // A community that does not serve the record still answers, with a + // refusal shown in words, so silence means the request or its answer was + // lost (R6.4). + view.phase = RoomsPhase::Failed(format!( + "no reply within {secs}s. A community that does not serve data rooms says \ so, so this is not that: the request or its answer was lost — the \ community's VTC is offline or unreachable, or it answered on a route this \ persona does not collect. Press r to try again." - )); - } - Purpose::Memberships => { - view.note( - Severity::Error, - format!("no reply to the refresh within {secs}s"), - ); - } - Purpose::Offers { .. } | Purpose::Accept { .. } => { - view.accept = None; - view.note( - Severity::Error, - format!( - "No reply within {secs}s. Accepting again is safe: it finishes whatever \ + )); + } + Purpose::Memberships => { + view.note( + Severity::Error, + format!("no reply to the refresh within {secs}s"), + ); + } + Purpose::Offers { .. } | Purpose::Accept { .. } => { + view.accept = None; + view.note( + Severity::Error, + format!( + "No reply within {secs}s. Accepting again is safe: it finishes whatever \ was done, here or elsewhere. Refresh (r) first to see where it stands." - ), - ); - } - Purpose::Renewals => { - view.note( - Severity::Warning, - format!( - "no reply within {secs}s to the check for renewed access; refresh (r) to \ + ), + ); + } + Purpose::Renewals => { + view.note( + Severity::Warning, + format!( + "no reply within {secs}s to the check for renewed access; refresh (r) to \ check again" - ), - ); - } + ), + ); } } - advance(lp).await; } /// A job ended without an outcome (it panicked): end what it was part of. pub(crate) fn job_lost(state: &mut State, msg: &str) { - if let Some(view) = view_mut(state) { - view.pending = None; - view.accept = None; - view.next = None; - view.note(Severity::Error, msg.to_string()); - } -} - -// **************************************************************************** -// Sending to the community -// **************************************************************************** - -/// One `vtc/rooms/*` send, off the loop. -pub(crate) struct SendJob { - sender: Sender, - vtc_did: String, - persona: PersonaId, - doc: RequestDocument, -} - -impl SendJob { - async fn run(mut self) -> RoomsOutcome { - let thid = correlation_thread(&self.doc).to_string(); - let s = &self.sender; - let result = async { - openvtc_core::capabilities::sign_document(&mut self.doc, &s.signer).await?; - let tsp_mediator = - openvtc_core::community_send::tsp_mediator_for(s.over_tsp, &self.vtc_did).await; - openvtc_core::capabilities::send_capability_document( - &openvtc_core::community_send::Delivery { - atm: &s.atm, - profile: &s.profile, - member_did: &s.persona_did, - vtc_did: &self.vtc_did, - mediator_did: &s.mediator, - tsp_mediator_did: tsp_mediator.as_deref(), - }, - &self.doc, - ) - .await - } - .await - .map(|_| ()) - .map_err(|e| e.to_string()); - RoomsOutcome { - vtc_did: self.vtc_did, - persona: self.persona, - thid, - result, - } - } -} - -/// What a send did. Applied on the loop. -pub(crate) struct RoomsOutcome { - vtc_did: String, - persona: PersonaId, - thid: String, - result: Result<(), String>, -} - -impl RoomsOutcome { - /// Say the send never left — while the view still waits on this request. - pub(crate) fn apply(self, state: &mut State) { - let Err(e) = self.result else { - return; - }; - let Some(view) = view_mut(state) else { - return; - }; - if view.vtc_did != self.vtc_did - || view.persona != self.persona - || view.pending.as_ref().is_none_or(|p| p.thid != self.thid) - { - tracing::debug!(thid = %self.thid, "rooms send failed for a request no longer awaited: {e}"); - return; - } - if let Some(p) = view.pending.take() { - fail( - state, - &p.purpose, - format!("couldn't reach the community: {e}"), - ); - } - } + community_request::job_lost::(state, msg); } /// Apply the community's answers. Returns whether any was taken, so the loop /// [`advance`]s. -pub(crate) fn apply_replies(state: &mut State, replies: Vec) -> bool { - let mut taken = false; - for inbound in replies { - let Some(vtc) = view_mut(state).map(|v| v.vtc_did.clone()) else { - continue; - }; - // A thread id is a correlation key, not a credential: an answer is taken only - // from the community the view is asking — sent by it and issued by it. - // By DID: a did:scid community may name itself with the `?src=` it is - // resolved through (VTI-KEY-163). - let from_vtc = vta_sdk::did_scid::same_reference(did_part(&inbound.from), &vtc); - let issued_by_vtc = inbound - .issuer - .as_deref() - .map(did_part) - .is_some_and(|issuer| vta_sdk::did_scid::same_reference(issuer, &vtc)); - if !(from_vtc && issued_by_vtc) { - tracing::warn!(from = %inbound.from, thid = %inbound.thid, "rooms reply not from the community the view is asking — dropped"); - continue; - } - taken |= apply_reply(state, &inbound.thid, inbound.reply); - } - taken +pub(crate) fn apply_replies( + state: &mut State, + pending_replies: &mut PendingReplies, + replies: Vec, +) -> bool { + community_request::apply_replies::(state, pending_replies, replies) } -fn apply_reply(state: &mut State, thid: &str, reply: Reply) -> bool { - let Some(view) = view_mut(state) else { - return false; - }; - if view.pending.as_ref().is_none_or(|p| p.thid != thid) { - if !matches!(reply, Reply::Refused(_)) { - tracing::warn!(%thid, "rooms reply matches no pending request — dropped"); - } - return false; - } - let Some(pending) = view.pending.take() else { - return false; - }; - match (reply, pending.purpose) { +/// Take the answer to the request made for `purpose`. +fn take_reply(view: &mut RoomsView, reply: Reply, purpose: Purpose) { + match (reply, purpose) { (Reply::Memberships(page), Purpose::Memberships) => { let next = page.next_cursor.as_ref().map(|c| c.as_str().to_string()); view.take_page(page.items, next.is_none()); @@ -813,7 +710,6 @@ fn apply_reply(state: &mut State, thid: &str, reply: Reply) -> bool { ); } } - true } // **************************************************************************** @@ -987,8 +883,10 @@ impl RoomsVtaOutcome { #[cfg(test)] mod tests { use super::*; + use crate::state_handler::main_page::rooms::Pending; use openvtc_core::community_rooms::Reply; use serde_json::json; + use std::time::Instant; fn state_with_view() -> State { let mut state = State::default(); @@ -1006,6 +904,7 @@ mod tests { thid: "t1".into(), sent_at: Instant::now(), purpose, + to: "did:key:zVtc".into(), }); } @@ -1047,6 +946,7 @@ mod tests { arm(&mut state, Purpose::Memberships); assert!(apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(page( json!([room("did:key:zR1", "admitted", true)]), Some("c1") @@ -1064,6 +964,7 @@ mod tests { arm(&mut state, Purpose::Memberships); apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(page( json!([room("did:key:zR2", "offered", false)]), None, @@ -1086,7 +987,11 @@ mod tests { arm(&mut state, Purpose::Memberships); let mut forged = inbound(page(json!([]), None)); forged.from = "did:key:zMallory".into(); - assert!(!apply_replies(&mut state, vec![forged])); + assert!(!apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![forged] + )); assert!(view_mut(&mut state).unwrap().pending.is_some()); } @@ -1133,7 +1038,11 @@ mod tests { message: None, }; assert!(refusal.is_already_accepted()); - apply_replies(&mut state, vec![inbound(Reply::Refused(refusal))]); + apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![inbound(Reply::Refused(refusal))], + ); let view = view_mut(&mut state).unwrap(); assert!(view.accept.is_none()); assert!(view.status.as_ref().unwrap().text.contains("re-admit")); @@ -1148,6 +1057,7 @@ mod tests { arm(&mut state, Purpose::Memberships); apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(page( json!([room("did:key:zR1", "admitted", false)]), None, @@ -1175,7 +1085,11 @@ mod tests { })) .unwrap(), )); - apply_replies(&mut state, vec![inbound(offers)]); + apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![inbound(offers)], + ); let view = view_mut(&mut state).unwrap(); assert_eq!(view.next, Some(Next::Renew)); assert_eq!(view.renewals.len(), 1); @@ -1187,7 +1101,11 @@ mod tests { let none: Reply = Reply::Offers(Box::new( serde_json::from_value(json!({ "items": [], "renewals": [] })).unwrap(), )); - apply_replies(&mut state, vec![inbound(none)]); + apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![inbound(none)], + ); let view = view_mut(&mut state).unwrap(); assert_eq!(view.next, None); assert!(view.status.is_none()); @@ -1203,7 +1121,11 @@ mod tests { r["suspendedAt"] = json!("2026-11-02T10:00:00Z"); r["roomState"] = json!("archived"); r["roomAccess"] = json!("noAccess"); - apply_replies(&mut state, vec![inbound(page(json!([r]), None))]); + apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![inbound(page(json!([r]), None))], + ); let view = view_mut(&mut state).unwrap(); let row = &view.rows()[0]; assert_eq!(row.standing, Standing::Suspended); diff --git a/openvtc/src/state_handler/runtime/arms.rs b/openvtc/src/state_handler/runtime/arms.rs index 7c54de8b..2e56e5e2 100644 --- a/openvtc/src/state_handler/runtime/arms.rs +++ b/openvtc/src/state_handler/runtime/arms.rs @@ -197,6 +197,7 @@ impl Runtime { vta: admin_vta.as_ref(), dispatch_tx, in_flight, + pending_replies, }) .await; // The same for the Elections panel. diff --git a/openvtc/src/state_handler/runtime/inbound_arm.rs b/openvtc/src/state_handler/runtime/inbound_arm.rs index a9d02af2..43c22cd9 100644 --- a/openvtc/src/state_handler/runtime/inbound_arm.rs +++ b/openvtc/src/state_handler/runtime/inbound_arm.rs @@ -291,14 +291,18 @@ impl Runtime { if !git_ns_replies.is_empty() && repos_actions::apply_replies(state, config, git_ns_replies) { - repos_actions::Loop { - state, - config, - tdk, - dispatch_tx, - in_flight, - } - .refresh(true) + repos_actions::refresh( + &mut repos_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + pending_replies, + }, + true, + ) .await; } // The answer names the repositories to look for @@ -306,7 +310,9 @@ impl Runtime { repos_workspace::probe_if_due(state, dispatch_tx, in_flight); // Data-room answers for the Rooms panel; each may call for the // next step of a refresh or an acceptance. - if !rooms_replies.is_empty() && rooms_actions::apply_replies(state, rooms_replies) { + if !rooms_replies.is_empty() + && rooms_actions::apply_replies(state, pending_replies, rooms_replies) + { rooms_actions::advance(&mut rooms_actions::Loop { state, config, @@ -314,6 +320,7 @@ impl Runtime { vta: admin_vta.as_ref(), dispatch_tx, in_flight, + pending_replies, }) .await; } diff --git a/openvtc/src/state_handler/runtime/timers.rs b/openvtc/src/state_handler/runtime/timers.rs index d927ba2f..49da9075 100644 --- a/openvtc/src/state_handler/runtime/timers.rs +++ b/openvtc/src/state_handler/runtime/timers.rs @@ -40,8 +40,10 @@ impl Runtime { state, config, tdk, + vta: admin_vta.as_ref(), dispatch_tx, in_flight, + pending_replies, }) .await; // The Rooms panel: reply windows, and a step left on a busy lane. @@ -52,6 +54,7 @@ impl Runtime { vta: admin_vta.as_ref(), dispatch_tx, in_flight, + pending_replies, }) .await; // The Elections panel: the same. From c59e67aec11555079573d7028791fcd05bce540d Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 20:12:05 +1000 Subject: [PATCH 22/27] =?UTF-8?q?refactor(state=5Fhandler):=20one=20persis?= =?UTF-8?q?tence=20path=20=E2=80=94=20every=20TUI=20save=20goes=20through?= =?UTF-8?q?=20SaveScheduler=20(#535)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete settings_actions::save_config. The passphrase set/remove and the export/import log entries force-flush with SaveScheduler::flush (awaited, so still durable before success is reported), which also clears the pending dirty mark; clear_after_external_save has no callers left and goes. A standalone persona mint is written in memory (MintedPersona::write_into over the new ConfigExtension::mint_persona_in_memory) and settle_after_apply force-flushes it through the scheduler, off the runtime. The headless join keeps its own save. mint_persona_into (join driver, setup) still saves itself. Signed-off-by: Glenn Gore --- openvtc/src/state_handler/create_persona.rs | 13 ++-- openvtc/src/state_handler/dispatch_util.rs | 2 +- openvtc/src/state_handler/join_cmd.rs | 5 +- openvtc/src/state_handler/mod.rs | 16 ++++- openvtc/src/state_handler/runtime/arms.rs | 2 +- openvtc/src/state_handler/save_coalesce.rs | 14 ---- openvtc/src/state_handler/settings_actions.rs | 70 +++++++------------ .../state_handler/setup_sequence/config.rs | 34 ++++++--- 8 files changed, 77 insertions(+), 79 deletions(-) diff --git a/openvtc/src/state_handler/create_persona.rs b/openvtc/src/state_handler/create_persona.rs index 91ec51c3..a945b95f 100644 --- a/openvtc/src/state_handler/create_persona.rs +++ b/openvtc/src/state_handler/create_persona.rs @@ -261,13 +261,12 @@ impl MintedPersona { /// Write the persona into the config, recording the context it was minted /// in as its origin. Runs on the loop thread, from whichever loop is live — /// a first persona is minted in State A. - pub(crate) async fn persist( - &self, - config: &mut Config, - tdk: &TDK, - profile: &str, - ) -> Result { - let persona_id = Config::mint_persona_into(config, &self.setup, tdk, profile).await?; + /// + /// In memory only: the caller persists it, the TUI loops through + /// [`SaveScheduler::flush`](crate::state_handler::save_coalesce::SaveScheduler::flush) + /// (the one persistence path), the headless join with its own save. + pub(crate) async fn write_into(&self, config: &mut Config, tdk: &TDK) -> Result { + let persona_id = Config::mint_persona_in_memory(config, &self.setup, tdk).await?; if let Some(record) = config.account.personas.get_mut(&persona_id) { record.origin_context_id = self.context_id.clone(); } diff --git a/openvtc/src/state_handler/dispatch_util.rs b/openvtc/src/state_handler/dispatch_util.rs index 774d058b..832ed1f9 100644 --- a/openvtc/src/state_handler/dispatch_util.rs +++ b/openvtc/src/state_handler/dispatch_util.rs @@ -5,7 +5,7 @@ //! inline across the inbox, relationship, credential, and settings handlers: //! //! 1. set the panel's `status_message`, -//! 2. persist the config (`save_config`, logging on failure), +//! 2. persist the config (now `SaveScheduler::mark_dirty`), //! 3. rebuild UI state from config (`sync_from_config`), //! 4. push an activity-log entry (`log` or `log_detailed`). //! diff --git a/openvtc/src/state_handler/join_cmd.rs b/openvtc/src/state_handler/join_cmd.rs index d1e1c819..cd705221 100644 --- a/openvtc/src/state_handler/join_cmd.rs +++ b/openvtc/src/state_handler/join_cmd.rs @@ -384,10 +384,11 @@ impl Job<'_> { ) .await?; let persona_id = minted - .persist(config, self.tdk, self.profile) + .write_into(config, self.tdk) .await .with_context(|| format!("persona {} minted but not saved", minted.did))?; - save(config, self.profile)?; + save(config, self.profile) + .with_context(|| format!("persona {} minted but not saved", minted.did))?; progress(format!("persona created: {}", minted.did)); Ok((persona_id, minted.setup.did_keys.take())) } diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index 3353e2a2..ee735db6 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -1720,8 +1720,8 @@ impl StateHandler { after, state, &mut ctx.config, + &mut save, &ctx.tdk, - self.profile.as_str(), ) .await; // The first persona was just minted. This loop has no @@ -2101,8 +2101,8 @@ async fn settle_after_apply( after: background_dispatch::AfterApply, state: &mut State, config: &mut Config, + save: &mut save_coalesce::SaveScheduler, tdk: &TDK, - profile: &str, ) -> AfterSettle { use main_page::content::CreatePersonaPhase; @@ -2116,7 +2116,17 @@ async fn settle_after_apply( AfterSettle::HiddenVetting(outcome) } background_dispatch::AfterApply::PersistPersona(minted) => { - match minted.persist(config, tdk, profile).await { + // Durability-critical: the DID already exists at the VTA, so the + // persona is force-flushed, not left to the debounce. + let persisted = match minted.write_into(config, tdk).await { + Ok(persona_id) => save + .flush(config) + .await + .map(|()| persona_id) + .map_err(anyhow::Error::from), + Err(e) => Err(e), + }; + match persisted { Ok(persona_id) => { let did = minted.shareable_did(); let copied = crate::clipboard::copy_to_clipboard(&did).is_ok(); diff --git a/openvtc/src/state_handler/runtime/arms.rs b/openvtc/src/state_handler/runtime/arms.rs index 2e56e5e2..af4d748f 100644 --- a/openvtc/src/state_handler/runtime/arms.rs +++ b/openvtc/src/state_handler/runtime/arms.rs @@ -211,7 +211,7 @@ impl Runtime { pending_replies, }) .await; - let settled = settle_after_apply(after, state, config, tdk, handler.profile.as_str()).await; + let settled = settle_after_apply(after, state, config, save, tdk).await; match settled { // A community we have just left still holds a messaging // session. The session manager lives here, not in the diff --git a/openvtc/src/state_handler/save_coalesce.rs b/openvtc/src/state_handler/save_coalesce.rs index 6fe14615..53bf2b29 100644 --- a/openvtc/src/state_handler/save_coalesce.rs +++ b/openvtc/src/state_handler/save_coalesce.rs @@ -274,20 +274,6 @@ impl SaveScheduler { pub(crate) fn needs_flush(&self) -> bool { self.dirty || self.in_flight } - - /// Drop any pending coalesced dirty state because the caller just persisted - /// the *current* config synchronously by another path (a force-flush save, - /// e.g. a passphrase/protection change or export). This avoids a redundant - /// debounced re-save of state that is already on disk. - /// - /// It does **not** touch the `in_flight` flag: a concurrently-running - /// background save must still call [`finish`](Self::finish). If that - /// background save raced ahead of this external save its data is a subset - /// (older) of what we just wrote, so clearing `dirty` is safe. - pub(crate) fn clear_after_external_save(&mut self) { - self.dirty = false; - self.deadline = None; - } } #[cfg(test)] diff --git a/openvtc/src/state_handler/settings_actions.rs b/openvtc/src/state_handler/settings_actions.rs index b3403215..550404b4 100644 --- a/openvtc/src/state_handler/settings_actions.rs +++ b/openvtc/src/state_handler/settings_actions.rs @@ -11,16 +11,6 @@ use openvtc_core::{config::Config, logs::LogFamily}; use secrecy::{ExposeSecret, SecretString}; use tracing::info; -/// Save the config to disk using the profile name. -pub fn save_config(config: &Config, profile: &str) -> Result<()> { - config.save( - profile, - #[cfg(feature = "openpgp-card")] - &|| {}, - )?; - Ok(()) -} - /// Update the friendly name. /// /// R11: mutates + logs only; persistence is the caller's responsibility (the @@ -56,11 +46,11 @@ pub fn update_friendly_name(config: &mut Config, name: &str) { /// `spawn_blocking` thread via `UnlockKey::derive_blocking` so it does not peg /// the async event-loop / render task. The key is derived under a fresh random /// salt (#566), which the save writes beside the ciphertext. The subsequent -/// `save_config` does not run Argon2 (it AES-encrypts with the already-derived +/// force-flush ([`SaveScheduler::flush`]) does not run Argon2 (it AES-encrypts with the already-derived /// key), so this is the only KDF on this path. pub async fn set_passphrase( config: &mut Config, - profile: &str, + save: &mut SaveScheduler, passphrase: &SecretString, ) -> Result<()> { use openvtc_core::config::{ConfigProtectionType, UnlockKey, validate_passphrase}; @@ -73,13 +63,16 @@ pub async fn set_passphrase( LogFamily::Config, "Profile protection changed to an unlock code".to_string(), ); - save_config(config, profile)?; + save.flush(config).await?; info!("config protection set to passphrase encrypted"); Ok(()) } /// Remove passphrase protection, reverting to keyring-only. -pub fn remove_passphrase(config: &mut Config, profile: &str) -> Result<()> { +/// +/// Force-flushes through [`SaveScheduler::flush`]: the protection change must +/// be on disk before success is reported. +pub async fn remove_passphrase(config: &mut Config, save: &mut SaveScheduler) -> Result<()> { use openvtc_core::config::ConfigProtectionType; config.unlock_code = None; @@ -88,7 +81,7 @@ pub fn remove_passphrase(config: &mut Config, profile: &str) -> Result<()> { LogFamily::Config, "Config protection changed to keyring only (no additional encryption)".to_string(), ); - save_config(config, profile)?; + save.flush(config).await?; info!("config protection reverted to keyring only"); Ok(()) } @@ -330,7 +323,6 @@ async fn handle_export_config_action( state: &mut State, state_tx: &watch::Sender>, save: &mut SaveScheduler, - profile: &str, path: &str, passphrase: SecretString, ) { @@ -351,16 +343,13 @@ async fn handle_export_config_action( .insert(LogFamily::Config, format!("Config exported to {}", path)); // R11 force-flush: export is durability-critical (the exported file // must reflect persisted state and the export-log entry must hit - // disk now). Keep this save synchronous on the loop thread — it is a - // deliberate force-flush point, not the coalesced hot path. It also - // clears any pending coalesced dirty state, so we drop the scheduler's - // deadline below. - if let Err(e) = save_config(config, profile) { + // disk now). The flush is awaited before the handler returns — a + // deliberate force-flush point, not the coalesced hot path — and a + // successful one also clears any pending coalesced dirty state. + if let Err(e) = save.flush(config).await { state .main_page .log_error("Failed to persist export-log entry", &e); - } else { - save.clear_after_external_save(); } state.main_page.content_panel.settings.mode = SettingsMode::View; dispatch_util::save_and_sync( @@ -381,11 +370,10 @@ async fn handle_export_config_action( } } -fn handle_import_config_action( +async fn handle_import_config_action( config: &mut Box, state: &mut State, save: &mut SaveScheduler, - profile: &str, path: &str, passphrase: &SecretString, ) { @@ -396,12 +384,10 @@ fn handle_import_config_action( .logs .insert(LogFamily::Config, format!("Config imported from {}", path)); // Force-flush the import-log entry (see export handler). - if let Err(e) = save_config(config, profile) { + if let Err(e) = save.flush(config).await { state .main_page .log_error("Failed to persist import-log entry", &e); - } else { - save.clear_after_external_save(); } state.main_page.content_panel.settings.mode = SettingsMode::View; dispatch_util::save_and_sync( @@ -451,14 +437,11 @@ async fn handle_set_passphrase( // R11 force-flush: a protection change re-derives the unlock key and rewrites // the secured config; it MUST be persisted before reporting success (the // keyring entry now requires the new passphrase to decrypt). `set_passphrase` - // saves synchronously — a deliberate force-flush point. (R12 moves its Argon2 - // derivation off the runtime.) - match set_passphrase(config, profile, passphrase).await { + // force-flushes and awaits it — a deliberate force-flush point, which also + // drops any pending coalesced dirty mark. (R12 moves its Argon2 derivation + // off the runtime.) + match set_passphrase(config, save, passphrase).await { Ok(()) => { - // The synchronous save above already persisted the latest state; - // drop any pending coalesced dirty mark so we don't redundantly - // re-save. - save.clear_after_external_save(); state.main_page.content_panel.settings.mode = SettingsMode::View; dispatch_util::save_and_sync( &mut state.main_page, @@ -486,18 +469,17 @@ async fn handle_set_passphrase( } } -fn handle_remove_passphrase( +async fn handle_remove_passphrase( config: &mut Box, state: &mut State, save: &mut SaveScheduler, profile: &str, ) { // R11 force-flush: same rationale as `handle_set_passphrase` — a protection - // change must be on disk before reporting success. `remove_passphrase` saves - // synchronously. - match remove_passphrase(config, profile) { + // change must be on disk before reporting success. `remove_passphrase` + // force-flushes and awaits it. + match remove_passphrase(config, save).await { Ok(()) => { - save.clear_after_external_save(); state.main_page.content_panel.settings.mode = SettingsMode::View; dispatch_util::save_and_sync( &mut state.main_page, @@ -1132,17 +1114,19 @@ pub(crate) async fn dispatch( // remaining (explicit, operator-driven) way to ask for one. SettingsAction::SubmitEdit { value } => handle_submit_edit(config, state, save, &value), SettingsAction::ExportConfig { path, passphrase } => { - handle_export_config_action(config, state, state_tx, save, profile, &path, passphrase) + handle_export_config_action(config, state, state_tx, save, &path, passphrase) .await } SettingsAction::ImportConfig { path, passphrase } => { - handle_import_config_action(config, state, save, profile, &path, &passphrase) + handle_import_config_action(config, state, save, &path, &passphrase).await } SettingsAction::ChangeProtection => handle_change_protection(state), SettingsAction::SetPassphrase { passphrase } => { handle_set_passphrase(config, state, state_tx, save, profile, &passphrase).await } - SettingsAction::RemovePassphrase => handle_remove_passphrase(config, state, save, profile), + SettingsAction::RemovePassphrase => { + handle_remove_passphrase(config, state, save, profile).await + } #[cfg(feature = "openpgp-card")] SettingsAction::TokenManagement => handle_token_management(state), #[cfg(feature = "openpgp-card")] diff --git a/openvtc/src/state_handler/setup_sequence/config.rs b/openvtc/src/state_handler/setup_sequence/config.rs index c404454a..5a121f1b 100644 --- a/openvtc/src/state_handler/setup_sequence/config.rs +++ b/openvtc/src/state_handler/setup_sequence/config.rs @@ -74,6 +74,16 @@ pub trait ConfigExtension { tdk: &TDK, profile: &str, ) -> Result; + + /// [`mint_persona_into`](Self::mint_persona_into) without the save: write + /// the persona into `config` in memory and return its id. The caller owns + /// persisting it — the TUI loops force-flush through their + /// `SaveScheduler`, the headless join saves directly. + async fn mint_persona_in_memory( + config: &mut Config, + state: &SetupState, + tdk: &TDK, + ) -> Result; } /// The operator-chosen name in `SetupState::username`, or `None` when there @@ -297,6 +307,22 @@ impl ConfigExtension for Config { state: &SetupState, tdk: &TDK, profile: &str, + ) -> Result { + let persona_id = Self::mint_persona_in_memory(config, state, tdk).await?; + config.save( + profile, + #[cfg(feature = "openpgp-card")] + &|| { + eprintln!("Touch confirmation needed for decryption"); + }, + )?; + Ok(persona_id) + } + + async fn mint_persona_in_memory( + config: &mut Config, + state: &SetupState, + tdk: &TDK, ) -> Result { // The callers have already refused a mint with no VTA mediator before // touching the VTA; this is the backstop for any path that forgets, so @@ -421,14 +447,6 @@ impl ConfigExtension for Config { config.public.friendly_name = name.to_string(); } - config.save( - profile, - #[cfg(feature = "openpgp-card")] - &|| { - eprintln!("Touch confirmation needed for decryption"); - }, - )?; - Ok(persona_id) } } From 653f23f125e2f10f6dbaf5bfd6757294af5328cc Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 20:23:12 +1000 Subject: [PATCH 23/27] =?UTF-8?q?refactor(state=5Fhandler):=20one=20spawn?= =?UTF-8?q?=20shape=20=E2=80=94=20a=20Job=20trait=20and=20background=5Fdis?= =?UTF-8?q?patch::dispatch=20(#535)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A job names its DispatchDomain and its I/O (Job::execute, returning the DispatchOutcome); dispatch(job, in_flight, dispatch_tx, on_busy) claims the domain and spawns it, or drops it unrun and hands on_busy the busy message. Work due only once the claim succeeded is done by the caller on true, in the same loop turn as the spawn, so no outcome can overtake it. On it: spawn_persona_mint, spawn_community_job, spawn_capability_job, spawn_vic_mutation, spawn_agent_name_job, spawn_vic_refresh (its begin_vic_refresh guard folds in; the test drives spawn_vic_refresh), spawn_persona_effect, dispatch_binding_refresh, the timer sweeps (agent names, community contexts, device grants, VTA transports), the did-git-sign detection, community_request's sends, the Repos send and workspace change, and the hidden-vetting job. Tests: dispatch claims the job's own domain and frees it when applied; a job for a busy domain is refused unrun with the domain's busy message. Signed-off-by: Glenn Gore --- docs/design/tui-architecture.md | 24 ++- .../src/state_handler/agent_name_actions.rs | 12 ++ .../src/state_handler/agent_name_refresh.rs | 18 ++ .../src/state_handler/background_dispatch.rs | 141 +++++++++++++ .../src/state_handler/capability_actions.rs | 46 +++-- .../src/state_handler/community_actions.rs | 10 + .../community_context_actions.rs | 48 +++++ .../src/state_handler/community_request.rs | 46 +++-- openvtc/src/state_handler/create_persona.rs | 12 ++ .../src/state_handler/hidden_vetting_vta.rs | 12 ++ openvtc/src/state_handler/mod.rs | 190 +++++++----------- .../src/state_handler/persona_actions/jobs.rs | 20 ++ .../state_handler/persona_binding_refresh.rs | 18 ++ openvtc/src/state_handler/repos_actions.rs | 44 ++-- openvtc/src/state_handler/repos_workspace.rs | 19 +- openvtc/src/state_handler/runtime/timers.rs | 83 +++----- openvtc/src/state_handler/settings_actions.rs | 39 ++-- .../src/state_handler/vetting_actions/pcs.rs | 22 +- openvtc/src/state_handler/vic.rs | 29 +++ openvtc/src/state_handler/vta_transports.rs | 17 ++ 20 files changed, 587 insertions(+), 263 deletions(-) diff --git a/docs/design/tui-architecture.md b/docs/design/tui-architecture.md index ea6e9581..4a3dfcda 100644 --- a/docs/design/tui-architecture.md +++ b/docs/design/tui-architecture.md @@ -103,11 +103,16 @@ or disk I/O parks the *whole* UI. Two mechanisms keep the loop live: for network-bound actions (mediator reconnect, relationship create/ping/remove, inbox accept/reject, context-DID delete): -1. The action's loop arm claims a `DispatchDomain` via - `InFlight::try_begin` (a per-domain busy-guard; a second action on a busy - domain is rejected with a status, not queued). -2. Cheap loop-thread prep builds a `…Job`; `spawn_dispatch` runs the job's **I/O - only** off-thread. +1. Cheap loop-thread prep builds a `…Job` from owned values: a type + implementing `background_dispatch::Job`, which names its `DispatchDomain` + and its **I/O only** (`execute`, returning the `DispatchOutcome`). +2. `background_dispatch::dispatch(job, in_flight, dispatch_tx, on_busy)` claims + the job's domain via `InFlight::try_begin` (a per-domain busy-guard) and + spawns it off-thread. A job for a busy domain is dropped unrun and + `on_busy` gets the busy message, to show or to remember as a deferred + retry — a second action is rejected or deferred, never run alongside. Work + due only once the claim succeeded (locking an overlay, arming a view on a + thread id) is done by the caller when `dispatch` returns `true`. 3. The job returns a `DispatchOutcome` over an mpsc the loop owns. The dedicated `Some(outcome) = dispatch_rx.recv()` arm calls `apply_outcome`, which mutates `State`/`Config` **on the loop thread** and clears the busy-flag. Mutation @@ -187,9 +192,12 @@ at each stage. - **Local business logic** → add an arm that calls into the matching `*_actions::dispatch`. - **Network/blocking work** → follow the background-dispatch pattern (§4): - claim a `DispatchDomain`, `spawn_dispatch` the I/O, and apply the result in - `apply_outcome` (extend `DispatchOutcome` if it is a new kind of work). -4. If the handler mutates `Config`, call `save.mark_dirty()` (not an inline save). + implement `Job` for the work, hand it to `background_dispatch::dispatch`, + and apply the result in `apply_outcome` (extend `DispatchOutcome` if it is + a new kind of work). +4. If the handler mutates `Config`, call `save.mark_dirty()` (not an inline + save); where the change must be on disk before success is reported, await + `save.flush(&config)`. Every TUI save goes through the `SaveScheduler`. ### Add a (DIDComm) message type diff --git a/openvtc/src/state_handler/agent_name_actions.rs b/openvtc/src/state_handler/agent_name_actions.rs index 57842ba0..647d8b58 100644 --- a/openvtc/src/state_handler/agent_name_actions.rs +++ b/openvtc/src/state_handler/agent_name_actions.rs @@ -59,6 +59,18 @@ pub(crate) struct AgentNameJob { pub(crate) verb: Verb, } +impl crate::state_handler::background_dispatch::Job for AgentNameJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::AgentNameManage + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::AgentNameManage( + self.run().await, + ) + } +} + impl AgentNameJob { /// Run the verb, then re-read the registry. I/O only — no `State`, no /// `Config`, nothing that has to be mutated on the loop. diff --git a/openvtc/src/state_handler/agent_name_refresh.rs b/openvtc/src/state_handler/agent_name_refresh.rs index beb3265e..9dac7e08 100644 --- a/openvtc/src/state_handler/agent_name_refresh.rs +++ b/openvtc/src/state_handler/agent_name_refresh.rs @@ -22,6 +22,24 @@ use tracing::debug; /// Maximum concurrent DID resolutions inside one batch job. const CONCURRENCY: usize = 4; +/// One sweep: every uncached or stale DID, resolved by [`resolve_batch`]. +pub(crate) struct AgentNameRefreshJob { + pub(crate) resolver: DIDCacheClient, + pub(crate) dids: Vec, +} + +impl crate::state_handler::background_dispatch::Job for AgentNameRefreshJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::AgentName + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::AgentName( + resolve_batch(self.resolver, self.dids).await, + ) + } +} + /// Resolve each DID to a verified agent name (or `None`), bounded to /// [`CONCURRENCY`] in flight. The result pairs each input DID with its verified /// name; order is not preserved (the caller keys by DID). diff --git a/openvtc/src/state_handler/background_dispatch.rs b/openvtc/src/state_handler/background_dispatch.rs index a1097ec8..0f5b2f94 100644 --- a/openvtc/src/state_handler/background_dispatch.rs +++ b/openvtc/src/state_handler/background_dispatch.rs @@ -487,6 +487,51 @@ pub(crate) fn spawn_dispatch( }); } +/// One unit of off-loop work: the domain it serialises on, and the I/O that +/// produces its [`DispatchOutcome`]. +/// +/// Every job is built on the loop thread from owned values (no borrow of +/// `State` or `Config` survives into it), then handed to [`dispatch`], which +/// is the one place a job is claimed and spawned. `execute` does I/O only; the +/// outcome it returns is applied on the loop by [`apply_outcome`]. +pub(crate) trait Job: Send + 'static { + /// The domain this job holds while it runs. + fn domain(&self) -> DispatchDomain; + + /// Run the job's I/O and wrap its result as the outcome the loop applies. + fn execute(self) -> impl std::future::Future + Send; +} + +/// Claim `job`'s domain and spawn it, or refuse it. +/// +/// The single spawn shape for off-loop work: +/// +/// * If the domain is free it is claimed ([`InFlight::try_begin`]) and the job +/// is spawned through [`spawn_dispatch`] (so a panic still frees the +/// domain); returns `true`. +/// * If the domain is busy the job is dropped unrun, `on_busy` is called with +/// the domain's [`InFlight::busy_message`] — each caller decides where that +/// is shown, or whether a deferred retry is remembered instead — and it +/// returns `false`. +/// +/// Work the loop must do only once the claim has succeeded (locking an +/// overlay, setting a loading flag) is done by the caller on `true`: the +/// outcome is applied by the same loop, so it can never overtake that work. +pub(crate) fn dispatch( + job: J, + in_flight: &mut InFlight, + dispatch_tx: &tokio::sync::mpsc::UnboundedSender, + on_busy: impl FnOnce(String), +) -> bool { + let domain = job.domain(); + if !in_flight.try_begin(domain) { + on_busy(InFlight::busy_message(domain)); + return false; + } + spawn_dispatch(dispatch_tx.clone(), domain, job.execute()); + true +} + /// Apply a completed [`DispatchOutcome`] to `state` and clear the domain's /// busy-flag. **Pure** over `(&mut State, &mut InFlight, outcome)` — no I/O — so /// it is unit-testable and is the single place the loop's outcome arm mutates @@ -922,6 +967,102 @@ mod tests { )); } + /// A test job: holds `domain`, and answers with a `Panicked` outcome for it + /// (the simplest outcome that names a domain) once `release` fires. + struct TestJob { + domain: DispatchDomain, + release: tokio::sync::oneshot::Receiver<()>, + } + + impl Job for TestJob { + fn domain(&self) -> DispatchDomain { + self.domain + } + + async fn execute(self) -> DispatchOutcome { + let _ = self.release.await; + DispatchOutcome::Panicked(self.domain) + } + } + + /// `dispatch` claims the job's own domain and spawns it: the domain is busy + /// until the outcome is applied, the outcome arrives on the channel, and + /// applying it frees the domain again. + #[tokio::test] + async fn dispatch_claims_the_jobs_domain_and_spawns_it() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let mut in_flight = InFlight::default(); + let (release, gate) = tokio::sync::oneshot::channel(); + + let mut busy = None; + let started = dispatch( + TestJob { + domain: DispatchDomain::Vic, + release: gate, + }, + &mut in_flight, + &tx, + |msg| busy = Some(msg), + ); + assert!(started); + assert!(busy.is_none(), "a free domain must not report busy"); + assert!(in_flight.is_busy(DispatchDomain::Vic)); + assert!( + !in_flight.is_busy(DispatchDomain::Community), + "only the job's own domain is claimed" + ); + + release.send(()).unwrap(); + let outcome = rx.recv().await.expect("the spawned job reports back"); + assert_eq!(outcome.domain(), DispatchDomain::Vic); + + let mut state = State::default(); + let mut config = test_config(); + let mut save = SaveScheduler::new("test"); + apply_outcome( + &mut state, + &mut config, + &mut save, + &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), + outcome, + ); + assert!(!in_flight.is_busy(DispatchDomain::Vic)); + } + + /// A job for a busy domain is refused: not spawned, `on_busy` told why in + /// the domain's own words, and the running job's claim left untouched. + #[tokio::test] + async fn dispatch_rejects_a_job_for_a_busy_domain() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let mut in_flight = InFlight::default(); + assert!(in_flight.try_begin(DispatchDomain::Community)); + + let (release, gate) = tokio::sync::oneshot::channel(); + let mut busy = None; + let started = dispatch( + TestJob { + domain: DispatchDomain::Community, + release: gate, + }, + &mut in_flight, + &tx, + |msg| busy = Some(msg), + ); + assert!(!started); + assert_eq!( + busy.as_deref(), + Some(InFlight::busy_message(DispatchDomain::Community).as_str()) + ); + assert!(in_flight.is_busy(DispatchDomain::Community)); + + // The refused job never ran: its gate was dropped with it, and nothing + // reaches the channel. + assert!(release.send(()).is_err(), "a refused job is dropped unrun"); + drop(tx); + assert!(rx.recv().await.is_none()); + } + /// The busy message names the domain so the UI tells the user *what* is /// already running. #[test] diff --git a/openvtc/src/state_handler/capability_actions.rs b/openvtc/src/state_handler/capability_actions.rs index dec74bee..84e82d5c 100644 --- a/openvtc/src/state_handler/capability_actions.rs +++ b/openvtc/src/state_handler/capability_actions.rs @@ -20,7 +20,7 @@ //! //! Building the document here is what fixes its id — the thread the reply is //! matched on — before the send is spawned, so the view is armed with it -//! straight away ([`CapabilitySend::arm`]). Armed only when the send reported +//! straight away ([`CapabilitySend::arming`]). Armed only when the send reported //! back, a community answering within a millisecond (a refusal from a host //! that offers no capability management does) beat it: the reply found no //! pending thread, was dropped, and the view sat out the 30 s reply window to @@ -126,25 +126,45 @@ pub(crate) struct CapabilitySend { signing_secret: Box, } +impl crate::state_handler::background_dispatch::Job for CapabilitySend { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::Capabilities + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::Capabilities(self.run().await) + } +} + impl CapabilitySend { /// The thread the community's reply will carry. pub(crate) fn thread(&self) -> &str { openvtc_core::capabilities::correlation_thread(&self.doc) } - /// Arm the view to await this request's reply. Called on the loop, before - /// the send is spawned, so a reply that overtakes the send's own report is + /// An owned closure that arms the view to await this request's reply. + /// + /// Detached from the send so the caller can hand the send to + /// [`dispatch`](crate::state_handler::background_dispatch::dispatch) and + /// arm only once the claim has succeeded. Called on the loop in the same + /// turn as the spawn, so a reply that overtakes the send's own report is /// still matched. - pub(crate) fn arm(&self, state: &mut State) { - arm( - state, - &self.vtc_did, - self.persona, - self.thread(), - self.toggle - .as_ref() - .map(|(slug, enable)| (slug.as_str(), *enable)), - ); + pub(crate) fn arming(&self) -> impl FnOnce(&mut State) + use<> { + let vtc_did = self.vtc_did.clone(); + let persona = self.persona; + let thid = self.thread().to_string(); + let toggle = self.toggle.clone(); + move |state: &mut State| { + arm( + state, + &vtc_did, + persona, + &thid, + toggle + .as_ref() + .map(|(slug, enable)| (slug.as_str(), *enable)), + ); + } } /// Sign and send. I/O only. diff --git a/openvtc/src/state_handler/community_actions.rs b/openvtc/src/state_handler/community_actions.rs index 12c36abc..7ca0ce21 100644 --- a/openvtc/src/state_handler/community_actions.rs +++ b/openvtc/src/state_handler/community_actions.rs @@ -116,6 +116,16 @@ pub(crate) struct CommunityJob { pub(crate) over_tsp: bool, } +impl crate::state_handler::background_dispatch::Job for CommunityJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::Community + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::Community(self.run().await) + } +} + impl CommunityJob { /// Do the send. I/O only. pub(crate) async fn run(self) -> CommunityOutcome { diff --git a/openvtc/src/state_handler/community_context_actions.rs b/openvtc/src/state_handler/community_context_actions.rs index 32343aee..8b16a195 100644 --- a/openvtc/src/state_handler/community_context_actions.rs +++ b/openvtc/src/state_handler/community_context_actions.rs @@ -862,6 +862,54 @@ pub(crate) fn sweep_targets(config: &Config) -> Vec { .collect() } +/// One device-grant sweep, by [`sweep`]. +pub(crate) struct DeviceGrantSweepJob { + pub(crate) client: VtaClient, + pub(crate) contexts: Vec, +} + +impl crate::state_handler::background_dispatch::Job for DeviceGrantSweepJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + DispatchDomain::DeviceGrantSweep + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + sweep(self.client, self.contexts).await + } +} + +/// Registering the contexts memberships name at the VTA: each is read, and +/// created under `top_context_id` when missing. +pub(crate) struct ContextRegistrationJob { + pub(crate) client: VtaClient, + pub(crate) top_context_id: String, + pub(crate) contexts: Vec, +} + +impl crate::state_handler::background_dispatch::Job for ContextRegistrationJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + DispatchDomain::CommunityContexts + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + let mut results = Vec::with_capacity(self.contexts.len()); + for context_id in self.contexts { + let name = openvtc_core::config::context_path::parse_sub_context_id(&context_id) + .map_or(context_id.clone(), |(_, slug)| slug.to_string()); + let result = community_context::ensure_context( + &self.client, + &self.top_context_id, + &context_id, + &name, + ) + .await + .map_err(|e| e.to_string()); + results.push((context_id, result)); + } + DispatchOutcome::CommunityContexts(results) + } +} + /// Read the device grants of every context in `contexts`. pub(crate) async fn sweep(client: VtaClient, contexts: Vec) -> DispatchOutcome { let mut results = Vec::with_capacity(contexts.len()); diff --git a/openvtc/src/state_handler/community_request.rs b/openvtc/src/state_handler/community_request.rs index ca86b4d7..622a862b 100644 --- a/openvtc/src/state_handler/community_request.rs +++ b/openvtc/src/state_handler/community_request.rs @@ -344,10 +344,10 @@ impl CommunityLoop<'_> { /// whether it was dispatched — or failed in a way the view now shows; `false` /// only for a busy lane, with nothing armed. /// - /// Armed before the send is spawned, never from its outcome: a community - /// that answers at once can have its answer applied first — to find nothing - /// pending, be dropped, and leave the view to time out on a request that - /// was answered. + /// Armed in the same loop turn as the spawn, never from its outcome: a + /// community that answers at once could otherwise have its answer applied + /// first — to find nothing pending, be dropped, and leave the view to time + /// out on a request that was answered. pub(crate) fn spawn_send( &mut self, sender: Sender, @@ -364,7 +364,16 @@ impl CommunityLoop<'_> { ); return true; }; - if !self.in_flight.try_begin(F::SEND) { + let job = FeatureSend:: { + job: SendJob { + sender, + vtc_did: to.clone(), + persona, + doc, + }, + feature: std::marker::PhantomData, + }; + if !background_dispatch::dispatch(job, self.in_flight, self.dispatch_tx, |_| {}) { return false; } if F::RECORDS_THREAD { @@ -377,22 +386,29 @@ impl CommunityLoop<'_> { thid, sent_at: Instant::now(), purpose, - to: to.clone(), + to, }); } - let job = SendJob { - sender, - vtc_did: to, - persona, - doc, - }; - background_dispatch::spawn_dispatch(self.dispatch_tx.clone(), F::SEND, async move { - F::outcome(job.run().await) - }); true } } +/// A [`SendJob`] on feature `F`'s lane, its outcome wrapped as `F`'s. +struct FeatureSend { + job: SendJob, + feature: std::marker::PhantomData F>, +} + +impl background_dispatch::Job for FeatureSend { + fn domain(&self) -> DispatchDomain { + F::SEND + } + + async fn execute(self) -> DispatchOutcome { + F::outcome(self.job.run().await) + } +} + /// Stop waiting on `thid` at the inbound gate, for a panel that records threads. fn forget(pending_replies: &mut PendingReplies, thid: &str) { if F::RECORDS_THREAD { diff --git a/openvtc/src/state_handler/create_persona.rs b/openvtc/src/state_handler/create_persona.rs index a945b95f..e4e9c059 100644 --- a/openvtc/src/state_handler/create_persona.rs +++ b/openvtc/src/state_handler/create_persona.rs @@ -295,6 +295,18 @@ pub(crate) struct MintJob { >, } +impl crate::state_handler::background_dispatch::Job for MintJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::Persona + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::Persona(Box::new( + self.run().await, + )) + } +} + impl MintJob { /// Run the mint, streaming each step. I/O only — the persist is the loop's. pub(crate) async fn run(self) -> MintOutcome { diff --git a/openvtc/src/state_handler/hidden_vetting_vta.rs b/openvtc/src/state_handler/hidden_vetting_vta.rs index 62681964..965ae4e3 100644 --- a/openvtc/src/state_handler/hidden_vetting_vta.rs +++ b/openvtc/src/state_handler/hidden_vetting_vta.rs @@ -118,6 +118,18 @@ pub(crate) struct PcsJob { pub work: Vec, } +impl crate::state_handler::background_dispatch::Job for PcsJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::HiddenVetting + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::HiddenVetting(Box::new( + self.run().await, + )) + } +} + impl PcsJob { pub(crate) async fn run(self) -> PcsOutcome { let mut results = Vec::with_capacity(self.work.len()); diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index ee735db6..5171f5fb 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -2205,16 +2205,11 @@ fn spawn_persona_mint( let Some(job) = advance_persona_overlay(state, config, tdk, admin_vta, dispatch_tx) else { return; }; - let domain = background_dispatch::DispatchDomain::Persona; - if !in_flight.try_begin(domain) { + background_dispatch::dispatch(job, in_flight, dispatch_tx, |busy| { if let Some(o) = state.main_page.create_persona.as_mut() { o.phase = main_page::content::CreatePersonaPhase::Path; - o.messages = vec![background_dispatch::InFlight::busy_message(domain)]; + o.messages = vec![busy]; } - return; - } - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::Persona(Box::new(job.run().await)) }); } @@ -2327,15 +2322,8 @@ fn spawn_community_job( state: &mut State, job: community_actions::CommunityJob, ) { - let domain = background_dispatch::DispatchDomain::Community; - if !in_flight.try_begin(domain) { - state.main_page.content_panel.communities.status_message = Some(Status::warning( - background_dispatch::InFlight::busy_message(domain), - )); - return; - } - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::Community(job.run().await) + background_dispatch::dispatch(job, in_flight, dispatch_tx, |busy| { + state.main_page.content_panel.communities.status_message = Some(Status::warning(busy)); }); } @@ -2383,29 +2371,26 @@ fn membership_over_tsp( /// governance documents at a community: the view is armed with exactly one /// pending thread id, and a second send would orphan the first. /// -/// The view is armed here, before the spawn, not when the send reports back: -/// the reply travels a different channel from the send's outcome and can be -/// dispatched first. +/// The view is armed here, in the same loop turn as the spawn, not when the +/// send reports back: the reply travels a different channel from the send's +/// outcome and can be dispatched first — but by this loop, so not before the +/// arm below has run. fn spawn_capability_job( dispatch_tx: &tokio::sync::mpsc::UnboundedSender, in_flight: &mut background_dispatch::InFlight, state: &mut State, job: capability_actions::CapabilityJob, ) { - let domain = background_dispatch::DispatchDomain::Capabilities; - if !in_flight.try_begin(domain) { + let send = job.prepare(); + let arm = send.arming(); + let started = background_dispatch::dispatch(send, in_flight, dispatch_tx, |busy| { if let Some(view) = state.main_page.content_panel.capabilities.view.as_mut() { - view.status_message = Some(Status::warning( - background_dispatch::InFlight::busy_message(domain), - )); + view.status_message = Some(Status::warning(busy)); } - return; - } - let send = job.prepare(); - send.arm(state); - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::Capabilities(send.run().await) }); + if started { + arm(state); + } } /// Start a VIC vault mutation off the loop, shared by both loops. @@ -2426,20 +2411,13 @@ fn spawn_vic_mutation( admin_vta: Option<&vta_sdk::client::VtaClient>, verb: vic::VicVerb, ) { - let domain = background_dispatch::DispatchDomain::Vic; let Some(admin_vta) = admin_vta else { return }; - if !in_flight.try_begin(domain) { - state - .main_page - .log(background_dispatch::InFlight::busy_message(domain)); - return; - } let job = vic::VicJob { admin_vta: admin_vta.clone(), verb, }; - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::VicMutation(job.run().await) + background_dispatch::dispatch(job, in_flight, dispatch_tx, |busy| { + state.main_page.log(busy); }); } @@ -2523,7 +2501,6 @@ fn spawn_agent_name_job( ) { use main_page::content::AgentNameManagerPhase; - let domain = background_dispatch::DispatchDomain::AgentNameManage; let opening = matches!(verb, agent_name_actions::Verb::Open); let status = verb.status(); @@ -2535,10 +2512,17 @@ fn spawn_agent_name_job( } return; }; - if !in_flight.try_begin(domain) { + let job = agent_name_actions::AgentNameJob { + vta: admin_vta.clone(), + persona_did, + verb, + }; + let started = background_dispatch::dispatch(job, in_flight, dispatch_tx, |busy| { if let Some(o) = state.main_page.agent_names.as_mut() { - o.message = Some(background_dispatch::InFlight::busy_message(domain)); + o.message = Some(busy); } + }); + if !started { return; } @@ -2553,38 +2537,6 @@ fn spawn_agent_name_job( // running; don't leave it armed over the refreshed list. o.confirm_remove = None; } - - let job = agent_name_actions::AgentNameJob { - vta: admin_vta.clone(), - persona_did, - verb, - }; - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::AgentNameManage(job.run().await) - }); -} - -/// Record what a refresh request does to the panel, and answer whether the -/// caller should actually start a job. Split out from [`spawn_vic_refresh`] -/// so the guard rules are testable without a live `VtaClient`. -/// -/// The three outcomes, and why each is what it is, are documented on -/// [`spawn_vic_refresh`]. -fn begin_vic_refresh( - in_flight: &mut background_dispatch::InFlight, - state: &mut State, - has_session: bool, -) -> bool { - if !has_session { - state.main_page.content_panel.vta.vic_loading = false; - return false; - } - if !in_flight.try_begin(background_dispatch::DispatchDomain::Vic) { - state.main_page.content_panel.vta.vic_refresh_queued = true; - return false; - } - state.main_page.content_panel.vta.vic_loading = true; - true } /// Start a background VIC-list refresh, shared by both loops. @@ -2615,20 +2567,20 @@ fn spawn_vic_refresh( state: &mut State, admin_vta: Option<&vta_sdk::client::VtaClient>, ) { - if !begin_vic_refresh(in_flight, state, admin_vta.is_some()) { + let Some(admin_vta) = admin_vta else { + state.main_page.content_panel.vta.vic_loading = false; return; + }; + let job = vic::VicRefreshJob { + admin_vta: admin_vta.clone(), + include_inactive: state.main_page.content_panel.vta.vic_show_inactive, + }; + let started = background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| { + state.main_page.content_panel.vta.vic_refresh_queued = true; + }); + if started { + state.main_page.content_panel.vta.vic_loading = true; } - let admin_vta = admin_vta.expect("checked by begin_vic_refresh").clone(); - let include_inactive = state.main_page.content_panel.vta.vic_show_inactive; - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::Vic, - async move { - background_dispatch::DispatchOutcome::Vic( - vic::VicRefreshOutcome::run(admin_vta, include_inactive).await, - ) - }, - ); } /// Start the identity pane's network work off the loop, shared by both loops. @@ -2659,7 +2611,6 @@ fn spawn_persona_effect( ) { use persona_actions::PersonaEffect; - let domain = background_dispatch::DispatchDomain::PersonaManage; if matches!(effect, PersonaEffect::None) { return; } @@ -2679,18 +2630,6 @@ fn spawn_persona_effect( } return; }; - if !in_flight.try_begin(domain) { - match effect { - PersonaEffect::Read => { - state.main_page.content_panel.identity.refresh_queued = true; - } - _ => persona_actions::release_form( - state, - background_dispatch::InFlight::busy_message(domain), - ), - } - return; - } match effect { PersonaEffect::None => unreachable!("returned above"), @@ -2701,18 +2640,20 @@ fn spawn_persona_effect( targets: persona_actions::PersonaReadJob::targets(state), needs_claim_types: !state.main_page.content_panel.identity.claim_types_loaded, }; - state.main_page.content_panel.identity.loading = true; - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::PersonaManage(job.run().await) + let started = background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| { + state.main_page.content_panel.identity.refresh_queued = true; }); + if started { + state.main_page.content_panel.identity.loading = true; + } } PersonaEffect::Job(job) => { let job = persona_actions::PersonaJobRun { admin_vta: admin_vta.clone(), job, }; - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::PersonaManage(job.run().await) + background_dispatch::dispatch(job, in_flight, dispatch_tx, |busy| { + persona_actions::release_form(state, busy); }); } } @@ -3327,18 +3268,13 @@ fn dispatch_binding_refresh( .collect(); if !binding_targets.is_empty() && let Some(client) = admin_vta - && in_flight.try_begin(background_dispatch::DispatchDomain::PersonaBinding) { - let client = client.clone(); - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::PersonaBinding, - async move { - background_dispatch::DispatchOutcome::PersonaBinding( - persona_binding_refresh::resolve_batch(client, binding_targets).await, - ) - }, - ); + let job = persona_binding_refresh::BindingRefreshJob { + client: client.clone(), + targets: binding_targets, + }; + // A sweep already running answers for this one too. + background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| {}); } } @@ -4769,33 +4705,45 @@ mod tests { /// filter flip — so its answer is already stale, and discarding the new /// request would leave, say, a just-archived VIC rendered as active until /// someone refreshed by hand. - #[test] - fn vic_refresh_guard_defers_rather_than_drops() { + #[tokio::test] + async fn vic_refresh_guard_defers_rather_than_drops() { let mut state = State::default(); let mut in_flight = background_dispatch::InFlight::default(); + let (tx, _rx) = mpsc::unbounded_channel(); + // Nothing listens there: the job a refresh starts fails fast and its + // outcome is never applied, so the domain stays claimed until this + // test frees it by hand. + let vta = vta_sdk::client::VtaClient::new("http://127.0.0.1:1"); // No admin session: nothing to query, and the panel must not be left // claiming a load is under way. state.main_page.content_panel.vta.vic_loading = true; - assert!(!begin_vic_refresh(&mut in_flight, &mut state, false)); + spawn_vic_refresh(&tx, &mut in_flight, &mut state, None); assert!(!state.main_page.content_panel.vta.vic_loading); assert!(!in_flight.is_busy(background_dispatch::DispatchDomain::Vic)); // First request with a session: claims the domain and starts loading. - assert!(begin_vic_refresh(&mut in_flight, &mut state, true)); + spawn_vic_refresh(&tx, &mut in_flight, &mut state, Some(&vta)); assert!(state.main_page.content_panel.vta.vic_loading); assert!(in_flight.is_busy(background_dispatch::DispatchDomain::Vic)); // Second while that one is in flight: not started, but remembered. - assert!(!begin_vic_refresh(&mut in_flight, &mut state, true)); + state.main_page.content_panel.vta.vic_loading = false; + spawn_vic_refresh(&tx, &mut in_flight, &mut state, Some(&vta)); assert!( state.main_page.content_panel.vta.vic_refresh_queued, "a rejected refresh must be re-issued once the domain frees" ); + assert!( + !state.main_page.content_panel.vta.vic_loading, + "a deferred refresh starts no load" + ); // Once the outcome lands the domain is free and the queued request runs. in_flight.finish(background_dispatch::DispatchDomain::Vic); state.main_page.content_panel.vta.vic_refresh_queued = false; - assert!(begin_vic_refresh(&mut in_flight, &mut state, true)); + spawn_vic_refresh(&tx, &mut in_flight, &mut state, Some(&vta)); + assert!(state.main_page.content_panel.vta.vic_loading); + assert!(in_flight.is_busy(background_dispatch::DispatchDomain::Vic)); } } diff --git a/openvtc/src/state_handler/persona_actions/jobs.rs b/openvtc/src/state_handler/persona_actions/jobs.rs index 9c3cf814..7c5f3747 100644 --- a/openvtc/src/state_handler/persona_actions/jobs.rs +++ b/openvtc/src/state_handler/persona_actions/jobs.rs @@ -205,6 +205,16 @@ pub(crate) struct PersonaReadJob { pub(crate) needs_claim_types: bool, } +impl crate::state_handler::background_dispatch::Job for PersonaReadJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::PersonaManage + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::PersonaManage(self.run().await) + } +} + impl PersonaReadJob { /// The targets a read should ask about: one per membership. pub(crate) fn targets(state: &State) -> Vec { @@ -272,6 +282,16 @@ pub(crate) struct PersonaJobRun { pub(crate) job: PersonaJob, } +impl crate::state_handler::background_dispatch::Job for PersonaJobRun { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::PersonaManage + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::PersonaManage(self.run().await) + } +} + impl PersonaJobRun { /// I/O only. pub(crate) async fn run(self) -> PersonaOutcome { diff --git a/openvtc/src/state_handler/persona_binding_refresh.rs b/openvtc/src/state_handler/persona_binding_refresh.rs index 8636f8b2..c6b92890 100644 --- a/openvtc/src/state_handler/persona_binding_refresh.rs +++ b/openvtc/src/state_handler/persona_binding_refresh.rs @@ -27,6 +27,24 @@ use vta_sdk::client::VtaClient; /// A `(sub_context_id, persona_did)` pair to ask about. pub type BindingTarget = (String, String); +/// One sweep: every membership's persona, asked of the agent in one job. +pub(crate) struct BindingRefreshJob { + pub(crate) client: VtaClient, + pub(crate) targets: Vec, +} + +impl crate::state_handler::background_dispatch::Job for BindingRefreshJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::PersonaBinding + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::PersonaBinding( + resolve_batch(self.client, self.targets).await, + ) + } +} + /// Resolve every target, in order, returning one entry per target. /// /// Sequential rather than concurrent on purpose: the count is bounded by the diff --git a/openvtc/src/state_handler/repos_actions.rs b/openvtc/src/state_handler/repos_actions.rs index 09850d41..4e2cab4d 100644 --- a/openvtc/src/state_handler/repos_actions.rs +++ b/openvtc/src/state_handler/repos_actions.rs @@ -28,7 +28,7 @@ use openvtc_core::config::account::PersonaId; use openvtc_core::git_ns::{self, GitRight, Reply, Request}; use crate::state_handler::actions::ReposAction as Act; -use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; +use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome}; use crate::state_handler::main_page::repos::{ AddPersonForm, ArmedChange, CreatedRepo, EXPIRY_CHOICES, LinkFlow, LinkPhase, LinkedAccount, NewRepoForm, Pending, Purpose, ReposPhase, ReposScreen, ReposView, Severity, Status, @@ -524,6 +524,16 @@ pub(crate) struct ReposJob { purpose: Purpose, } +impl background_dispatch::Job for ReposJob { + fn domain(&self) -> DispatchDomain { + DOMAIN + } + + async fn execute(self) -> DispatchOutcome { + DispatchOutcome::Repos(self.run().await) + } +} + impl ReposJob { /// Sign and send. I/O only. pub(crate) async fn run(mut self) -> ReposOutcome { @@ -712,30 +722,27 @@ async fn send(lp: &mut Loop<'_>, request: Request, purpose: Purpose, quiet: bool return false; } }; - if !lp.in_flight.try_begin(DOMAIN) { - if !quiet && let Some(view) = view_mut(lp.state) { - view.note(Severity::Warning, InFlight::busy_message(DOMAIN)); - } - return false; - } - if let Some(view) = view_mut(lp.state) { - arm( - view, - correlation_thread(&doc).to_string(), - purpose.clone(), - Instant::now(), - ); - } + let thid = correlation_thread(&doc).to_string(); let job = ReposJob { sender, vtc_did, persona, doc, - purpose, + purpose: purpose.clone(), }; - background_dispatch::spawn_dispatch(lp.dispatch_tx.clone(), DOMAIN, async move { - DispatchOutcome::Repos(job.run().await) + let started = background_dispatch::dispatch(job, lp.in_flight, lp.dispatch_tx, |msg| { + if !quiet && let Some(view) = view_mut(lp.state) { + view.note(Severity::Warning, msg); + } }); + if !started { + return false; + } + // Armed in the same loop turn as the spawn, so the answer — applied by + // this loop — always finds the view waiting on it. + if let Some(view) = view_mut(lp.state) { + arm(view, thid, purpose, Instant::now()); + } true } @@ -1472,6 +1479,7 @@ mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*; + use crate::state_handler::background_dispatch::InFlight; use openvtc_core::git_ns::{Refusal, view}; use openvtc_core::pending_replies::PendingReplies; use serde_json::json; diff --git a/openvtc/src/state_handler/repos_workspace.rs b/openvtc/src/state_handler/repos_workspace.rs index 962f094f..ab89e32c 100644 --- a/openvtc/src/state_handler/repos_workspace.rs +++ b/openvtc/src/state_handler/repos_workspace.rs @@ -653,6 +653,16 @@ pub(crate) async fn set_up_identity( const INSTALL_BINARY: &str = "did-git-sign is not on PATH, and git runs it to sign every commit. \ Install it (`cargo install did-git-sign`), then press e here."; +impl background_dispatch::Job for WorkspaceJob { + fn domain(&self) -> DispatchDomain { + DOMAIN + } + + async fn execute(self) -> DispatchOutcome { + DispatchOutcome::Workspace(self.run().await) + } +} + impl WorkspaceJob { async fn run(self) -> WorkspaceOutcome { let (vtc_did, persona) = (self.vtc_did.clone(), self.persona); @@ -1410,15 +1420,14 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: W) { settings: view.workspace.settings.clone(), settings_path: WorkspaceSettings::path(ctx.profile).ok(), }; - if !ctx.in_flight.try_begin(DOMAIN) { - view.note(Severity::Warning, InFlight::busy_message(DOMAIN)); + let started = background_dispatch::dispatch(job, ctx.in_flight, ctx.dispatch_tx, |msg| { + view.note(Severity::Warning, msg); + }); + if !started { return; } view.note(Severity::Progress, format!("{}…", capitalise(&busy))); view.workspace.busy = Some(busy); - background_dispatch::spawn_dispatch(ctx.dispatch_tx.clone(), DOMAIN, async move { - DispatchOutcome::Workspace(job.run().await) - }); } /// How a checkout reads, in a few words, for the list. diff --git a/openvtc/src/state_handler/runtime/timers.rs b/openvtc/src/state_handler/runtime/timers.rs index 49da9075..1ad67315 100644 --- a/openvtc/src/state_handler/runtime/timers.rs +++ b/openvtc/src/state_handler/runtime/timers.rs @@ -533,18 +533,12 @@ impl Runtime { // thread by `apply_outcome`. The busy-guard drops the tick if // a prior sweep is still running, so ticks never pile up. let targets = config.agent_name_refresh_targets(chrono::Utc::now()); - if !targets.is_empty() - && in_flight.try_begin(background_dispatch::DispatchDomain::AgentName) - { - let resolver = tdk.did_resolver().clone(); - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::AgentName, - async move { - let results = agent_name_refresh::resolve_batch(resolver, targets).await; - background_dispatch::DispatchOutcome::AgentName(results) - }, - ); + if !targets.is_empty() { + let job = agent_name_refresh::AgentNameRefreshJob { + resolver: tdk.did_resolver().clone(), + dids: targets, + }; + background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| {}); } // Ask what each persona presents. Shares this tick for the @@ -572,32 +566,15 @@ impl Runtime { .collect(); if !unchecked.is_empty() && let Some(client) = admin_vta.as_ref() - && in_flight.try_begin(background_dispatch::DispatchDomain::CommunityContexts) { - contexts_checked.extend(unchecked.iter().cloned()); - let client = client.clone(); - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::CommunityContexts, - async move { - let mut results = Vec::with_capacity(unchecked.len()); - for context_id in unchecked { - let name = - openvtc_core::config::context_path::parse_sub_context_id(&context_id) - .map_or(context_id.clone(), |(_, slug)| slug.to_string()); - let result = openvtc_core::config::community_context::ensure_context( - &client, - &top_context_id, - &context_id, - &name, - ) - .await - .map_err(|e| e.to_string()); - results.push((context_id, result)); - } - background_dispatch::DispatchOutcome::CommunityContexts(results) - }, - ); + let job = community_context_actions::ContextRegistrationJob { + client: client.clone(), + top_context_id, + contexts: unchecked.clone(), + }; + if background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| {}) { + contexts_checked.extend(unchecked); + } } // Read each membership context's device grants, once a run, @@ -609,15 +586,14 @@ impl Runtime { && let Some(client) = admin_vta.as_ref() { let targets = community_context_actions::sweep_targets(config); - if !targets.is_empty() - && in_flight.try_begin(background_dispatch::DispatchDomain::DeviceGrantSweep) - { - *grants_swept = true; - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::DeviceGrantSweep, - community_context_actions::sweep(client.clone(), targets), - ); + if !targets.is_empty() { + let job = community_context_actions::DeviceGrantSweepJob { + client: client.clone(), + contexts: targets, + }; + if background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| {}) { + *grants_swept = true; + } } } @@ -638,18 +614,11 @@ impl Runtime { if needs_probe && let openvtc_core::config::KeyBackend::Vta { vta_did, .. } = &config.key_backend && !vta_did.is_empty() - && in_flight.try_begin(background_dispatch::DispatchDomain::VtaTransports) { - let vta_did = vta_did.clone(); - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::VtaTransports, - async move { - background_dispatch::DispatchOutcome::VtaTransports( - vta_transports::probe(vta_did).await, - ) - }, - ); + let job = vta_transports::VtaTransportsProbe { + vta_did: vta_did.clone(), + }; + background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| {}); } } } diff --git a/openvtc/src/state_handler/settings_actions.rs b/openvtc/src/state_handler/settings_actions.rs index 550404b4..f6e58857 100644 --- a/openvtc/src/state_handler/settings_actions.rs +++ b/openvtc/src/state_handler/settings_actions.rs @@ -601,19 +601,33 @@ pub(crate) fn refresh_did_git_sign( settings.did_git_sign_wanted = false; return; } - if !in_flight.try_begin(DispatchDomain::DidGitSign) { + let job = DidGitSignJob { persona_did }; + let started = background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| { settings.did_git_sign_wanted = true; - return; + }); + if started { + settings.did_git_sign_wanted = false; + } +} + +/// One did-git-sign detection for `persona_did`, by +/// [`detect_did_git_sign_off_loop`]. +struct DidGitSignJob { + persona_did: String, +} + +impl background_dispatch::Job for DidGitSignJob { + fn domain(&self) -> DispatchDomain { + DispatchDomain::DidGitSign + } + + async fn execute(self) -> DispatchOutcome { + let info = detect_did_git_sign_off_loop(self.persona_did.clone()).await; + DispatchOutcome::DidGitSign { + persona_did: self.persona_did, + info, + } } - settings.did_git_sign_wanted = false; - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - DispatchDomain::DidGitSign, - async move { - let info = detect_did_git_sign_off_loop(persona_did.clone()).await; - DispatchOutcome::DidGitSign { persona_did, info } - }, - ); } /// [`detect_did_git_sign_info`](crate::state_handler::main_page::detect_did_git_sign_info) @@ -1114,8 +1128,7 @@ pub(crate) async fn dispatch( // remaining (explicit, operator-driven) way to ask for one. SettingsAction::SubmitEdit { value } => handle_submit_edit(config, state, save, &value), SettingsAction::ExportConfig { path, passphrase } => { - handle_export_config_action(config, state, state_tx, save, &path, passphrase) - .await + handle_export_config_action(config, state, state_tx, save, &path, passphrase).await } SettingsAction::ImportConfig { path, passphrase } => { handle_import_config_action(config, state, save, &path, &passphrase).await diff --git a/openvtc/src/state_handler/vetting_actions/pcs.rs b/openvtc/src/state_handler/vetting_actions/pcs.rs index 0d01173b..3202ed58 100644 --- a/openvtc/src/state_handler/vetting_actions/pcs.rs +++ b/openvtc/src/state_handler/vetting_actions/pcs.rs @@ -17,7 +17,7 @@ use super::hidden::{cannot_attest_hidden, vetter_context}; use super::jobs::Sent; use super::send::{sign_and_send, spawn_send}; use super::words::community_display; -use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome}; +use crate::state_handler::background_dispatch; use crate::state_handler::hidden_vetting_vta::{PcsDone, PcsJob, PcsOutcome, PcsWork}; use crate::state_handler::main_page::sanitize_display; use crate::state_handler::runtime_actions::ActionCtx; @@ -80,23 +80,19 @@ pub(crate) fn start_pcs_work(ctx: &mut ActionCtx<'_>, extra: Vec) -> bo ctx.config.private.vetting.pcs_due = false; return true; } - if !ctx.in_flight.try_begin(DispatchDomain::HiddenVetting) { - // The job running now takes what it carried; this is asked for when it is back. - ctx.config.private.vetting.pcs_due = true; - return false; - } - ctx.config.private.vetting.pcs_due = false; let job = PcsJob { client, inbox_taken, work, }; - background_dispatch::spawn_dispatch( - ctx.dispatch_tx.clone(), - DispatchDomain::HiddenVetting, - async move { DispatchOutcome::HiddenVetting(Box::new(job.run().await)) }, - ); - true + let started = background_dispatch::dispatch(job, ctx.in_flight, ctx.dispatch_tx, |_| { + // The job running now takes what it carried; this is asked for when it is back. + ctx.config.private.vetting.pcs_due = true; + }); + if started { + ctx.config.private.vetting.pcs_due = false; + } + started } /// The call that hands `item` to the VTA, with what it needs that the item does not carry. diff --git a/openvtc/src/state_handler/vic.rs b/openvtc/src/state_handler/vic.rs index 90c484f3..000dbe7a 100644 --- a/openvtc/src/state_handler/vic.rs +++ b/openvtc/src/state_handler/vic.rs @@ -250,12 +250,41 @@ impl VicVerb { } } +/// One backgrounded VIC-list refresh: the vault query, with the filter it was +/// asked under. +pub(crate) struct VicRefreshJob { + pub(crate) admin_vta: VtaClient, + pub(crate) include_inactive: bool, +} + +impl crate::state_handler::background_dispatch::Job for VicRefreshJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::Vic + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::Vic( + VicRefreshOutcome::run(self.admin_vta, self.include_inactive).await, + ) + } +} + /// One backgrounded vault mutation. pub(crate) struct VicJob { pub(crate) admin_vta: VtaClient, pub(crate) verb: VicVerb, } +impl crate::state_handler::background_dispatch::Job for VicJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::Vic + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::VicMutation(self.run().await) + } +} + impl VicJob { /// Do the round-trip. I/O only. pub(crate) async fn run(self) -> VicMutationOutcome { diff --git a/openvtc/src/state_handler/vta_transports.rs b/openvtc/src/state_handler/vta_transports.rs index 83214ec2..1b834be5 100644 --- a/openvtc/src/state_handler/vta_transports.rs +++ b/openvtc/src/state_handler/vta_transports.rs @@ -27,6 +27,23 @@ use crate::state_handler::main_page::content::AdvertisedTransports; +/// One probe of `vta_did`, by [`probe`]. +pub(crate) struct VtaTransportsProbe { + pub(crate) vta_did: String, +} + +impl crate::state_handler::background_dispatch::Job for VtaTransportsProbe { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::VtaTransports + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::VtaTransports( + probe(self.vta_did).await, + ) + } +} + /// Ceiling on one probe. `resolve_vta` resolves a DID document through the /// resolver cache and sets no deadline of its own, so the bound is imposed here /// (VTI R1.2): a hung publication endpoint must produce an error the panel can From 73b2d220d28064e4741487fc0daab36767a324c6 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 20:23:16 +1000 Subject: [PATCH 24/27] refactor(community_context): its VTA work is a ContextJob on dispatch (#535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview, delete, grant listing, grant and revoke jobs were ad-hoc futures spawned after a claim helper that checked the session and the domain together. They are now one ContextJob enum on the Job trait: the session is checked first (same message), then dispatch claims and spawns, and the panel is committed only once the claim succeeded — as before. Signed-off-by: Glenn Gore --- .../community_context_actions.rs | 345 ++++++++++++------ 1 file changed, 235 insertions(+), 110 deletions(-) diff --git a/openvtc/src/state_handler/community_context_actions.rs b/openvtc/src/state_handler/community_context_actions.rs index 8b16a195..8b9a8b5d 100644 --- a/openvtc/src/state_handler/community_context_actions.rs +++ b/openvtc/src/state_handler/community_context_actions.rs @@ -24,7 +24,6 @@ //! [`reduce`], which both loops share. use crate::state_handler::main_page::status::Status; -use std::future::Future; use chrono::Utc; use openvtc_core::community_access::{self, DeviceGrant, EXPIRY_CHOICES, Revoked}; @@ -33,11 +32,12 @@ use openvtc_core::config::account::{CommunityRecord, PersonaId}; use openvtc_core::config::community_context::{ self, ContextDeletion, ContextDeletionPreview, DELETE_CONFIRMATION, }; +use openvtc_core::didcomm::Messaging; use openvtc_core::errors::OpenVTCError; use vta_sdk::client::VtaClient; use crate::state_handler::actions::CommunityContextAction as Act; -use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; +use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome}; use crate::state_handler::main_page::content::{ CommunitiesState, ContextDeletePhase, ContextDeleteView, DeviceAccessView, GrantForm, GrantListing, @@ -239,23 +239,175 @@ fn community_name(config: &Config, membership: &CommunityRecord) -> String { ) } -/// Claim the domain and the VTA session for a job, or say why not. -fn claim(ctx: &mut ActionCtx<'_>, what: &str) -> Result { - let Some(client) = ctx.admin_vta else { - return Err(format!( - "VTA session unavailable — cannot {what} right now." - )); - }; - if !ctx.in_flight.try_begin(DOMAIN) { - return Err(InFlight::busy_message(DOMAIN)); +/// The VTA session a job needs, or why there is none. +fn session(ctx: &ActionCtx<'_>, what: &str) -> Result { + ctx.admin_vta + .cloned() + .ok_or_else(|| format!("VTA session unavailable — cannot {what} right now.")) +} + +/// Claim the domain for `job` and spawn it. On a busy domain the job is +/// dropped and `on_busy` given the reason; returns whether it started. +fn start( + ctx: &mut ActionCtx<'_>, + job: ContextJob, + on_busy: impl FnOnce(&mut State, String), +) -> bool { + let state = &mut *ctx.state; + background_dispatch::dispatch(job, ctx.in_flight, ctx.dispatch_tx, |busy| { + on_busy(state, busy); + }) +} + +/// One context job, resolved on the loop. I/O only. +enum ContextJob { + /// What deleting `context_id` would remove. + Preview { + client: VtaClient, + top: String, + context_id: String, + }, + /// Delete the context (and the persona that goes with it), then tear the + /// persona's listener down if its DID went. + Delete { + client: VtaClient, + service: Messaging, + top: String, + deletion: ContextDeletion, + vtc_did: String, + persona: PersonaId, + community: String, + }, + /// Read `context_id`'s device grants. + List { + client: VtaClient, + context_id: String, + }, + /// Grant `did` access, then re-read the grants. + Grant { + client: VtaClient, + top: String, + context_id: String, + did: String, + label: String, + expires: chrono::DateTime, + }, + /// Revoke `did`'s access, then re-read the grants. + Revoke { + client: VtaClient, + context_id: String, + did: String, + }, +} + +impl background_dispatch::Job for ContextJob { + fn domain(&self) -> DispatchDomain { + DOMAIN + } + + async fn execute(self) -> DispatchOutcome { + DispatchOutcome::CommunityContext(self.run().await) } - Ok(client.clone()) } -fn spawn(ctx: &ActionCtx<'_>, job: impl Future + Send + 'static) { - background_dispatch::spawn_dispatch(ctx.dispatch_tx.clone(), DOMAIN, async move { - DispatchOutcome::CommunityContext(job.await) - }); +impl ContextJob { + async fn run(self) -> ContextOutcome { + match self { + ContextJob::Preview { + client, + top, + context_id, + } => { + let result = + community_context::preview_context_deletion(&client, &top, &context_id) + .await + .map_err(|e| reason(&e)); + ContextOutcome::Previewed { context_id, result } + } + ContextJob::Delete { + client, + service, + top, + deletion, + vtc_did, + persona, + community, + } => { + let report = + community_context::delete_community_context(&client, &top, &deletion).await; + if report.persona_removed + && let Some(taken) = &deletion.persona + { + // The DID is gone, so the persona's listener has nothing left to + // receive — the same teardown deleting a persona does. + service + .remove_listener(&openvtc_core::didcomm::persona_listener_id(&taken.did)) + .await; + } + ContextOutcome::Deleted { + vtc_did, + persona, + community, + persona_removed: report.persona_removed, + result: report.result.map_err(|e| reason(&e)), + deletion, + } + } + ContextJob::List { client, context_id } => { + let result = community_access::list_device_grants(&client, &context_id) + .await + .map_err(|e| reason(&e)); + ContextOutcome::Listed { context_id, result } + } + ContextJob::Grant { + client, + top, + context_id, + did, + label, + expires, + } => { + let result = community_access::grant_device( + &client, + &top, + &context_id, + &did, + &label, + expires, + ) + .await + .map_err(|e| reason(&e)); + // Re-read whatever the grant did, so the list is the VTA's answer. + let listing = community_access::list_device_grants(&client, &context_id) + .await + .map_err(|e| reason(&e)); + ContextOutcome::Granted { + context_id, + did, + result, + listing, + } + } + ContextJob::Revoke { + client, + context_id, + did, + } => { + let result = community_access::revoke_device_grant(&client, &context_id, &did) + .await + .map_err(|e| reason(&e)); + let listing = community_access::list_device_grants(&client, &context_id) + .await + .map_err(|e| reason(&e)); + ContextOutcome::Revoked { + context_id, + did, + result, + listing, + } + } + } + } } /// Report a grant or revoke that could not start — always a refusal. @@ -291,25 +443,24 @@ fn start_deletion(ctx: &mut ActionCtx<'_>, index: usize) { phase: ContextDeletePhase::Previewing, typed: String::new(), }; - let context_id = deletion.context_id; - let top = config.account.top_context_id.clone(); - let client = match claim(ctx, "delete a context") { - Ok(client) => client, - Err(message) => { - panel(ctx.state).status_message = Some(Status::error(message)); - return; - } + let refuse = |state: &mut State, message: String| { + panel(state).status_message = Some(Status::error(message)); + }; + let job = match session(ctx, "delete a context") { + Ok(client) => ContextJob::Preview { + client, + top: config.account.top_context_id.clone(), + context_id: deletion.context_id, + }, + Err(message) => return refuse(ctx.state, message), }; + if !start(ctx, job, refuse) { + return; + } let communities = panel(ctx.state); communities.device_access = None; communities.status_message = None; communities.context_delete = Some(view); - spawn(ctx, async move { - let result = community_context::preview_context_deletion(&client, &top, &context_id) - .await - .map_err(|e| reason(&e)); - ContextOutcome::Previewed { context_id, result } - }); } fn confirm_deletion(ctx: &mut ActionCtx<'_>) { @@ -355,17 +506,13 @@ fn confirm_deletion(ctx: &mut ActionCtx<'_>) { communities.status_message = Some(Status::warning(problem)); return; } - let top = config.account.top_context_id.clone(); - let client = match claim(ctx, "delete a context") { + let refuse = |state: &mut State, message: String| { + panel(state).status_message = Some(Status::error(message)); + }; + let client = match session(ctx, "delete a context") { Ok(client) => client, - Err(message) => { - panel(ctx.state).status_message = Some(Status::error(message)); - return; - } + Err(message) => return refuse(ctx.state, message), }; - if let Some(open) = panel(ctx.state).context_delete.as_mut() { - open.phase = ContextDeletePhase::Deleting; - } let deletion = view.deletion(); let ContextDeleteView { vtc_did, @@ -373,27 +520,21 @@ fn confirm_deletion(ctx: &mut ActionCtx<'_>) { community, .. } = view; - let service = ctx.didcomm_service.clone(); - spawn(ctx, async move { - let report = community_context::delete_community_context(&client, &top, &deletion).await; - if report.persona_removed - && let Some(taken) = &deletion.persona - { - // The DID is gone, so the persona's listener has nothing left to - // receive — the same teardown deleting a persona does. - service - .remove_listener(&openvtc_core::didcomm::persona_listener_id(&taken.did)) - .await; - } - ContextOutcome::Deleted { - vtc_did, - persona, - community, - persona_removed: report.persona_removed, - result: report.result.map_err(|e| reason(&e)), - deletion, - } - }); + let job = ContextJob::Delete { + client, + service: ctx.didcomm_service.clone(), + top: config.account.top_context_id.clone(), + deletion, + vtc_did, + persona, + community, + }; + if !start(ctx, job, refuse) { + return; + } + if let Some(open) = panel(ctx.state).context_delete.as_mut() { + open.phase = ContextDeletePhase::Deleting; + } } /// The persona OpenVTC is acting as — the one selected, or, with no selection, @@ -441,24 +582,25 @@ fn open_devices(ctx: &mut ActionCtx<'_>, index: usize) { } fn read_grants(ctx: &mut ActionCtx<'_>, context_id: String) { - let client = match claim(ctx, "read device access") { + let client = match session(ctx, "read device access") { Ok(client) => client, Err(message) => return set_view_message(ctx.state, message), }; + let job = ContextJob::List { + client, + context_id: context_id.clone(), + }; + if !start(ctx, job, set_view_message) { + return; + } let communities = panel(ctx.state); communities .device_grants - .entry(context_id.clone()) + .entry(context_id) .or_insert(GrantListing::Loading); if let Some(view) = communities.device_access.as_mut() { view.busy = true; } - spawn(ctx, async move { - let result = community_access::list_device_grants(&client, &context_id) - .await - .map_err(|e| reason(&e)); - ContextOutcome::Listed { context_id, result } - }); } fn submit_grant(ctx: &mut ActionCtx<'_>) { @@ -478,35 +620,26 @@ fn submit_grant(ctx: &mut ActionCtx<'_>) { }; let expires = community_access::expires_at(Utc::now(), form.expiry); let label = community_access::grant_label(&form.name, &view.community); - let top = ctx.config.account.top_context_id.clone(); - let client = match claim(ctx, "grant device access") { + let client = match session(ctx, "grant device access") { Ok(client) => client, Err(message) => return set_view_message(ctx.state, message), }; + let progress = format!("Granting {}…", shorten_did(&did, 40)); + let job = ContextJob::Grant { + client, + top: ctx.config.account.top_context_id.clone(), + context_id: view.context_id, + did, + label, + expires, + }; + if !start(ctx, job, set_view_message) { + return; + } if let Some(open) = panel(ctx.state).device_access.as_mut() { open.busy = true; - open.message = Some(Status::progress(format!( - "Granting {}…", - shorten_did(&did, 40) - ))); + open.message = Some(Status::progress(progress)); } - let context_id = view.context_id; - spawn(ctx, async move { - let result = - community_access::grant_device(&client, &top, &context_id, &did, &label, expires) - .await - .map_err(|e| reason(&e)); - // Re-read whatever the grant did, so the list is the VTA's answer. - let listing = community_access::list_device_grants(&client, &context_id) - .await - .map_err(|e| reason(&e)); - ContextOutcome::Granted { - context_id, - did, - result, - listing, - } - }); } fn confirm_revoke(ctx: &mut ActionCtx<'_>) { @@ -527,31 +660,23 @@ fn confirm_revoke(ctx: &mut ActionCtx<'_>) { else { return; }; - let client = match claim(ctx, "revoke device access") { + let client = match session(ctx, "revoke device access") { Ok(client) => client, Err(message) => return set_view_message(ctx.state, message), }; + let progress = format!("Revoking {}…", shorten_did(&grant.did, 40)); + let job = ContextJob::Revoke { + client, + context_id, + did: grant.did, + }; + if !start(ctx, job, set_view_message) { + return; + } if let Some(open) = panel(ctx.state).device_access.as_mut() { open.busy = true; - open.message = Some(Status::progress(format!( - "Revoking {}…", - shorten_did(&grant.did, 40) - ))); + open.message = Some(Status::progress(progress)); } - spawn(ctx, async move { - let result = community_access::revoke_device_grant(&client, &context_id, &grant.did) - .await - .map_err(|e| reason(&e)); - let listing = community_access::list_device_grants(&client, &context_id) - .await - .map_err(|e| reason(&e)); - ContextOutcome::Revoked { - context_id, - did: grant.did, - result, - listing, - } - }); } // **************************************************************************** From cd51713ab7e966de2ea60ff4688852bbc564038c Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 21:27:06 +1000 Subject: [PATCH 25/27] ci: gate PRs on the #[ignore]d e2e suites; document the policy (#537) Also moves bip39 to openvtc-core's dev-dependencies: only the BIP32 tests use it. Signed-off-by: Glenn Gore --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ CONTRIBUTING.md | 4 ++++ openvtc-core/Cargo.toml | 3 ++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05819cd3..476670d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,29 @@ jobs: cargo check -p openvtc-core --locked --no-default-features --features arbitrary cargo test -p openvtc-core --locked --features arbitrary --lib + # The `#[ignore]`d tests (#537): the e2e suites that boot an in-process + # mediator or VTA (~1-30 s each). Policy: a test is `#[ignore]`d only for + # being slow or needing an in-process service — never for being flaky or + # broken — and every one of them gates PRs here. The coverage job also runs + # them, but it has no threshold, so a failure there did not fail a PR. + test-ignored: + name: Test (ignored e2e suites) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/add-swap + - name: Install Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev libdbus-1-dev + - run: cargo test --workspace --locked -- --ignored + test-dev-overrides: name: Test (dev-overrides) runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18106b15..c6f482d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,7 +90,11 @@ cargo clippy --workspace --all-targets -- -D warnings cargo fmt --all --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace + cargo test --workspace -- --ignored # the e2e suites (in-process mediator / VTA) ``` + A test is `#[ignore]`d only because it is slow or boots an in-process + service, never because it is flaky or broken, and its `ignore = "…"` reason + says which. CI's `test-ignored` job runs every one of them and gates the PR. 4. **Open the PR** with a clear title and description summarizing the change 5. **Link related issues** using `Closes #123` or `Fixes #123` in the PR body 6. **Respond to review feedback** promptly diff --git a/openvtc-core/Cargo.toml b/openvtc-core/Cargo.toml index 7edbc344..929dd9ec 100644 --- a/openvtc-core/Cargo.toml +++ b/openvtc-core/Cargo.toml @@ -69,7 +69,6 @@ affinidi-messaging-sdk = { workspace = true } futures-util = { workspace = true } agent-names.workspace = true base64.workspace = true -bip39.workspace = true chrono.workspace = true dirs.workspace = true didwebvh-rs.workspace = true @@ -108,6 +107,8 @@ byteorder = { workspace = true, optional = true } trust-tasks-build.workspace = true [dev-dependencies] +# Mnemonic fixtures in the BIP32 tests. +bip39.workspace = true # Scratch directories for the git-workspace tests (a real clone, read back). tempfile = "3" criterion = { version = "0.8", features = ["html_reports"] } From 422b0da92e6eb28a6c6d85b830f8a5f6020321da Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 21:27:32 +1000 Subject: [PATCH 26/27] docs(changelog): the runtime restructure Signed-off-by: Glenn Gore --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8382c4..74e1a076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Joining a community no longer freezes the rest of the app** + ([#529](https://github.com/OpenVTC/openvtc/issues/529)). The join runs as a job on the + main loop instead of a nested event loop, so saves, inbound messages, VTA supervision + and timers carry on while it runs. Esc, F10 or an error cancels a resolve, discovery or + launch at once, and the steps already taken are undone in reverse. A join job that + panics reports "Joining a community failed (internal error)." +- **The runtime loop is a `Runtime` struct with one method per event** + ([#528](https://github.com/OpenVTC/openvtc/issues/528)), and its arms are tested on + their own. Rooms and elections share one community request/reply engine, and the + replies awaited by thread are held in a `PendingReplies` table the runtime owns + instead of three process-wide statics + ([#534](https://github.com/OpenVTC/openvtc/issues/534)). Every TUI save goes through the + save scheduler, and background work is spawned through one `Job` / `dispatch` shape + ([#535](https://github.com/OpenVTC/openvtc/issues/535), in part). +- **Less work per loop turn** ([#555](https://github.com/OpenVTC/openvtc/issues/555), + [#556](https://github.com/OpenVTC/openvtc/issues/556), + [#565](https://github.com/OpenVTC/openvtc/issues/565), + [#557](https://github.com/OpenVTC/openvtc/issues/557)). The published state is shared + (`Arc`) rather than copied three times a turn, and two idle timers no longer + re-publish it. Config sync no longer reads did-git-sign's files or re-checks every VRC + for post-quantum proofs; persona labels, membership counts and relationship keys are + looked up by map. Personas load three at a time at startup, and one that fails no + longer stops the rest (their progress lines can interleave). VTA retries are jittered, + and `OPENVTC_DEBUG_LOG` is rotated once it passes 50 MiB. +- **CI runs the `#[ignore]`d end-to-end suites on every PR** + ([#537](https://github.com/OpenVTC/openvtc/issues/537)); CONTRIBUTING states when a + test may be ignored. + - **One confirmation rule** ([#579](https://github.com/OpenVTC/openvtc/issues/579)). A destructive or outward-facing action is armed by its key and committed by `y` alone; Enter — the key that opened the view — now cancels like any other key. Every prompt ends "y: confirm · any other key: @@ -150,6 +178,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **A failed join submit no longer deletes a persona the join reused** + ([#529](https://github.com/OpenVTC/openvtc/issues/529)). Only a persona the join itself + minted is rolled back. +- **A standalone persona mint is written to disk in one save**, including its origin + context ([#535](https://github.com/OpenVTC/openvtc/issues/535)), and the save no longer + blocks the event loop. + - **Nothing essential is clipped at 80x24** ([#576](https://github.com/OpenVTC/openvtc/issues/576)): all twelve menu items fit (the activity log shrinks to 4 rows below 32 rows; the menu scrolls to the selection on anything shorter, and is wide enough for its longest item); the footer row leads From cc23e14b6312ad40f5b6ff94fe8fce0b2c7f6de5 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 21:52:32 +1000 Subject: [PATCH 27/27] fix(join): the submit is past the point of no return; hold its community's replies; a flush survives an older save From review of the runtime restructure: - F10/Esc/an error during the submit stage wait for it instead of aborting it and forgetting a persona the community may now hold; a loop stop mid-submit keeps the persona and listener. - Inbound messages from the community being submitted to are held until the pending record exists, then processed (the nested loop got this by not reading inbound). - SaveScheduler::flush leaves the config dirty while an older background save is in flight, so that save cannot have the last word. - Launch guards that found no persona/applicant now fail and undo instead of leaving the page locked. - The 2 s VTA-sync tick republishes state again, so time-based displays keep moving. Signed-off-by: Glenn Gore --- CHANGELOG.md | 5 +- openvtc/src/state_handler/join_flow/driver.rs | 62 ++++++++++++++- .../state_handler/join_flow/driver/tests.rs | 78 +++++++++++++++++++ openvtc/src/state_handler/runtime/arms.rs | 4 +- .../src/state_handler/runtime/inbound_arm.rs | 28 +++++++ openvtc/src/state_handler/runtime/mod.rs | 18 +++-- openvtc/src/state_handler/save_coalesce.rs | 42 ++++++++-- 7 files changed, 221 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74e1a076..a5085c8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ([#529](https://github.com/OpenVTC/openvtc/issues/529)). The join runs as a job on the main loop instead of a nested event loop, so saves, inbound messages, VTA supervision and timers carry on while it runs. Esc, F10 or an error cancels a resolve, discovery or - launch at once, and the steps already taken are undone in reverse. A join job that + launch at once, and the steps already taken are undone in reverse — except once the + join request is being sent: then the exit waits for it, so the request is recorded and + the persona the community now holds is kept. Messages from that community wait until + the request is recorded, so a fast answer is not dropped. A join job that panics reports "Joining a community failed (internal error)." - **The runtime loop is a `Runtime` struct with one method per event** ([#528](https://github.com/OpenVTC/openvtc/issues/528)), and its arms are tested on diff --git a/openvtc/src/state_handler/join_flow/driver.rs b/openvtc/src/state_handler/join_flow/driver.rs index 18b17526..1cc1ec44 100644 --- a/openvtc/src/state_handler/join_flow/driver.rs +++ b/openvtc/src/state_handler/join_flow/driver.rs @@ -943,6 +943,9 @@ struct Job { generation: u64, abort: tokio::task::AbortHandle, then: Then, + /// The submit stage: the request may already be on the wire, so the job + /// is past the point where cancelling can undo it. + submitting: bool, } enum Then { @@ -1144,6 +1147,22 @@ impl JoinDriver { let Some(session) = self.session.as_mut() else { return JoinStep::Continue; }; + if session.job.as_ref().is_some_and(|job| job.submitting) + && matches!( + action, + Action::Exit | Action::UXError(_) | Action::JoinCancel + ) + { + // The request may already be on the wire. Cancelling now would + // forget the persona the community is about to answer, with no + // record of the request: let the submit finish and be recorded, + // then take this way out. + session.queued.push_back(action); + ctx.state + .join + .info("Sending the join request — stopping once it has been sent."); + return JoinStep::Continue; + } if session.job.is_some() { // The ways out take effect now, whatever the job is waiting on; // everything else waits its turn, as it did in the old loop's @@ -1217,7 +1236,7 @@ impl JoinDriver { JoinStep::Continue } (Then::Stage, JobResult::Invited(invited)) => { - self.on_invited(ctx, invited); + self.on_invited(ctx, invited).await; JoinStep::Continue } (Then::Stage, JobResult::Presented(presented)) => { @@ -1340,10 +1359,27 @@ impl JoinDriver { /// Abort the job in flight, and undo a launch that was under way. async fn interrupt(&mut self, ctx: &mut JoinCtx<'_>) { + let mut submitting = false; if let Some(job) = self.session.as_mut().and_then(|s| s.job.take()) { + submitting = job.submitting; job.abort.abort(); ctx.in_flight.finish(DispatchDomain::Join); } + if submitting && self.session.as_ref().is_some_and(|s| s.launch.is_some()) { + // Interrupted mid-submit (the app is stopping): the request may + // have gone out, so the persona and its listener are kept rather + // than forgotten — deleting a DID the community may now hold is + // worse than leaving one unused. + self.end_launch(ctx); + ctx.state.join.completed = Completion::CompletedFail; + ctx.state.join.info( + "Stopped while sending the join request. It may have been sent; the persona was kept. Check the community's status from Communities.", + ); + ctx.state + .main_page + .log("Join stopped while its request was being sent; persona kept."); + return; + } if self.session.as_ref().is_some_and(|s| s.launch.is_some()) { self.compensate(ctx).await; self.end_launch(ctx); @@ -1399,6 +1435,7 @@ impl JoinDriver { generation, abort, then, + submitting: false, }); } @@ -2789,7 +2826,7 @@ impl JoinDriver { /// Stage 3 came back: the listener's fate and the invitation to present. /// Plan the presenting — the linkage proof's signer, the answers, the /// hidden-vetting proof — on the loop, and run it off. - fn on_invited(&mut self, ctx: &mut JoinCtx<'_>, invited: Invited) { + async fn on_invited(&mut self, ctx: &mut JoinCtx<'_>, invited: Invited) { let Some(launch) = self.launch_mut() else { return; }; @@ -2835,9 +2872,11 @@ impl JoinDriver { ); } let Some((persona_id, _)) = launch.persona.clone() else { + self.fail_and_undo(ctx, "The join lost track of its persona part-way through and was undone. Please try again.").await; return; }; let Some((applicant_did, _)) = launch.applicant.clone() else { + self.fail_and_undo(ctx, "The join lost track of its persona part-way through and was undone. Please try again.").await; return; }; let linkage = plan_linkage(config, state, &applicant_did); @@ -2896,6 +2935,7 @@ impl JoinDriver { let (Some((persona_id, _)), Some((applicant_did, mediator))) = (launch.persona.clone(), launch.applicant.clone()) else { + self.fail_and_undo(ctx, "The join lost track of its persona part-way through and was undone. Please try again.").await; return; }; let vtc_did = launch.vtc_did.clone(); @@ -2968,6 +3008,23 @@ impl JoinDriver { |io, progress| io.submit(req, progress), JobResult::Submitted, ); + if let Some(job) = self.session.as_mut().and_then(|s| s.job.as_mut()) { + job.submitting = true; + } + } + + /// The community a join request is being sent to right now, if one is. + /// The runtime holds that community's messages until the request has + /// been recorded, so a fast answer is not processed before the pending + /// record it answers exists. + pub(crate) fn submitting_to(&self) -> Option<&str> { + let session = self.session.as_ref()?; + session + .job + .as_ref() + .filter(|job| job.submitting) + .and(session.launch.as_ref()) + .map(|launch| launch.vtc_did.as_str()) } /// Stage 5 came back: the request went out, or why not. Record the @@ -3006,6 +3063,7 @@ impl JoinDriver { let (Some((persona_id, persona_did)), Some((applicant_did, _))) = (launch.persona.clone(), launch.applicant.clone()) else { + self.fail_and_undo(ctx, "The join lost track of its persona part-way through and was undone. Please try again.").await; return; }; let vtc_did = launch.vtc_did.clone(); diff --git a/openvtc/src/state_handler/join_flow/driver/tests.rs b/openvtc/src/state_handler/join_flow/driver/tests.rs index bd1e3909..6795cf32 100644 --- a/openvtc/src/state_handler/join_flow/driver/tests.rs +++ b/openvtc/src/state_handler/join_flow/driver/tests.rs @@ -39,6 +39,9 @@ enum Fail { SubmitSigner, /// The invitation stage never finishes — for a cancel to land in. HangInInvite, + /// The submit waits for the test to release it — the request is on the + /// wire while it waits. + GatedSubmit, } struct Fake { @@ -48,6 +51,8 @@ struct Fake { saves: AtomicUsize, /// A resolve that waits for the test to answer it. resolve_gate: Mutex>>>, + /// Released by the test to let a `GatedSubmit` submit finish. + submit_gate: Mutex>>, } impl Fake { @@ -57,6 +62,7 @@ impl Fake { removed: Mutex::new(Vec::new()), saves: AtomicUsize::new(0), resolve_gate: Mutex::new(None), + submit_gate: Mutex::new(None), }) } @@ -161,7 +167,13 @@ impl JoinIo for Fake { fn submit(&self, req: SubmitReq, _progress: Progress) -> IoFuture<'static, Submitted> { let fail = self.fail; + let gate = self.submit_gate.lock().unwrap().take(); Box::pin(async move { + if fail == Fail::GatedSubmit + && let Some(gate) = gate + { + let _ = gate.await; + } let request = match req.prepared { Err(e) => Err(e), Ok(_) if fail == Fail::SubmitSend => Err(SubmitError::Send(OpenVTCError::Config( @@ -767,3 +779,69 @@ async fn a_stale_outcome_is_ignored() { assert_eq!(h.state.join.page, JoinPage::EnterDid); assert!(!h.job_in_flight()); } + +// ---- The submit: past the point of no return ---- + +/// The job in flight is the submit. +fn submitting(h: &Harness) -> bool { + h.driver.submitting_to().is_some() +} + +/// F10 while the request is on the wire waits for it: the request is +/// recorded and the persona kept, then the join exits. +#[tokio::test] +async fn an_exit_during_the_submit_waits_for_it_and_records_the_join() { + let mut h = Harness::new(Fail::GatedSubmit).await; + let (release, gate) = oneshot::channel(); + *h.fake.submit_gate.lock().unwrap() = Some(gate); + h.launch(IdentityPick::Mint).await; + h.run_until(submitting).await; + assert!(submitting(&h), "parked in the submit"); + assert_eq!(h.driver.submitting_to(), Some(COMMUNITY)); + + let step = h.act(Action::Exit).await; + assert!(matches!(step, JoinStep::Continue), "the exit waits"); + assert!(h.said("stopping once it has been sent")); + assert!(h.persona(MINTED_DID), "nothing is undone while it sends"); + + release.send(()).unwrap(); + h.run_jobs().await; + assert!( + h.config + .account + .memberships() + .any(|m| m.vtc_did == COMMUNITY), + "the sent request is recorded" + ); + assert!( + h.persona(MINTED_DID), + "the persona the community holds is kept" + ); + assert!(h.fake.removed().is_empty(), "its listener stays"); + // The loop takes the queued exit next (`next_action`), as it does any key + // held back behind a job. + let queued = h.driver.leftover.pop_front(); + assert!(matches!(queued, Some(Action::Exit)), "the exit was kept"); + let step = h.act(Action::Exit).await; + assert!( + matches!(step, JoinStep::Exit(Interrupted::UserInt)), + "then the exit is taken" + ); + assert!(h.persona(MINTED_DID), "and nothing is undone by it"); +} + +/// The loop stopping mid-submit cannot wait, so it keeps the persona rather +/// than forget a DID the community may now hold. +#[tokio::test] +async fn an_interrupt_during_the_submit_keeps_the_persona() { + let mut h = Harness::new(Fail::GatedSubmit).await; + let (_release, gate) = oneshot::channel::<()>(); + *h.fake.submit_gate.lock().unwrap() = Some(gate); + h.launch(IdentityPick::Mint).await; + h.run_until(submitting).await; + h.cancel().await; + assert!(!h.driver.is_active()); + assert!(h.persona(MINTED_DID)); + assert!(h.fake.removed().is_empty()); + assert!(h.said("It may have been sent; the persona was kept.")); +} diff --git a/openvtc/src/state_handler/runtime/arms.rs b/openvtc/src/state_handler/runtime/arms.rs index af4d748f..036f6030 100644 --- a/openvtc/src/state_handler/runtime/arms.rs +++ b/openvtc/src/state_handler/runtime/arms.rs @@ -150,10 +150,12 @@ impl Runtime { background_dispatch::DispatchOutcome::Join(outcome) => { let (join, mut ctx) = self.join_parts(); let step = join.on_outcome(&mut ctx, *outcome).await; - return match self.apply_join_step(step).await { + let flow = match self.apply_join_step(step).await { Some(interrupted) => Flow::Exit(interrupted), None => Flow::Continue, }; + self.release_held_inbound().await; + return flow; } background_dispatch::DispatchOutcome::Progress( background_dispatch::ProgressUpdate::Join { generation, line }, diff --git a/openvtc/src/state_handler/runtime/inbound_arm.rs b/openvtc/src/state_handler/runtime/inbound_arm.rs index 43c22cd9..004843fc 100644 --- a/openvtc/src/state_handler/runtime/inbound_arm.rs +++ b/openvtc/src/state_handler/runtime/inbound_arm.rs @@ -4,7 +4,22 @@ use super::*; use crate::state_handler::*; +/// The most messages held for a join being submitted; past this they are +/// processed as they arrive, as before. +const MAX_HELD_INBOUND: usize = 64; + impl Runtime { + /// Process messages held while a join request was being sent, once it + /// no longer is. + pub(super) async fn release_held_inbound(&mut self) { + if self.join.submitting_to().is_some() || self.held_inbound.is_empty() { + return; + } + for (event, pre) in std::mem::take(&mut self.held_inbound) { + Box::pin(self.on_inbound(event, pre)).await; + } + } + /// DIDComm inbound message events — as they arrive, or handed /// back with the result of their off-loop check. pub(super) async fn on_inbound( @@ -12,6 +27,19 @@ impl Runtime { event: didcomm::DIDCommEvent, pre: Option, ) { + // While a join request to a community is being sent, its messages + // wait: the pending record its submit-receipt or verdict is matched + // against is written only once the submit comes back, and a fast + // answer processed first would find nothing and be dropped. The old + // nested join loop got this for free by not reading inbound at all. + if let didcomm::DIDCommEvent::InboundMessage { authenticated, .. } = &event + && let Some(vtc) = self.join.submitting_to() + && authenticated.as_deref() == Some(vtc) + && self.held_inbound.len() < MAX_HELD_INBOUND + { + self.held_inbound.push((event, pre)); + return; + } let Runtime { state, config, diff --git a/openvtc/src/state_handler/runtime/mod.rs b/openvtc/src/state_handler/runtime/mod.rs index 2020a258..78a95ea4 100644 --- a/openvtc/src/state_handler/runtime/mod.rs +++ b/openvtc/src/state_handler/runtime/mod.rs @@ -171,6 +171,12 @@ pub(crate) struct Runtime { pub(super) join: join_flow::JoinDriver, /// The join flow to enter at the end of this iteration. pub(super) join_entry: Option, + /// Messages from a community whose join request is being sent, held + /// until the request is recorded (see [`Runtime::on_inbound`]). + pub(super) held_inbound: Vec<( + didcomm::DIDCommEvent, + Option, + )>, /// A join waiting on a community's requirements (see `join_flow::AwaitingRequirements`). pub(super) awaiting_requirements: Option, /// A join parked on a hidden-vetting challenge (see @@ -512,6 +518,7 @@ impl Runtime { hidden_vetting_pacer: hidden_vetting_poll::HiddenVettingPacer::default(), join: join_flow::JoinDriver::new(true), join_entry: None, + held_inbound: Vec::new(), awaiting_requirements: None, awaiting_challenge: None, awaiting_wallet: None, @@ -614,11 +621,12 @@ impl Runtime { } // Every two seconds, and almost always with nothing to start. _ = self.vta_sync_tick.tick() => { - if self.on_vta_sync_tick().await { - Flow::Continue - } else { - Flow::Quiet - } + // Always republished: the UI redraws only on keys or new + // state, and this 2 s tick is what keeps "now"-based + // displays (pending-join liveness, election and receive + // countdowns) moving, as on the old loop. + self.on_vta_sync_tick().await; + Flow::Continue } Some(ex) = self.vta_sync_rx.recv() => { self.on_vta_sync_exchange(ex).await; diff --git a/openvtc/src/state_handler/save_coalesce.rs b/openvtc/src/state_handler/save_coalesce.rs index 53bf2b29..c1616b56 100644 --- a/openvtc/src/state_handler/save_coalesce.rs +++ b/openvtc/src/state_handler/save_coalesce.rs @@ -227,11 +227,11 @@ impl SaveScheduler { /// `spawn_blocking`, awaiting completion. This drains any pending dirty state: /// after a successful flush, `dirty` is cleared and the deadline disarmed. /// - /// It does not coordinate with an already in-flight background save (the - /// caller's mutation may post-date it); flushing unconditionally guarantees - /// the *current* live state hits disk, which is the durability guarantee we - /// want. The in-flight flag is left untouched so a concurrently completing - /// background save still calls `finish` correctly. + /// It does not wait for an already in-flight background save (the caller's + /// mutation may post-date it); flushing unconditionally puts the *current* + /// live state on disk now. Because that older save may land afterwards, + /// the config is left dirty while one is in flight, and its `finish` + /// writes the current state once more. /// /// # Errors /// @@ -248,12 +248,20 @@ impl SaveScheduler { .await .map_err(|e| OpenVTCError::Config(format!("save task panicked: {e}")))?; if result.is_ok() { - self.dirty = false; - self.deadline = None; + self.settle_after_flush(); } result } + /// The bookkeeping after a successful [`flush`](Self::flush). A background + /// save still in flight holds an *older* snapshot and may land after the + /// flush, overwriting it; leaving the config dirty makes its + /// [`finish`](Self::finish) schedule one more save of the current state. + fn settle_after_flush(&mut self) { + self.dirty = self.in_flight; + self.deadline = None; + } + /// Build a [`PendingSave`] from the live config without touching the /// scheduler's dirty/in-flight state. Used by the shutdown path, which runs a /// *direct* blocking save after the loop has broken (no runtime arm left to @@ -328,6 +336,26 @@ mod tests { /// At most one save in flight: while a save is running, the deadline firing /// must NOT start a second one. A mark that lands during the in-flight save /// is remembered and scheduled by `finish`. + /// A flush that overlaps a background save must not let the older + /// snapshot have the last word: the config stays dirty, and the + /// background save's `finish` schedules a fresh one. + #[test] + fn a_flush_during_a_background_save_schedules_another() { + let mut s = SaveScheduler::new("test"); + s.mark_dirty(); + s.dirty = false; + s.in_flight = true; // a background save with an older snapshot + s.settle_after_flush(); + assert!(s.dirty, "the overlapped flush must leave the config dirty"); + s.finish(true); + assert!(s.deadline.is_some(), "finish must schedule another save"); + + let mut idle = SaveScheduler::new("test"); + idle.mark_dirty(); + idle.settle_after_flush(); + assert!(!idle.dirty && idle.deadline.is_none()); + } + #[test] fn at_most_one_in_flight_and_reschedules() { let mut sched = SaveScheduler::new("test");