Skip to main content

agentmux_srv/sagas/
close_pane.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// ClosePane saga — closes every block in a pane, each one stopped before its
5// records are deleted. docs/specs/SPEC_AGENT_PANE_CLOSE_GRACEFUL_SHUTDOWN_2026_09_18.md
6//
7// A pane (layout leaf) can hold several blocks as tabs (`block_stack`). The
8// pane's × used to send one `DeleteBlock` for the visible tab only, leaving
9// every other tab's agent running with no pane (§2 of the spec), and the
10// `ClosePane` MCP tool had the same gap (#3202). This saga takes the whole set.
11//
12// **Steps (§4.2, §9.2):**
13// 1. `agent_teardown::run_many(.., Policy::close())` over every block at
14//    once, one deadline (SPEC_AGENT_TEARDOWN_SINGLE_PATH_2026_10_01.md): refuse
15//    respawn and messaging delivery, interrupt any turn, let the process
16//    exit (force-kill at the deadline), THEN drop its process tracker, then
17//    save final state (`session:active_pid` cleared, instance row `stopped`
18//    + `ended_at`) while the block record still exists.
19// 2. `DeleteBlock { tab_id, block_id }` through the reducer, per block.
20//
21// **Then, once for the pane:** if every member of the leaf is gone, delete
22// the leaf (`LayoutDeleteNode`) and queue one frontend `delete` for it.
23// A leaf that still has a live member is left alone — closing one tab of a
24// stack (`closeBlockInStack`) must never take its siblings with it; the
25// frontend has already pushed the shortened stack, and the reducer's
26// `prune_dangling_stack_members` covers any stale copy.
27//
28// **Compensation:** none, same as `delete_block` — a stopped process and a
29// deleted record cannot be recreated from saga state. A block whose
30// `DeleteBlock` dispatch fails stays in the tab with its process stopped;
31// closing it again finishes the job.
32
33use agentmux_common::ipc::Command;
34use serde_json::{json, Value};
35
36use super::{
37    alloc_saga_id, classify_run_saga_result, emit_saga_started, emit_terminal, run_saga, SagaCtx,
38};
39use crate::backend::blockcontroller;
40use crate::server::AppState;
41
42/// Close `block_ids` (normally every member of one pane's stack). Ids that no
43/// longer exist are skipped; ids in different tabs are rejected. On success
44/// returns `{"tab_id": "...", "block_ids": [...closed...], "leaf_removed": bool}`.
45pub async fn run(state: &AppState, block_ids: Vec<String>) -> Result<Value, String> {
46    run_with(state, block_ids, CloseOpts::default()).await
47}
48
49/// How the caller's frontend handles the layout.
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
51pub struct CloseOpts {
52    /// The frontend kept the pane on screen (showing the shutdown log,
53    /// SPEC_AGENT_SELF_QUIT_2026_09_24.md §5.5) instead of removing it first,
54    /// so srv must queue a frontend `delete` for EVERY closed member — the
55    /// frontend removes each as it arrives, the last one taking the pane.
56    /// `false` (the historical flow): the frontend already removed what it
57    /// closed, and one `delete` for the visible block covers a whole leaf.
58    pub frontend_waits: bool,
59}
60
61pub async fn run_with(state: &AppState, block_ids: Vec<String>, opts: CloseOpts) -> Result<Value, String> {
62    let mut ids: Vec<String> = Vec::new();
63    for id in block_ids {
64        if !id.is_empty() && !ids.contains(&id) {
65            ids.push(id);
66        }
67    }
68    if ids.is_empty() {
69        return Err("ClosePane: no block ids".to_string());
70    }
71
72    let (tab_id, present, leaf_to_delete) = {
73        let s = state.srv_state.lock().await;
74        let mut tab_id: Option<String> = None;
75        let mut present = Vec::new();
76        for id in &ids {
77            let Some(block) = s.blocks.get(id) else {
78                continue; // already gone
79            };
80            match &tab_id {
81                None => tab_id = Some(block.tab_id.clone()),
82                Some(t) if *t != block.tab_id => {
83                    return Err(format!(
84                        "ClosePane: blocks span tabs ({} is in {}, not {})",
85                        id, block.tab_id, t
86                    ));
87                }
88                Some(_) => {}
89            }
90            present.push(id.clone());
91        }
92        let Some(tab_id) = tab_id else {
93            return Err(format!("ClosePane: block not found: {}", ids.join(", ")));
94        };
95        if !s.tabs.contains_key(&tab_id) {
96            return Err(format!("ClosePane: tab not found: {}", tab_id));
97        }
98        // The leaf to remove: the one holding these blocks, and only if none
99        // of its members survives this close.
100        let leaf_to_delete = s
101            .tabs
102            .get(&tab_id)
103            .and_then(|t| t.rootnode.as_ref())
104            .and_then(|root| {
105                present
106                    .iter()
107                    .find_map(|id| crate::backend::layout::find_leaf_containing_block(root, id))
108            })
109            .and_then(|leaf| {
110                let data = leaf.data.as_ref()?;
111                let all_closing = crate::backend::layout::leaf_members(data)
112                    .iter()
113                    .all(|m| ids.contains(m) || !s.blocks.contains_key(m));
114                all_closing.then(|| (leaf.id.clone(), data.block_id.clone()))
115            });
116        (tab_id, present, leaf_to_delete)
117    };
118
119    let saga_id = alloc_saga_id(state);
120    emit_saga_started(
121        state,
122        saga_id,
123        "close_pane",
124        json!({
125            "tab_id": &tab_id,
126            "block_ids": &present,
127        }),
128    )
129    .await?;
130    let ctx = SagaCtx::new(state, saga_id);
131    let result = run_saga(
132        "close_pane",
133        run_inner(ctx, tab_id, present.clone(), leaf_to_delete, opts, gone_before(&ids, &present)),
134    )
135    .await;
136    for id in &present {
137        finish_close(state, id).await;
138    }
139    emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
140    result
141}
142
143async fn run_inner(
144    ctx: SagaCtx<'_>,
145    tab_id: String,
146    block_ids: Vec<String>,
147    leaf_to_delete: Option<(String, String)>,
148    opts: CloseOpts,
149    // Asked for, but already gone before this close: a waiting frontend still
150    // shows them, so they get a frontend `delete` too.
151    gone: Vec<String>,
152) -> Result<Value, String> {
153    let mut failures = Vec::new();
154    let mut failed_ids: Vec<&String> = Vec::new();
155    super::agent_teardown::run_many(ctx.state, &block_ids, super::agent_teardown::Policy::close()).await;
156    for block_id in &block_ids {
157        if let Err(reason) = ctx
158            .dispatch(Command::DeleteBlock {
159                tab_id: tab_id.clone(),
160                block_id: block_id.clone(),
161            })
162            .await
163        {
164            tracing::warn!(
165                tab_id = %tab_id,
166                block_id = %block_id,
167                "[saga] ClosePane: DeleteBlock dispatch failed (process already stopped): {}",
168                reason
169            );
170            failures.push(format!("{block_id}: {reason}"));
171            failed_ids.push(block_id);
172        }
173    }
174
175    // A member whose delete failed still exists; removing the leaf now would
176    // leave it in the tab with no pane — the orphan this saga exists to
177    // prevent. Return before touching the layout (as `delete_block` does);
178    // the pane stays, and closing it again finishes the job.
179    if !failures.is_empty() {
180        // A waiting frontend still shows every member, including the ones
181        // that DID close — tell it to drop those, or they'd stay on screen
182        // with nothing behind them (ReAgent P1 on #3784). The failed ones stay
183        // and show the error.
184        if opts.frontend_waits {
185            let mut closed: Vec<String> = block_ids.iter().filter(|id| !failed_ids.contains(id)).cloned().collect();
186            closed.extend(gone.iter().cloned());
187            queue_member_deletes(ctx.state, &tab_id, &closed).await;
188        }
189        // Only the members that failed show the error; the ones that closed
190        // are on their way out (ReAgent P2 on #3784).
191        let reason = format!("ClosePane: {}", failures.join("; "));
192        for id in &failed_ids {
193            publish_shutdown_error(ctx.state, id, &reason);
194        }
195        return Err(reason);
196    }
197
198    let leaf_removed = leaf_to_delete.is_some();
199    if opts.frontend_waits {
200        // The frontend still shows every member (§5.5): one `delete` each, in
201        // one batch. Its handler removes just that member; the last one
202        // removes the leaf. Queued whether or not the leaf goes — closing one
203        // tab of a kept pane needs it just as much.
204        let mut closed = block_ids.clone();
205        closed.extend(gone.iter().cloned());
206        queue_member_deletes(ctx.state, &tab_id, &closed).await;
207    }
208    if let Some((node_id, visible_block_id)) = leaf_to_delete {
209        // Best-effort, as in `delete_block`: the blocks are already gone.
210        if let Err(reason) = ctx
211            .dispatch(Command::LayoutDeleteNode {
212                tab_id: tab_id.clone(),
213                node_id: node_id.clone(),
214                correlation_id: String::new(),
215            })
216            .await
217        {
218            tracing::warn!(
219                tab_id = %tab_id,
220                node_id = %node_id,
221                "[saga] ClosePane: LayoutDeleteNode failed (best-effort): {}",
222                reason
223            );
224        }
225        // One frontend prune for the pane, keyed on its visible block — not
226        // one per member, which is what produced the "could not find leaf
227        // node with blockId" errors when each member sent its own. (Unless
228        // the frontend waited, above: then it still has every member.)
229        if opts.frontend_waits {
230            // already queued per member
231        } else if let Err(reason) = crate::server::service::layout_helpers::queue_source_layout_delete(
232            ctx.state,
233            &tab_id,
234            &visible_block_id,
235        )
236        .await
237        {
238            tracing::warn!(
239                tab_id = %tab_id,
240                block_id = %visible_block_id,
241                "[saga] ClosePane: queue_source_layout_delete failed (best-effort): {}",
242                reason
243            );
244        }
245    }
246
247    Ok(json!({
248        "tab_id": tab_id,
249        "block_ids": block_ids,
250        "leaf_removed": leaf_removed,
251    }))
252}
253
254/// Serialises tab closes within one workspace, from `delete_tab`'s last-tab
255/// pre-check through its `DeleteTab` dispatch (spec §9.5). One lock per
256/// workspace id; entries are tiny and bounded by the number of workspaces.
257pub(crate) async fn workspace_close_lock(workspace_id: &str) -> tokio::sync::OwnedMutexGuard<()> {
258    static LOCKS: std::sync::LazyLock<
259        std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>>,
260    > = std::sync::LazyLock::new(Default::default);
261    let lock = LOCKS
262        .lock()
263        .unwrap()
264        .entry(workspace_id.to_string())
265        .or_default()
266        .clone();
267    lock.lock_owned().await
268}
269
270/// The requested ids that no longer existed when this close began.
271fn gone_before(requested: &[String], present: &[String]) -> Vec<String> {
272    requested.iter().filter(|id| !present.contains(id)).cloned().collect()
273}
274
275/// One frontend `delete` per closed member, in one batch (best-effort: the
276/// blocks are already gone).
277async fn queue_member_deletes(state: &AppState, tab_id: &str, block_ids: &[String]) {
278    if block_ids.is_empty() {
279        return;
280    }
281    let actions = block_ids
282        .iter()
283        .map(|id| crate::server::service::layout_helpers::source_delete_action(id))
284        .collect();
285    if let Err(reason) = crate::server::service::reducer_helpers::queue_layout_actions_via_reducer(state, tab_id, actions).await {
286        tracing::warn!(tab_id = %tab_id, "[saga] ClosePane: queueing frontend deletes failed (best-effort): {}", reason);
287    }
288}
289
290/// `agent:shutdown` — the shutdown log a closing pane shows
291/// (SPEC_AGENT_SELF_QUIT_2026_09_24.md §5.5, §12.1).
292pub(crate) const EVENT_AGENT_SHUTDOWN: &str = "agent:shutdown";
293
294/// One `agent:shutdown` line (`mps_events::AgentShutdown`): its order, step and text, and what
295/// the step adds.
296#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
297#[ts(export, export_to = "../../../frontend/types/rpc/")]
298pub struct ShutdownLineEvent {
299    pub block_id: String,
300    #[ts(type = "number")]
301    pub seq: u64,
302    pub step: String,
303    pub text: String,
304    #[serde(flatten)]
305    pub extra: ShutdownExtra,
306}
307
308/// What a shutdown line can add beyond its step and text. Each is omitted when absent.
309#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, ts_rs::TS)]
310#[ts(export, export_to = "../../../frontend/types/rpc/")]
311pub struct ShutdownExtra {
312    /// `kill`: how long the agent had before it was killed.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    #[ts(optional, type = "number")]
315    pub forced_after_ms: Option<u64>,
316    /// `error`: why the pane couldn't close.
317    #[serde(skip_serializing_if = "Option::is_none")]
318    #[ts(optional)]
319    pub error: Option<String>,
320    /// `container`: a Compose container left running, and that it was left.
321    #[serde(skip_serializing_if = "Option::is_none")]
322    #[ts(optional)]
323    pub container: Option<String>,
324    #[serde(skip_serializing_if = "Option::is_none")]
325    #[ts(optional)]
326    pub outcome: Option<String>,
327    /// `process` / `survivor`: one of the agent's processes, and what became of it.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    #[ts(optional)]
330    pub process: Option<ShutdownProcess>,
331}
332
333#[derive(Debug, Clone, PartialEq, serde::Serialize, ts_rs::TS)]
334#[ts(export, export_to = "../../../frontend/types/rpc/")]
335pub struct ShutdownProcess {
336    pub pid: u32,
337    pub name: String,
338    /// `killed`, `stopped`, `survived` or `escaped`.
339    pub outcome: String,
340}
341
342/// The extra for a line about one of the agent's processes.
343pub(crate) fn process_extra(pid: u32, command: &str, outcome: &str) -> ShutdownExtra {
344    ShutdownExtra { process: Some(ShutdownProcess { pid, name: short_command(command), outcome: outcome.to_string() }), ..Default::default() }
345}
346
347/// Orders the lines: strictly increasing across the process, so per block too.
348static SHUTDOWN_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
349
350/// Display lines stay one short line (spec §12.1).
351const SHUTDOWN_LINE_MAX: usize = 80;
352
353fn clip(s: &str, max: usize) -> String {
354    if s.chars().count() <= max {
355        return s.to_string();
356    }
357    let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
358    out.push('…');
359    out
360}
361
362/// The program and its first arguments — enough to recognise it.
363pub(crate) fn short_command(command: &str) -> String {
364    let command = command.trim();
365    if command.is_empty() {
366        return "process".to_string();
367    }
368    clip(command, 48)
369}
370
371pub(crate) fn process_line(command: &str, pid: u32, killed: bool) -> String {
372    clip(&format!("{} (pid {pid}) — {}", short_command(command), if killed { "killed" } else { "stopped" }), SHUTDOWN_LINE_MAX)
373}
374
375/// The agent-process line for a shutdown outcome: `(step, text, extra)`.
376pub(crate) fn exit_line(
377    label: &str,
378    outcome: &blockcontroller::StopOutcome,
379    elapsed: std::time::Duration,
380) -> (&'static str, String, ShutdownExtra) {
381    use blockcontroller::StopOutcome::*;
382    match outcome {
383        Killed => (
384            "kill",
385            clip(&format!("{label} — killed after {} s", elapsed.as_secs().max(1)), SHUTDOWN_LINE_MAX),
386            ShutdownExtra { forced_after_ms: Some(elapsed.as_millis() as u64), ..Default::default() },
387        ),
388        Exited => ("exit", clip(&format!("{label} — exited"), SHUTDOWN_LINE_MAX), ShutdownExtra::default()),
389        Stopped => ("exit", clip(&format!("{label} — stopped"), SHUTDOWN_LINE_MAX), ShutdownExtra::default()),
390        NotRunning => ("exit", clip(&format!("{label} — was not running"), SHUTDOWN_LINE_MAX), ShutdownExtra::default()),
391    }
392}
393
394/// Publish one `agent:shutdown` line, scoped to the block. Display only: the
395/// saga's own result stays the source of truth.
396pub(crate) fn publish_shutdown(state: &AppState, block_id: &str, step: &str, text: String, extra: ShutdownExtra) {
397    let line = ShutdownLineEvent {
398        block_id: block_id.to_string(),
399        seq: SHUTDOWN_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
400        step: step.to_string(),
401        text,
402        extra,
403    };
404    state.broker.publish_event::<crate::backend::mps_events::AgentShutdown>(&line, vec![format!("block:{block_id}")], 0);
405}
406
407/// A close that failed after its shutdown: the pane stays open showing why.
408pub(crate) fn publish_shutdown_error(state: &AppState, block_id: &str, reason: &str) {
409    publish_shutdown(
410        state,
411        block_id,
412        "error",
413        clip(&format!("couldn't close: {reason}"), SHUTDOWN_LINE_MAX),
414        ShutdownExtra { error: Some(reason.to_string()), ..Default::default() },
415    );
416}
417
418/// Clear `session:active_pid` (so the next boot doesn't read this as an
419/// interrupted session) and mark the local instance row stopped. The session
420/// id itself is left everywhere it is — block meta, instance row, shared
421/// registry — so reopening the agent resumes it (spec §4.2 step 6).
422pub(crate) fn save_final_state(state: &AppState, block_id: &str) {
423    use crate::backend::blockcontroller::session_recovery;
424
425    let has_active_pid = state
426        .mstore
427        .get::<crate::backend::obj::Block>(block_id)
428        .ok()
429        .flatten()
430        .is_some_and(|b| {
431            b.meta
432                .get(session_recovery::META_SESSION_ACTIVE_PID)
433                .is_some_and(|v| !v.is_null())
434        });
435    if has_active_pid {
436        session_recovery::clear_active_pid(&state.mstore, block_id);
437    }
438
439    match state.mstore.instance_get_by_block_id(block_id) {
440        Ok(Some(instance)) => {
441            let now_ms = agentmux_common::time::now_ms();
442            let upd = crate::backend::storage::agents::InstanceUpdate {
443                status: Some(
444                    crate::backend::storage::agents::InstanceStatus::Stopped
445                        .as_str()
446                        .to_string(),
447                ),
448                ended_at: Some(now_ms),
449                ..Default::default()
450            };
451            if let Err(e) = state.mstore.instance_update_partial(&instance.id, &upd) {
452                tracing::warn!(
453                    block_id = %block_id,
454                    instance_id = %instance.id,
455                    error = %e,
456                    "ClosePane: failed to mark instance stopped"
457                );
458            }
459        }
460        Ok(None) => {}
461        Err(e) => {
462            tracing::debug!(block_id = %block_id, error = %e, "ClosePane: instance lookup failed");
463        }
464    }
465}
466
467/// After the saga: purge the block's persisted broker history and lift the
468/// respawn guard. If the block somehow survived (dispatch failed), it keeps
469/// its stopped state and can be closed again.
470pub(crate) async fn finish_close(state: &AppState, block_id: &str) {
471    let gone = !state.srv_state.lock().await.blocks.contains_key(block_id);
472    if gone {
473        state.broker.purge_scope(&format!("block:{}", block_id));
474    }
475    blockcontroller::unmark_closing(block_id);
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::backend::obj::Block;
482    use crate::server::tests::test_state;
483    use agentmux_common::ipc::Event;
484
485    async fn dispatch_apply(state: &AppState, cmd: Command) -> Vec<Event> {
486        let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
487        for ev in &events {
488            crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
489        }
490        events
491    }
492
493    async fn seed_tab(state: &AppState) -> (String, String) {
494        let ws_id = dispatch_apply(state, Command::CreateWorkspace { name: "w".into() })
495            .await
496            .iter()
497            .find_map(|e| match e {
498                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
499                _ => None,
500            })
501            .unwrap();
502        let tab_id = dispatch_apply(
503            state,
504            Command::CreateTab {
505                workspace_id: ws_id.clone(),
506                name: "t".into(),
507            },
508        )
509        .await
510        .iter()
511        .find_map(|e| match e {
512            Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
513            _ => None,
514        })
515        .unwrap();
516        (ws_id, tab_id)
517    }
518
519    async fn seed_block(state: &AppState, tab_id: &str) -> String {
520        dispatch_apply(
521            state,
522            Command::CreateBlock {
523                tab_id: tab_id.to_string(),
524                meta: serde_json::Value::Null,
525            },
526        )
527        .await
528        .iter()
529        .find_map(|e| match e {
530            Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
531            _ => None,
532        })
533        .unwrap()
534    }
535
536    fn stack_leaf(id: &str, stack: &[&str], visible: &str) -> agentmux_common::LayoutNode {
537        agentmux_common::LayoutNode {
538            id: id.into(),
539            data: Some(agentmux_common::LayoutNodeData {
540                block_id: visible.to_string(),
541                block_stack: stack.iter().map(|s| s.to_string()).collect(),
542                active_block_id: visible.to_string(),
543                ..Default::default()
544            }),
545            ..Default::default()
546        }
547    }
548
549    async fn set_tree(state: &AppState, tab_id: &str, tree: agentmux_common::LayoutNode) {
550        dispatch_apply(
551            state,
552            Command::LayoutSetTree {
553                tab_id: tab_id.to_string(),
554                new_tree: Some(tree),
555                correlation_id: String::new(),
556                slices: None,
557            },
558        )
559        .await;
560    }
561
562    fn queued_deletes(state: &AppState, tab_id: &str) -> Vec<String> {
563        let tab = state.mstore.must_get::<crate::backend::obj::Tab>(tab_id).unwrap();
564        let layout = state
565            .mstore
566            .must_get::<crate::backend::obj::LayoutState>(&tab.layoutstate)
567            .unwrap();
568        layout
569            .pendingbackendactions
570            .unwrap_or_default()
571            .into_iter()
572            .filter(|a| a.actiontype == "delete")
573            .map(|a| a.blockid)
574            .collect()
575    }
576
577    /// The incident (spec §2): a pane holding [A, B, C] with A visible. Closing
578    /// it must remove all three blocks, not just A, and prune the leaf once.
579    #[tokio::test]
580    async fn a_waiting_frontend_gets_one_delete_per_member_for_a_whole_pane() {
581        let state = test_state();
582        let (_ws, tab_id) = seed_tab(&state).await;
583        let a = seed_block(&state, &tab_id).await;
584        let b = seed_block(&state, &tab_id).await;
585        set_tree(&state, &tab_id, stack_leaf("leaf", &[&a, &b], &a)).await;
586
587        let out = run_with(&state, vec![a.clone(), b.clone()], CloseOpts { frontend_waits: true }).await.unwrap();
588        assert_eq!(out["leaf_removed"], true);
589        // The frontend still shows both tabs: one `delete` each, the last
590        // taking the pane — never only the visible one (§5.5).
591        assert_eq!(queued_deletes(&state, &tab_id), vec![a.clone(), b.clone()]);
592    }
593
594    #[tokio::test]
595    async fn a_waiting_frontend_is_told_about_one_closed_tab_of_a_kept_pane() {
596        let state = test_state();
597        let (_ws, tab_id) = seed_tab(&state).await;
598        let a = seed_block(&state, &tab_id).await;
599        let b = seed_block(&state, &tab_id).await;
600        set_tree(&state, &tab_id, stack_leaf("leaf", &[&a, &b], &a)).await;
601
602        let out = run_with(&state, vec![a.clone()], CloseOpts { frontend_waits: true }).await.unwrap();
603        assert_eq!(out["leaf_removed"], false);
604        assert!(state.srv_state.lock().await.blocks.contains_key(&b), "sibling survives");
605        assert_eq!(queued_deletes(&state, &tab_id), vec![a.clone()], "the kept pane drops just that tab");
606    }
607
608    /// The shutdown log (§5.5, §12.1): ordered lines scoped to the block,
609    /// ending in `done`. A block with no running controller still says so.
610    #[tokio::test]
611    async fn shutdown_publishes_an_ordered_log_scoped_to_the_block() {
612        let state = test_state();
613        let seen: std::sync::Arc<std::sync::Mutex<Vec<(Vec<String>, serde_json::Value)>>> = Default::default();
614        let sink = seen.clone();
615        state.broker.add_observer(std::sync::Arc::new(move |ev: &crate::backend::mps::MuxEvent| {
616            if ev.event == EVENT_AGENT_SHUTDOWN {
617                sink.lock().unwrap().push((ev.scopes.clone(), ev.data.clone().unwrap_or_default()));
618            }
619        }));
620        let (_ws, tab_id) = seed_tab(&state).await;
621        let a = seed_block(&state, &tab_id).await;
622        set_tree(&state, &tab_id, stack_leaf("leaf", &[&a], &a)).await;
623
624        run(&state, vec![a.clone()]).await.unwrap();
625
626        let seen = seen.lock().unwrap();
627        let steps: Vec<&str> = seen.iter().map(|(_, d)| d["step"].as_str().unwrap()).collect();
628        assert_eq!(steps, vec!["exit", "saved", "done"]);
629        assert_eq!(seen[0].1["text"], "agent — was not running");
630        assert!(seen.iter().all(|(scopes, d)| scopes == &vec![format!("block:{a}")] && d["block_id"] == a.as_str()));
631        let seqs: Vec<u64> = seen.iter().map(|(_, d)| d["seq"].as_u64().unwrap()).collect();
632        assert!(seqs.windows(2).all(|w| w[0] < w[1]), "strictly increasing: {seqs:?}");
633    }
634
635    #[test]
636    fn shutdown_lines_stay_short_and_say_what_happened() {
637        let (step, text, extra) =
638            exit_line("Camper", &blockcontroller::StopOutcome::Killed, std::time::Duration::from_millis(5_200));
639        assert_eq!((step, text.as_str()), ("kill", "Camper — killed after 5 s"));
640        assert_eq!(extra.forced_after_ms, Some(5_200));
641        assert_eq!(exit_line("Camper", &blockcontroller::StopOutcome::Exited, Default::default()).1, "Camper — exited");
642        assert_eq!(process_line("node dev-server.js", 4312, false), "node dev-server.js (pid 4312) — stopped");
643        let long = process_line(&"x".repeat(200), 1, true);
644        assert!(long.chars().count() <= SHUTDOWN_LINE_MAX && long.ends_with("— killed"), "{long}");
645        assert_eq!(short_command("   "), "process");
646    }
647
648    /// ReAgent P1 on #3784: a waiting frontend is told to drop members that
649    /// were already gone, not left showing them with nothing behind.
650    #[tokio::test]
651    async fn a_waiting_frontend_also_drops_members_that_were_already_gone() {
652        let state = test_state();
653        let (_ws, tab_id) = seed_tab(&state).await;
654        let a = seed_block(&state, &tab_id).await;
655        let b = seed_block(&state, &tab_id).await;
656        set_tree(&state, &tab_id, stack_leaf("leaf", &[&a, &b], &a)).await;
657        run(&state, vec![b.clone()]).await.unwrap(); // b closed elsewhere first
658        let before = queued_deletes(&state, &tab_id).len();
659
660        run_with(&state, vec![a.clone(), b.clone()], CloseOpts { frontend_waits: true }).await.unwrap();
661        let queued = queued_deletes(&state, &tab_id);
662        assert_eq!(&queued[before..], &[a.clone(), b.clone()], "both leave the waiting frontend");
663    }
664
665    #[test]
666    fn gone_before_lists_requested_ids_missing_at_the_start() {
667        let ids = vec!["a".to_string(), "b".to_string(), "c".to_string()];
668        assert_eq!(gone_before(&ids, &["a".to_string(), "c".to_string()]), vec!["b".to_string()]);
669        assert!(gone_before(&ids, &ids).is_empty());
670    }
671
672    #[tokio::test]
673    async fn closing_a_stacked_pane_removes_every_member() {
674        let state = test_state();
675        let (_ws, tab_id) = seed_tab(&state).await;
676        let a = seed_block(&state, &tab_id).await;
677        let b = seed_block(&state, &tab_id).await;
678        let c = seed_block(&state, &tab_id).await;
679        set_tree(&state, &tab_id, stack_leaf("leaf", &[&a, &b, &c], &a)).await;
680
681        let out = run(&state, vec![a.clone(), b.clone(), c.clone()]).await.unwrap();
682        assert_eq!(out["leaf_removed"], true);
683
684        {
685            let s = state.srv_state.lock().await;
686            for id in [&a, &b, &c] {
687                assert!(!s.blocks.contains_key(id), "block {id} must be deleted");
688            }
689            assert!(s.tabs[&tab_id].block_ids.is_empty(), "no member left in tab.blockids");
690            assert!(s.tabs[&tab_id].rootnode.is_none(), "leaf pruned");
691        }
692        for id in [&a, &b, &c] {
693            assert!(state.mstore.get::<Block>(id).unwrap().is_none());
694            assert!(!blockcontroller::is_closing(id), "respawn guard lifted after close");
695        }
696        assert_eq!(queued_deletes(&state, &tab_id), vec![a.clone()], "one frontend prune, for the visible block");
697    }
698
699    /// Closing one tab of a stack (the tab's own ×) must leave the pane and its
700    /// other tabs alone — even when the closed tab is the visible one in the
701    /// backend's copy of the tree.
702    #[tokio::test]
703    async fn closing_one_member_keeps_the_pane_and_its_siblings() {
704        let state = test_state();
705        let (_ws, tab_id) = seed_tab(&state).await;
706        let a = seed_block(&state, &tab_id).await;
707        let b = seed_block(&state, &tab_id).await;
708        set_tree(&state, &tab_id, stack_leaf("leaf", &[&a, &b], &a)).await;
709
710        let out = run(&state, vec![a.clone()]).await.unwrap();
711        assert_eq!(out["leaf_removed"], false);
712
713        let s = state.srv_state.lock().await;
714        assert!(!s.blocks.contains_key(&a));
715        assert!(s.blocks.contains_key(&b), "sibling survives");
716        assert_eq!(s.tabs[&tab_id].block_ids, vec![b.clone()]);
717        assert!(s.tabs[&tab_id].rootnode.is_some(), "pane survives");
718        drop(s);
719        assert!(queued_deletes(&state, &tab_id).is_empty());
720    }
721
722    fn sample_agent(id: &str) -> crate::backend::storage::agents::AgentDefinition {
723        crate::backend::storage::agents::AgentDefinition {
724            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
725            id: id.to_string(),
726            slug: id.to_string(),
727            name: id.to_string(),
728            icon: "✦".to_string(),
729            provider: "claude".to_string(),
730            description: String::new(),
731            working_directory: String::new(),
732            shell: String::new(),
733            provider_flags: String::new(),
734            auto_start: 0,
735            restart_on_crash: 0,
736            idle_timeout_minutes: 0,
737            created_at: 0,
738            agent_type: "host".to_string(),
739            environment: String::new(),
740            agent_bus_id: String::new(),
741            is_seeded: 0,
742            accounts: String::new(),
743            parent_id: String::new(),
744            branch_label: String::new(),
745            updated_at: 0,
746            user_hidden: 0,
747            container_image: String::new(),
748            container_volumes: "[]".to_string(),
749            container_name: String::new(),
750            use_ambient_login: 0,
751            auto_continue_enabled: 0,
752            model_vendor_base_url: String::new(),
753            memory_id: String::new(),
754        }
755    }
756
757    /// Ordering (spec §4.2): final state is saved while the block still
758    /// exists — `session:active_pid` cleared and the instance row marked
759    /// stopped — and the session id is kept.
760    #[tokio::test]
761    async fn final_state_is_saved_before_the_block_is_deleted() {
762        use crate::backend::storage::agents::{AgentInstance, InstanceStatus};
763        let state = test_state();
764        let (_ws, tab_id) = seed_tab(&state).await;
765        let a = seed_block(&state, &tab_id).await;
766
767        let mut meta = crate::backend::obj::MetaMapType::new();
768        meta.insert(
769            crate::backend::blockcontroller::session_recovery::META_SESSION_ACTIVE_PID.to_string(),
770            serde_json::json!(4242),
771        );
772        crate::server::service::update_object_meta(&state.mstore, &format!("block:{a}"), &meta).unwrap();
773
774        let mut def = sample_agent("def-close");
775        state.mstore.agent_def_insert(&mut def).unwrap();
776        state
777            .mstore
778            .instance_create(&AgentInstance {
779                id: "def-close".to_string(),
780                definition_id: "def-close".to_string(),
781                parent_instance_id: String::new(),
782                block_id: a.clone(),
783                session_id: "sess-keep".to_string(),
784                status: InstanceStatus::Running.as_str().to_string(),
785                github_context: String::new(),
786                started_at: 1000,
787                ended_at: 0,
788                created_at: 1000,
789                identity_id: String::new(),
790                memory_id: String::new(),
791                instance_name: String::new(),
792                working_directory: String::new(),
793                display_hidden: false,
794            })
795            .unwrap();
796
797        // Run only the pre-delete half and observe the block while it exists.
798        crate::sagas::agent_teardown::run_many(&state, std::slice::from_ref(&a), crate::sagas::agent_teardown::Policy::close()).await;
799        let block = state.mstore.get::<Block>(&a).unwrap().expect("block still exists");
800        assert!(
801            block
802                .meta
803                .get(crate::backend::blockcontroller::session_recovery::META_SESSION_ACTIVE_PID)
804                .is_none_or(|v| v.is_null()),
805            "active pid cleared before delete"
806        );
807        let inst = state.mstore.instance_get_by_block_id(&a).unwrap().unwrap();
808        assert_eq!(inst.status, "stopped");
809        assert!(inst.ended_at > 0);
810        assert_eq!(inst.session_id, "sess-keep", "session id kept for resume");
811        assert!(blockcontroller::is_closing(&a), "respawn refused mid-close");
812        finish_close(&state, &a).await;
813    }
814
815    #[tokio::test]
816    async fn rejects_blocks_from_different_tabs() {
817        let state = test_state();
818        let (ws, tab1) = seed_tab(&state).await;
819        let tab2 = dispatch_apply(
820            &state,
821            Command::CreateTab {
822                workspace_id: ws,
823                name: "t2".into(),
824            },
825        )
826        .await
827        .iter()
828        .find_map(|e| match e {
829            Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
830            _ => None,
831        })
832        .unwrap();
833        let a = seed_block(&state, &tab1).await;
834        let b = seed_block(&state, &tab2).await;
835        let err = run(&state, vec![a.clone(), b.clone()]).await.unwrap_err();
836        assert!(err.contains("span tabs"), "got: {err}");
837        let s = state.srv_state.lock().await;
838        assert!(s.blocks.contains_key(&a) && s.blocks.contains_key(&b), "nothing closed");
839    }
840
841    #[tokio::test]
842    async fn skips_ids_that_are_already_gone() {
843        let state = test_state();
844        let (_ws, tab_id) = seed_tab(&state).await;
845        let a = seed_block(&state, &tab_id).await;
846        let out = run(&state, vec!["ghost".into(), a.clone()]).await.unwrap();
847        assert_eq!(out["block_ids"], json!([a]));
848        let err = run(&state, vec!["ghost".into()]).await.unwrap_err();
849        assert!(err.contains("block not found"), "got: {err}");
850    }
851
852    /// A controller whose graceful shutdown takes `delay`.
853    struct SlowStop {
854        block_id: String,
855        delay: std::time::Duration,
856    }
857
858    impl blockcontroller::Controller for SlowStop {
859        fn start(&self, _: crate::backend::obj::MetaMapType, _: Option<Value>, _: bool) -> Result<(), String> {
860            Ok(())
861        }
862        fn stop(&self, _: bool, _: &str) -> Result<(), String> {
863            Ok(())
864        }
865        fn shutdown(&self, _deadline: std::time::Instant) -> blockcontroller::ShutdownFuture {
866            let delay = self.delay;
867            Box::pin(async move {
868                tokio::time::sleep(delay).await;
869                blockcontroller::StopOutcome::Exited
870            })
871        }
872        fn get_runtime_status(&self) -> blockcontroller::BlockControllerRuntimeStatus {
873            blockcontroller::BlockControllerRuntimeStatus {
874                blockid: self.block_id.clone(),
875                ..Default::default()
876            }
877        }
878        fn send_input(&self, _: blockcontroller::BlockInputUnion, _: Option<u64>) -> Result<(), String> {
879            Ok(())
880        }
881        fn controller_type(&self) -> &str {
882            "slow-stop-test"
883        }
884        fn block_id(&self) -> &str {
885            &self.block_id
886        }
887        fn as_any(&self) -> &dyn std::any::Any {
888            self
889        }
890    }
891
892    fn register_slow(block_id: &str, delay_ms: u64) {
893        blockcontroller::register_controller(
894            block_id,
895            std::sync::Arc::new(SlowStop {
896                block_id: block_id.to_string(),
897                delay: std::time::Duration::from_millis(delay_ms),
898            }),
899        );
900    }
901
902    /// Spec §9.2: agents in one close stop concurrently under one deadline.
903    /// Three that each take 1s finish in about 1s, not 3s.
904    #[tokio::test]
905    async fn agents_in_one_close_stop_concurrently() {
906        let state = test_state();
907        let (_ws, tab_id) = seed_tab(&state).await;
908        let mut ids = Vec::new();
909        for _ in 0..3 {
910            let id = seed_block(&state, &tab_id).await;
911            register_slow(&id, 1000);
912            ids.push(id);
913        }
914        let started = std::time::Instant::now();
915        crate::sagas::agent_teardown::run_many(&state, &ids, crate::sagas::agent_teardown::Policy::close()).await;
916        let elapsed = started.elapsed();
917        assert!(elapsed < std::time::Duration::from_millis(2500), "sequential? took {elapsed:?}");
918        for id in &ids {
919            assert!(blockcontroller::get_controller(id).is_none(), "controller removed");
920            finish_close(&state, id).await;
921        }
922    }
923
924    /// A teardown finishes even when its caller is dropped mid-way: the
925    /// host gives `CloseWindow` 2 s and then hangs up, which drops the
926    /// handler, and a graceful close takes longer than that.
927    #[tokio::test]
928    async fn a_teardown_outlives_a_dropped_caller() {
929        let state = test_state();
930        let (_ws, tab_id) = seed_tab(&state).await;
931        let a = seed_block(&state, &tab_id).await;
932        register_slow(&a, 500);
933        let caller = {
934            let (st, a) = (state.clone(), a.clone());
935            tokio::spawn(async move {
936                crate::sagas::agent_teardown::run_many(&st, std::slice::from_ref(&a), crate::sagas::agent_teardown::Policy::close())
937                    .await;
938            })
939        };
940        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
941        caller.abort();
942        let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
943        while !(blockcontroller::is_closing(&a) && !blockcontroller::closing_blocks_still_running().contains(&a)) {
944            assert!(std::time::Instant::now() < until, "the teardown was cut short with its caller");
945            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
946        }
947        assert!(blockcontroller::get_controller(&a).is_none());
948        finish_close(&state, &a).await;
949    }
950
951    /// Agent teardown spec §6.6: a pane close takes its PtyShell drawers
952    /// (sub-blocks) server-side, whether or not the pane is mounted to send
953    /// `deletesubblock`.
954    #[tokio::test]
955    async fn a_pane_close_takes_its_drawers() {
956        let state = test_state();
957        let (_ws, tab_id) = seed_tab(&state).await;
958        let a = seed_block(&state, &tab_id).await;
959        let drawer = format!("drawer-of-{a}");
960        let mut block = state.mstore.must_get::<crate::backend::obj::Block>(&a).unwrap();
961        block.subblockids = Some(vec![drawer.clone()]);
962        state.mstore.update(&mut block).unwrap();
963        register_slow(&a, 0);
964        register_slow(&drawer, 0);
965
966        crate::sagas::agent_teardown::run_many(&state, std::slice::from_ref(&a), crate::sagas::agent_teardown::Policy::close()).await;
967        assert!(blockcontroller::get_controller(&drawer).is_none(), "the drawer's controller is gone");
968        assert!(blockcontroller::get_controller(&a).is_none());
969        assert!(!blockcontroller::is_closing(&drawer), "a drawer isn't left unable to respawn");
970        finish_close(&state, &a).await;
971    }
972
973    /// Agent teardown spec §7.4: Stop keeps the agent's background tasks
974    /// unless asked, and stops them (tree and all) when asked. The block and
975    /// its record stay either way.
976    #[tokio::test]
977    async fn stop_keeps_background_tasks_unless_asked() {
978        let state = test_state();
979        let (_ws, tab_id) = seed_tab(&state).await;
980        let a = seed_block(&state, &tab_id).await;
981        // A stand-in dev server, its own process group like bashwrap's.
982        #[cfg(windows)]
983        let mut dev = std::process::Command::new("cmd").args(["/C", "ping -n 30 127.0.0.1 > nul"]).spawn().unwrap();
984        #[cfg(unix)]
985        let mut dev = {
986            use std::os::unix::process::CommandExt as _;
987            std::process::Command::new("sh").args(["-c", "sleep 30"]).process_group(0).spawn().unwrap()
988        };
989        let now = agentmux_common::time::now_ms() as i64;
990        state.mstore.background_task_observe("bg-dev", &a, "task dev", now, now).unwrap();
991        state.mstore.background_task_set_pid("bg-dev", dev.id() as i64).unwrap();
992
993        register_slow(&a, 0);
994        let kept = crate::sagas::agent_teardown::stop(&state, &a, true, Some(false)).await.unwrap();
995        assert_eq!(kept.stopped_background, 0);
996        assert!(dev.try_wait().unwrap().is_none(), "kept running");
997
998        register_slow(&a, 0);
999        let stopped = crate::sagas::agent_teardown::stop(&state, &a, true, Some(true)).await.unwrap();
1000        assert_eq!(stopped.stopped_background, 1);
1001        let until = std::time::Instant::now() + std::time::Duration::from_secs(10);
1002        while dev.try_wait().unwrap().is_none() && std::time::Instant::now() < until {
1003            std::thread::sleep(std::time::Duration::from_millis(50));
1004        }
1005        assert!(dev.try_wait().unwrap().is_some(), "the background task was stopped");
1006        assert!(state.srv_state.lock().await.blocks.contains_key(&a), "Stop keeps the block");
1007        assert!(
1008            crate::sagas::agent_teardown::stop(&state, "no-such-block", true, None).await.unwrap_err().starts_with("NOT_RUNNING"),
1009        );
1010        blockcontroller::delete_controller(&a);
1011    }
1012
1013    /// A controller whose stop fails.
1014    struct FailingStop(String);
1015
1016    impl blockcontroller::Controller for FailingStop {
1017        fn start(&self, _: crate::backend::obj::MetaMapType, _: Option<Value>, _: bool) -> Result<(), String> {
1018            Ok(())
1019        }
1020        fn stop(&self, _: bool, _: &str) -> Result<(), String> {
1021            Err("stop_subprocess: no such process".into())
1022        }
1023        fn get_runtime_status(&self) -> blockcontroller::BlockControllerRuntimeStatus {
1024            blockcontroller::BlockControllerRuntimeStatus { blockid: self.0.clone(), ..Default::default() }
1025        }
1026        fn send_input(&self, _: blockcontroller::BlockInputUnion, _: Option<u64>) -> Result<(), String> {
1027            Ok(())
1028        }
1029        fn controller_type(&self) -> &str {
1030            "failing-stop-test"
1031        }
1032        fn block_id(&self) -> &str {
1033            &self.0
1034        }
1035        fn as_any(&self) -> &dyn std::any::Any {
1036            self
1037        }
1038    }
1039
1040    /// ReAgent P1 on #4174: a Stop whose CLI stop fails reports the error and
1041    /// touches nothing else; the agent is still running, so its background
1042    /// tasks (and claims) stay.
1043    #[tokio::test]
1044    async fn a_failed_stop_is_an_error_and_keeps_everything() {
1045        let state = test_state();
1046        let (_ws, tab_id) = seed_tab(&state).await;
1047        let a = seed_block(&state, &tab_id).await;
1048        #[cfg(windows)]
1049        let mut dev = std::process::Command::new("cmd").args(["/C", "ping -n 30 127.0.0.1 > nul"]).spawn().unwrap();
1050        #[cfg(unix)]
1051        let mut dev = {
1052            use std::os::unix::process::CommandExt as _;
1053            std::process::Command::new("sh").args(["-c", "sleep 30"]).process_group(0).spawn().unwrap()
1054        };
1055        let now = agentmux_common::time::now_ms() as i64;
1056        state.mstore.background_task_observe("bg-keep", &a, "task dev", now, now).unwrap();
1057        state.mstore.background_task_set_pid("bg-keep", dev.id() as i64).unwrap();
1058        blockcontroller::register_controller(&a, std::sync::Arc::new(FailingStop(a.clone())));
1059
1060        let err = crate::sagas::agent_teardown::stop(&state, &a, true, Some(true)).await.unwrap_err();
1061        assert!(err.contains("no such process"), "the stop's own error: {err}");
1062        std::thread::sleep(std::time::Duration::from_millis(300));
1063        assert!(dev.try_wait().unwrap().is_none(), "the background task was not stopped");
1064
1065        blockcontroller::take_controller(&a);
1066        let _ = dev.kill();
1067        let _ = dev.wait();
1068    }
1069
1070    /// App exit lists every controller; a drawer is left to its parent's
1071    /// cascade, not torn down twice at once (ReAgent P2 on #4164).
1072    #[tokio::test]
1073    async fn app_exit_leaves_drawers_to_their_parent() {
1074        let state = test_state();
1075        let (_ws, tab_id) = seed_tab(&state).await;
1076        let a = seed_block(&state, &tab_id).await;
1077        let b = seed_block(&state, &tab_id).await;
1078        let drawer = format!("drawer-of-{a}");
1079        let mut block = state.mstore.must_get::<crate::backend::obj::Block>(&a).unwrap();
1080        block.subblockids = Some(vec![drawer.clone()]);
1081        state.mstore.update(&mut block).unwrap();
1082        let ids = crate::sagas::agent_teardown::without_sub_blocks(&state, vec![a.clone(), drawer, b.clone()]);
1083        assert_eq!(ids, vec![a, b]);
1084    }
1085
1086    /// Spec §9.5: closing the last window tab is refused BEFORE any agent in
1087    /// it is stopped — otherwise a refused close leaves a tab of stopped
1088    /// agents.
1089    #[tokio::test]
1090    async fn last_tab_close_is_refused_without_stopping_its_agents() {
1091        let state = test_state();
1092        let (ws, tab_id) = seed_tab(&state).await;
1093        let a = seed_block(&state, &tab_id).await;
1094        register_slow(&a, 0);
1095
1096        let err = crate::sagas::delete_tab::run(&state, ws, tab_id).await.unwrap_err();
1097        assert!(err.contains("last tab"), "got: {err}");
1098        assert!(blockcontroller::get_controller(&a).is_some(), "agent must not be stopped");
1099        assert!(!blockcontroller::is_closing(&a));
1100        blockcontroller::delete_controller(&a);
1101    }
1102
1103    /// Closing a window tab stops its agents (through the shared shutdown)
1104    /// and deletes the tab.
1105    #[tokio::test]
1106    async fn window_tab_close_stops_its_agents() {
1107        let state = test_state();
1108        let (ws, tab1) = seed_tab(&state).await;
1109        let tab2 = dispatch_apply(
1110            &state,
1111            Command::CreateTab {
1112                workspace_id: ws.clone(),
1113                name: "t2".into(),
1114            },
1115        )
1116        .await
1117        .iter()
1118        .find_map(|e| match e {
1119            Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
1120            _ => None,
1121        })
1122        .unwrap();
1123        let a = seed_block(&state, &tab2).await;
1124        let b = seed_block(&state, &tab2).await;
1125        register_slow(&a, 300);
1126        register_slow(&b, 300);
1127
1128        crate::sagas::delete_tab::run(&state, ws, tab2.clone()).await.unwrap();
1129
1130        assert!(blockcontroller::get_controller(&a).is_none());
1131        assert!(blockcontroller::get_controller(&b).is_none());
1132        assert!(!blockcontroller::is_closing(&a) && !blockcontroller::is_closing(&b));
1133        let s = state.srv_state.lock().await;
1134        assert!(!s.tabs.contains_key(&tab2));
1135        assert!(s.tabs.contains_key(&tab1));
1136    }
1137
1138    /// A reopen racing a close waits for the old process instead of calling
1139    /// it gone the moment its controller leaves the registry (spec §9.6).
1140    #[tokio::test]
1141    async fn waiting_on_a_closing_block_returns_once_it_has_stopped() {
1142        let id = format!("closing-wait-{}", uuid::Uuid::new_v4());
1143        blockcontroller::mark_closing(&id);
1144        let waiter_id = id.clone();
1145        let waiter = tokio::spawn(async move {
1146            let started = std::time::Instant::now();
1147            blockcontroller::wait_closing_stopped(&waiter_id, std::time::Duration::from_secs(5)).await;
1148            started.elapsed()
1149        });
1150        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1151        blockcontroller::mark_closing_stopped(&id);
1152        let waited = waiter.await.unwrap();
1153        assert!(waited >= std::time::Duration::from_millis(250), "returned early: {waited:?}");
1154        assert!(waited < std::time::Duration::from_secs(2), "missed the signal: {waited:?}");
1155        blockcontroller::unmark_closing(&id);
1156    }
1157
1158    #[test]
1159    fn closing_guard_blocks_resync() {
1160        let block = Block {
1161            oid: "closing-guard-blk".to_string(),
1162            parentoref: String::new(),
1163            version: 1,
1164            runtimeopts: None,
1165            stickers: None,
1166            meta: {
1167                let mut m = crate::backend::obj::MetaMapType::new();
1168                m.insert("controller".to_string(), json!("shell"));
1169                m
1170            },
1171            subblockids: None,
1172        };
1173        blockcontroller::mark_closing(&block.oid);
1174        let err = blockcontroller::resync_controller(
1175            &block, "tab-1", None, false, true, None, None, None, None, None, None, None,
1176            std::sync::Arc::from("test-boot"), "test-key", None,
1177        )
1178        .unwrap_err();
1179        blockcontroller::unmark_closing(&block.oid);
1180        assert!(err.contains("is closing"), "got: {err}");
1181        assert!(blockcontroller::get_controller(&block.oid).is_none());
1182    }
1183}