1use std::time::Duration;
4
5use anyhow::{Context, Result, bail, ensure};
6
7use crate::hel_session_manager::{SessionManagerControl, new_command_id};
8use hel::hel_state::{CheckpointMetadata, SessionRecord, SessionState};
9use hel::hel_targets::{self, CommandExecutor, ProcessExecutor};
10use hel::hel_worker::{RelayCommand, RelayExecutionState};
11
12use super::backend::backend_locator;
13use super::checkpoint::{
14 CheckpointExportPolicy, LatchExclusivity, prune_replaced_checkpoint,
15 release_projection_behind_checkpoint, verify_installed_checkpoint_gate, wait_for_relay_closed,
16};
17use super::provisioning::retire_git_broker;
18use super::worktree::{cleanup_managed_worktree, retire_managed_worktree};
19use super::{Controller, now, persist_session_record_transition_or_restore};
20
21impl Controller {
22 pub async fn close_session(&mut self, session_id: &str) -> Result<()> {
25 self.close_session_controlled(session_id, &ProcessExecutor)
26 .await
27 }
28
29 pub async fn close_session_controlled(
30 &mut self,
31 session_id: &str,
32 executor: &(impl CommandExecutor + Sync),
33 ) -> Result<()> {
34 if self
35 .close_session_controlled_with_manager(session_id, executor, None, None)
36 .await?
37 {
38 self.cleanup_stopped_target(session_id, executor)?;
39 }
40 Ok(())
41 }
42
43 pub async fn close_session_managed_controlled(
44 &mut self,
45 session_id: &str,
46 executor: &(impl CommandExecutor + Sync),
47 manager: &SessionManagerControl,
48 ) -> Result<bool> {
49 self.close_session_controlled_with_manager(session_id, executor, Some(manager), None)
50 .await
51 }
52
53 pub(super) async fn close_session_for_move(
54 &mut self,
55 session_id: &str,
56 executor: &(impl CommandExecutor + Sync),
57 manager: &SessionManagerControl,
58 operation: &mut hel::hel_state::MoveOperation,
59 preparation: Option<&hel::hel_state::MovePreparation>,
60 ) -> Result<bool> {
61 self.close_session_controlled_with_manager(
62 session_id,
63 executor,
64 Some(manager),
65 Some((operation, preparation)),
66 )
67 .await
68 }
69
70 async fn close_session_controlled_with_manager(
71 &mut self,
72 session_id: &str,
73 executor: &(impl CommandExecutor + Sync),
74 manager: Option<&SessionManagerControl>,
75 move_intent: Option<(
76 &mut hel::hel_state::MoveOperation,
77 Option<&hel::hel_state::MovePreparation>,
78 )>,
79 ) -> Result<bool> {
80 let previous = self
81 .state
82 .sessions
83 .get(session_id)
84 .with_context(|| format!("unknown session {session_id}"))?
85 .clone();
86 let record = self.state.sessions.get_mut(session_id).unwrap();
87 apply_close_checkpoint_started(record, now());
91 self.persist_session_transition_or_restore(
92 session_id,
93 &previous,
94 "persist closing state before checkpointing the session",
95 )?;
96
97 let mut latched = match self
100 .checkpoint_session_latched(
101 session_id,
102 executor,
103 manager,
104 LatchExclusivity::HoldThroughClose,
105 CheckpointExportPolicy::ReuseUnchangedArchive,
106 )
107 .await
108 {
109 Ok(latched) => latched,
110 Err(error) => {
111 let record = self.state.sessions.get_mut(session_id).unwrap();
112 record.state = previous.state;
113 record.updated_at = now();
114 record.last_checkpoint_error = Some(format!("{error:#}"));
115 return Err(
116 self.persist_failed_checkpoint_state_or_restore(session_id, &previous, error)
117 );
118 }
119 };
120
121 let artifact = latched.artifact.clone();
122 let record = self.state.sessions.get_mut(session_id).unwrap();
123 record.state = SessionState::Closing;
124 record.native_session_id = Some(artifact.native_session_id.clone());
125 record.checkpoint = Some(artifact.metadata.clone());
126 record.updated_at = now();
127 record.last_error = None;
128 record.last_checkpoint_error = None;
129 self.persist_checkpoint_transition_or_restore(
130 session_id,
131 &previous,
132 "persist verified checkpoint and closing state before sealing the relay",
133 )?;
134 if let Some((operation, preparation)) = move_intent {
135 if let Err(error) = self.validate_move_checkpoint(operation, preparation, executor) {
138 let record = self.state.sessions.get_mut(session_id).unwrap();
139 record.state = previous.state;
140 record.last_error = Some(format!("{error:#}"));
141 self.persist_session_transition_or_restore(
142 session_id,
143 &previous,
144 "restore source after move preflight failure",
145 )?;
146 return Err(error);
147 }
148 operation.checkpoint = Some(artifact.metadata.clone());
149 operation.updated_at = now();
150 hel::hel_database::save_move_operation(operation)?;
151 }
152 prune_replaced_checkpoint(previous.checkpoint.as_ref(), &artifact.metadata);
153 release_projection_behind_checkpoint(session_id, &artifact.metadata);
156
157 let close_command_id = new_command_id("close")?;
158 let barrier_command_id = latched.barrier_command_id.clone();
159 if let Err(error) = latched
160 .relay
161 .connection_mut()
162 .submit(
163 close_command_id,
164 RelayCommand::Close {
165 barrier_command_id: barrier_command_id.clone(),
166 expected: latched.cursor.clone(),
167 },
168 )
169 .await
170 {
171 self.record_interrupted_close(session_id, &error)?;
172 return Err(error.context("seal verified checkpoint for close"));
173 }
174 if let Err(error) = latched
175 .relay
176 .connection_mut()
177 .submit(
178 new_command_id("checkpoint-complete")?,
179 RelayCommand::CompleteCheckpoint { barrier_command_id },
180 )
181 .await
182 {
183 self.record_interrupted_close(session_id, &error)?;
184 return Err(error.context("release verified close checkpoint"));
185 }
186 if let Err(error) = wait_for_relay_closed(latched.relay.connection_mut()).await {
187 self.record_interrupted_close(session_id, &error)?;
188 return Err(error);
189 }
190 latched.relay.release();
191
192 match self.destroy_after_verified_checkpoint(session_id, &artifact.metadata, executor) {
193 Ok(deferred) => Ok(deferred),
194 Err(error) => {
195 self.record_interrupted_close(session_id, &error)?;
196 Err(error)
197 }
198 }
199 }
200
201 pub async fn recover_interrupted_close_managed(
207 &mut self,
208 session_id: &str,
209 executor: &(impl CommandExecutor + Sync),
210 manager: &SessionManagerControl,
211 ) -> Result<bool> {
212 let (state, verified) = {
213 let session = self
214 .state
215 .sessions
216 .get(session_id)
217 .with_context(|| format!("unknown session {session_id}"))?;
218 ensure!(
219 matches!(
220 session.state,
221 SessionState::Closing | SessionState::Destroying
222 ),
223 "session {session_id} has no interrupted close to recover"
224 );
225 (session.state, session.checkpoint.clone())
226 };
227 if state == SessionState::Destroying {
228 let verified = verified.context("destroying session has no verified checkpoint")?;
229 return self.destroy_after_verified_checkpoint(session_id, &verified, executor);
230 }
231 ensure!(
232 state == SessionState::Closing,
233 "session {session_id} has no relay close to recover"
234 );
235 let handle = manager
236 .wait_for_session(session_id, Duration::from_secs(5))
237 .await?;
238 let mut lease = handle.lease_connection().await?;
239 let execution = lease.connection_mut().sync().await?.operational.execution;
240 match execution {
241 RelayExecutionState::Closed => {}
242 RelayExecutionState::Closing => {
243 wait_for_relay_closed(lease.connection_mut()).await?;
244 }
245 RelayExecutionState::Idle | RelayExecutionState::Running => {
246 lease.release();
247 return self
248 .close_session_controlled_with_manager(
249 session_id,
250 executor,
251 Some(manager),
252 None,
253 )
254 .await;
255 }
256 }
257 lease.release();
258 let verified = verified.context("closed relay has no verified checkpoint")?;
259 self.destroy_after_verified_checkpoint(session_id, &verified, executor)
260 }
261
262 fn record_interrupted_close(&mut self, session_id: &str, error: &anyhow::Error) -> Result<()> {
263 let record = self.state.sessions.get_mut(session_id).unwrap();
264 apply_interrupted_close_error(record, error, &now());
265 self.persist_session_state(session_id)
266 }
267
268 fn destroy_after_verified_checkpoint(
271 &mut self,
272 session_id: &str,
273 verified: &CheckpointMetadata,
274 executor: &impl CommandExecutor,
275 ) -> Result<bool> {
276 self.destroy_after_verified_checkpoint_with(
277 session_id,
278 verified,
279 executor,
280 hel::hel_database::save_lifecycle_session,
281 )
282 }
283
284 fn destroy_after_verified_checkpoint_with(
285 &mut self,
286 session_id: &str,
287 verified: &CheckpointMetadata,
288 executor: &impl CommandExecutor,
289 persist: impl Fn(&SessionRecord) -> Result<()>,
290 ) -> Result<bool> {
291 let session = self
292 .state
293 .sessions
294 .get(session_id)
295 .with_context(|| format!("unknown session {session_id}"))?
296 .clone();
297 ensure!(
298 matches!(
299 session.state,
300 SessionState::Closing | SessionState::Destroying
301 ),
302 "refusing to destroy session {session_id}: it is not closing or destroying"
303 );
304 ensure!(
305 session.checkpoint.as_ref() == Some(verified),
306 "refusing to destroy session {session_id}: verified checkpoint gate is stale"
307 );
308 if session.state == SessionState::Closing {
309 let record = self.state.sessions.get_mut(session_id).unwrap();
310 record.state = SessionState::Destroying;
311 record.updated_at = now();
312 record.last_error = None;
313 persist_session_record_transition_or_restore(
314 &mut self.state,
315 session_id,
316 &session,
317 "persist destroying state before target cleanup",
318 &persist,
319 )?;
320 }
321
322 let destroying = self
323 .state
324 .sessions
325 .get(session_id)
326 .expect("destroying session disappeared")
327 .clone();
328 verify_installed_checkpoint_gate(session_id, verified)?;
329 if let Err(error) = hel::hel_database::lose_reviewer_continuity(session_id) {
334 tracing::warn!(
335 session_id,
336 error = format!("{error:#}"),
337 "could not record that the second-opinion conversation ends with this target"
338 );
339 }
340 retire_git_broker(session_id).context("stop the session's local Git broker")?;
344 let locator = destroying
345 .target
346 .as_ref()
347 .context("session has no target")?;
348 let backend = backend_locator(locator, &destroying, &self.config)?;
349 let deferred = if let Some(plan) = hel_targets::quiesce_plan(&backend, session_id)? {
350 plan.execute(executor)?;
351 true
352 } else {
353 execute_target_cleanup(&backend, session_id, executor)?;
354 false
355 };
356 if let Some(worktree) = &destroying.managed_worktree {
357 retire_managed_worktree(executor, worktree)
358 .context("retire managed raw-session worktree after verified close")?;
359 }
360 let record = self.state.sessions.get_mut(session_id).unwrap();
361 record.state = SessionState::Stopped;
362 if !deferred {
363 record.target = None;
364 }
365 record.updated_at = now();
366 record.last_error = None;
367 persist_session_record_transition_or_restore(
368 &mut self.state,
369 session_id,
370 &destroying,
371 "persist stopped state after target cleanup",
372 &persist,
373 )?;
374 Ok(deferred)
375 }
376
377 pub fn cleanup_stopped_target(
381 &mut self,
382 session_id: &str,
383 executor: &impl CommandExecutor,
384 ) -> Result<()> {
385 self.cleanup_stopped_target_with(
386 session_id,
387 executor,
388 hel::hel_database::save_lifecycle_session,
389 )
390 }
391
392 fn cleanup_stopped_target_with(
393 &mut self,
394 session_id: &str,
395 executor: &impl CommandExecutor,
396 persist: impl Fn(&SessionRecord) -> Result<()>,
397 ) -> Result<()> {
398 let previous = self
399 .state
400 .sessions
401 .get(session_id)
402 .with_context(|| format!("unknown session {session_id}"))?
403 .clone();
404 ensure!(
405 previous.state == SessionState::Stopped,
406 "refusing deferred cleanup for active session {session_id}"
407 );
408 let Some(locator) = previous.target.as_ref() else {
409 return Ok(());
410 };
411 let backend = backend_locator(locator, &previous, &self.config)?;
412 ensure!(
413 hel_targets::quiesce_plan(&backend, session_id)?.is_some(),
414 "session {session_id} retained a non-Podman target after stopping"
415 );
416 execute_target_cleanup(&backend, session_id, executor)?;
417 let record = self.state.sessions.get_mut(session_id).unwrap();
418 record.target = None;
419 record.updated_at = now();
420 record.last_error = None;
421 persist_session_record_transition_or_restore(
422 &mut self.state,
423 session_id,
424 &previous,
425 "persist completion of deferred Podman target cleanup",
426 &persist,
427 )
428 }
429
430 pub fn force_stop(
433 &mut self,
434 session_id: &str,
435 executor: &impl CommandExecutor,
436 ) -> Result<bool> {
437 self.force_stop_with(
438 session_id,
439 executor,
440 hel::hel_database::save_lifecycle_session,
441 )
442 }
443
444 fn force_stop_with(
445 &mut self,
446 session_id: &str,
447 executor: &impl CommandExecutor,
448 persist: impl Fn(&SessionRecord) -> Result<()>,
449 ) -> Result<bool> {
450 let session = self
451 .state
452 .sessions
453 .get(session_id)
454 .with_context(|| format!("unknown session {session_id}"))?
455 .clone();
456 ensure!(
457 session.state.is_active(),
458 "session {session_id} is already inactive"
459 );
460 let checkpoint = session
461 .checkpoint
462 .as_ref()
463 .context("force stop requires an existing recovery archive")?;
464 verify_installed_checkpoint_gate(session_id, checkpoint)
467 .context("verify the recovery archive before force stopping")?;
468 retire_git_broker(session_id).context("stop the session's local Git broker")?;
469 let mut deferred = false;
470 if let Some(locator) = &session.target {
471 let backend = backend_locator(locator, &session, &self.config)?;
472 if let Some(plan) = hel_targets::quiesce_plan(&backend, session_id)? {
473 plan.execute(executor)?;
474 deferred = true;
475 } else {
476 execute_target_cleanup(&backend, session_id, executor)?;
477 }
478 }
479 if let Some(worktree) = &session.managed_worktree {
480 retire_managed_worktree(executor, worktree)
481 .context("retire managed raw-session worktree after force stop")?;
482 }
483 let record = self.state.sessions.get_mut(session_id).unwrap();
484 record.state = SessionState::Stopped;
485 if !deferred {
486 record.target = None;
487 }
488 record.updated_at = now();
489 record.last_error = None;
490 record.last_checkpoint_error = None;
491 persist_session_record_transition_or_restore(
492 &mut self.state,
493 session_id,
494 &session,
495 "persist stopped state after force stopping the current target",
496 &persist,
497 )?;
498 Ok(deferred)
499 }
500
501 pub fn destroy_session_controlled(
505 &mut self,
506 session_id: &str,
507 executor: &impl CommandExecutor,
508 ) -> Result<()> {
509 let session = self
510 .state
511 .sessions
512 .get(session_id)
513 .with_context(|| format!("unknown session {session_id}"))?
514 .clone();
515 if session.state.is_active() {
516 bail!("refusing to destroy active session {session_id}");
517 }
518 retire_git_broker(session_id).context("stop the session's local Git broker")?;
521 if let Some(worktree) = &session.managed_worktree {
522 cleanup_managed_worktree(executor, worktree)
523 .context("remove managed raw-session worktree")?;
524 }
525 if let Some(checkpoint) = &session.checkpoint
526 && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
527 && error.kind() != std::io::ErrorKind::NotFound
528 {
529 return Err(error).with_context(|| {
530 format!(
531 "remove session recovery archive {}",
532 checkpoint.archive_path.display()
533 )
534 });
535 }
536 hel::hel_database::delete_session(session_id)
537 .context("destroy stopped session in database")?;
538 self.state.destroy_stopped_session(session_id)?;
539 Ok(())
540 }
541
542 pub fn force_destroy_session(
552 &mut self,
553 session_id: &str,
554 executor: &impl CommandExecutor,
555 ) -> Result<()> {
556 self.force_destroy_session_with(session_id, executor, hel::hel_database::delete_session)
557 }
558
559 fn force_destroy_session_with(
560 &mut self,
561 session_id: &str,
562 executor: &impl CommandExecutor,
563 delete: impl Fn(&str) -> Result<()>,
564 ) -> Result<()> {
565 let session = self
566 .state
567 .sessions
568 .get(session_id)
569 .with_context(|| format!("unknown session {session_id}"))?
570 .clone();
571 retire_git_broker(session_id).context("stop the session's local Git broker")?;
575 if let Some(locator) = &session.target {
576 let backend = backend_locator(locator, &session, &self.config)?;
577 execute_target_cleanup(&backend, session_id, executor)?;
578 }
579 if let Some(worktree) = &session.managed_worktree {
580 cleanup_managed_worktree(executor, worktree)
581 .context("remove managed raw-session worktree")?;
582 }
583 if let Some(checkpoint) = &session.checkpoint
584 && let Err(error) = std::fs::remove_file(&checkpoint.archive_path)
585 && error.kind() != std::io::ErrorKind::NotFound
586 {
587 return Err(error).with_context(|| {
588 format!(
589 "remove session recovery archive {}",
590 checkpoint.archive_path.display()
591 )
592 });
593 }
594 delete(session_id).context("force destroy session in database")?;
595 self.state.destroy_session_force(session_id)?;
596 Ok(())
597 }
598}
599
600fn execute_target_cleanup(
601 backend: &hel_targets::TargetLocator,
602 session_id: &str,
603 executor: &impl CommandExecutor,
604) -> Result<()> {
605 if let Err(cleanup_error) = hel_targets::close_plan(backend, session_id)?.execute(executor) {
606 match hel_targets::cleanup_target_is_confirmed_absent(backend, session_id, executor) {
607 Ok(true) => {
608 tracing::warn!(
609 session_id,
610 error = format!("{cleanup_error:#}"),
611 "target cleanup command failed, but the target was confirmed absent"
612 );
613 }
614 Ok(false) => {
615 tracing::error!(
616 session_id,
617 error = format!("{cleanup_error:#}"),
618 "target cleanup failed and the target is still present"
619 );
620 return Err(cleanup_error);
621 }
622 Err(probe_error) => {
623 tracing::error!(
624 session_id,
625 cleanup_error = format!("{cleanup_error:#}"),
626 probe_error = format!("{probe_error:#}"),
627 "target cleanup failed and exact absence could not be confirmed"
628 );
629 return Err(cleanup_error.context(format!(
630 "target cleanup failed and exact absence could not be confirmed: {probe_error:#}"
631 )));
632 }
633 }
634 }
635 Ok(())
636}
637
638fn apply_close_checkpoint_started(record: &mut SessionRecord, updated_at: String) {
639 record.state = SessionState::Closing;
640 record.updated_at = updated_at;
641 record.last_checkpoint_error = None;
642}
643
644fn apply_interrupted_close_error(
645 record: &mut SessionRecord,
646 error: &anyhow::Error,
647 updated_at: &str,
648) {
649 let destroying = record.state == SessionState::Destroying;
650 if !destroying {
651 record.state = SessionState::Closing;
652 }
653 record.updated_at = updated_at.to_owned();
654 record.last_error = Some(if destroying {
655 format!("target cleanup is safely retryable from its verified checkpoint: {error:#}")
656 } else {
657 format!("close is safely resumable from its verified checkpoint: {error:#}")
658 });
659}
660
661#[cfg(test)]
662mod tests {
663 use std::cell::RefCell;
664 use std::collections::BTreeMap;
665
666 use anyhow::Result;
667
668 use crate::hel_controller::Controller;
669 use crate::hel_controller::test_support::{
670 checkpoint_test_session, committed_repository, managed_worktree_session, test_git,
671 write_checkpoint_gate_archive,
672 };
673 use hel::hel_config::{ContainerTemplate as ConfigContainer, HelConfig, TargetTemplate};
674 use hel::hel_state::{HelState, SessionState, TargetLocator};
675 use hel::hel_targets::{self, CommandExecutor, CommandOutput, CommandSpec, ProcessExecutor};
676
677 use super::*;
678
679 #[test]
680 fn starting_close_persists_its_intent_before_checkpointing() {
681 let mut session = checkpoint_test_session("0123456789abcdef0123456789abcdef");
682 session.state = SessionState::Running;
683 session.last_checkpoint_error = Some("old failure".into());
684
685 apply_close_checkpoint_started(&mut session, "2026-08-14T12:00:00Z".into());
686
687 assert_eq!(session.state, SessionState::Closing);
688 assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
689 assert!(session.last_checkpoint_error.is_none());
690 }
691 #[test]
692 fn target_cleanup_persists_destroying_and_rechecks_the_installed_archive() {
693 struct RecordingExecutor {
694 commands: RefCell<Vec<CommandSpec>>,
695 }
696
697 impl CommandExecutor for RecordingExecutor {
698 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
699 self.commands.borrow_mut().push(command.clone());
700 Ok(CommandOutput {
701 status: 0,
702 stdout: Vec::new(),
703 stderr: Vec::new(),
704 })
705 }
706 }
707
708 let directory = tempfile::tempdir().unwrap();
709 let session_id = "0123456789abcdef0123456789abcdef";
710 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
711 let mut session = checkpoint_test_session(session_id);
712 session.target_template_id = "local".into();
713 session.state = SessionState::Closing;
714 session.target = Some(TargetLocator::LocalBare {
715 worker_root: directory.path().join(session_id),
716 });
717 session.checkpoint = Some(checkpoint.clone());
718 let mut config = HelConfig::default();
719 config
720 .targets
721 .insert("local".into(), TargetTemplate::LocalBare);
722 let mut controller = Controller {
723 config,
724 state: HelState {
725 sessions: BTreeMap::from([(session_id.into(), session)]),
726 ..HelState::default()
727 },
728 };
729 let executor = RecordingExecutor {
730 commands: RefCell::new(Vec::new()),
731 };
732 let persisted = RefCell::new(Vec::new());
733
734 controller
735 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
736 persisted.borrow_mut().push(record.state);
737 Ok(())
738 })
739 .unwrap();
740
741 assert_eq!(
742 persisted.into_inner(),
743 vec![SessionState::Destroying, SessionState::Stopped]
744 );
745 assert_eq!(executor.commands.borrow().len(), 1);
746 let stopped = &controller.state.sessions[session_id];
747 assert_eq!(stopped.state, SessionState::Stopped);
748 assert!(stopped.target.is_none());
749 }
750
751 #[test]
752 fn podman_close_persists_stopped_before_deferred_storage_cleanup() {
753 struct RecordingExecutor {
754 commands: RefCell<Vec<CommandSpec>>,
755 }
756
757 impl CommandExecutor for RecordingExecutor {
758 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
759 self.commands.borrow_mut().push(command.clone());
760 Ok(CommandOutput {
761 status: 0,
762 stdout: Vec::new(),
763 stderr: Vec::new(),
764 })
765 }
766 }
767
768 let directory = tempfile::tempdir().unwrap();
769 let session_id = "0123456789abcdef0123456789abcdef";
770 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
771 let container_id = hel_targets::resource_name(session_id).unwrap();
772 let volume = format!("{container_id}-workspace");
773 let mut session = checkpoint_test_session(session_id);
774 session.target_template_id = "podman".into();
775 session.state = SessionState::Closing;
776 session.target = Some(TargetLocator::LocalPodman {
777 container_id,
778 workspace_storage: hel::hel_state::PodmanWorkspaceLocator::Volume {
779 name: volume.clone(),
780 },
781 });
782 session.checkpoint = Some(checkpoint.clone());
783 let mut config = HelConfig::default();
784 config.targets.insert(
785 "podman".into(),
786 TargetTemplate::LocalPodman {
787 container: ConfigContainer {
788 image: "test:latest".into(),
789 pull_policy: Default::default(),
790 platform: None,
791 cpus: None,
792 memory: None,
793 environment: BTreeMap::new(),
794 workspace_storage: hel::hel_config::PodmanWorkspaceStorage::PodmanVolume,
795 },
796 },
797 );
798 let mut controller = Controller {
799 config,
800 state: HelState {
801 sessions: BTreeMap::from([(session_id.into(), session)]),
802 ..HelState::default()
803 },
804 };
805 let executor = RecordingExecutor {
806 commands: RefCell::new(Vec::new()),
807 };
808 let persisted = RefCell::new(Vec::new());
809
810 let deferred = controller
811 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
812 persisted
813 .borrow_mut()
814 .push((record.state, record.target.is_some()));
815 Ok(())
816 })
817 .unwrap();
818
819 assert!(deferred);
820 assert_eq!(
821 persisted.borrow().as_slice(),
822 &[
823 (SessionState::Destroying, true),
824 (SessionState::Stopped, true)
825 ]
826 );
827 let commands = executor.commands.borrow();
828 assert_eq!(commands.len(), 1);
829 assert!(commands[0].args[1].contains("podman stop --time 0"));
830 assert!(!commands[0].args[1].contains("podman rm"));
831 drop(commands);
832
833 controller
834 .cleanup_stopped_target_with(session_id, &executor, |record| {
835 assert_eq!(record.state, SessionState::Stopped);
836 assert!(record.target.is_none());
837 Ok(())
838 })
839 .unwrap();
840
841 let commands = executor.commands.borrow();
842 assert_eq!(commands.len(), 4);
843 assert_eq!(
844 commands[1].stage,
845 Some(hel_targets::ProvisionStage::RemovingContainer)
846 );
847 assert_eq!(
848 commands[2].stage,
849 Some(hel_targets::ProvisionStage::RemovingStorage)
850 );
851 assert_eq!(
852 commands[3].stage,
853 Some(hel_targets::ProvisionStage::CleaningCache)
854 );
855 assert!(commands[2].args.contains(&volume));
856 assert!(controller.state.sessions[session_id].target.is_none());
857 }
858 #[test]
859 fn verified_close_retires_managed_checkout_but_keeps_archive_and_branch() {
860 let archive_directory = tempfile::tempdir().unwrap();
861 let repository = committed_repository();
862 let session_id = "0123456789abcdef0123456789abcdef";
863 let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
864 let mut session = managed_worktree_session(repository.path(), session_id);
865 let worktree = session.managed_worktree.clone().unwrap();
866 std::fs::write(worktree.worktree_root.join("dirty.txt"), "worktree state\n").unwrap();
867 session.state = SessionState::Closing;
868 session.target = Some(TargetLocator::LocalBare {
869 worker_root: archive_directory.path().join(session_id),
870 });
871 session.checkpoint = Some(checkpoint.clone());
872 let mut config = HelConfig::default();
873 config
874 .targets
875 .insert("local-bare".into(), TargetTemplate::LocalBare);
876 let mut controller = Controller {
877 config,
878 state: HelState {
879 sessions: BTreeMap::from([(session_id.into(), session)]),
880 ..HelState::default()
881 },
882 };
883
884 controller
885 .destroy_after_verified_checkpoint_with(
886 session_id,
887 &checkpoint,
888 &ProcessExecutor,
889 |_| Ok(()),
890 )
891 .unwrap();
892
893 assert!(!worktree.worktree_root.exists());
894 assert!(checkpoint.archive_path.is_file());
895 assert_eq!(
896 test_git(
897 repository.path(),
898 &[
899 "show-ref",
900 "--hash",
901 &format!("refs/heads/{}", worktree.branch),
902 ],
903 )
904 .len(),
905 40
906 );
907 assert_eq!(
908 controller.state.sessions[session_id].state,
909 SessionState::Stopped
910 );
911 }
912 #[test]
913 fn force_stop_reuses_verified_archive_and_leaves_session_resumable() {
914 let archive_directory = tempfile::tempdir().unwrap();
915 let repository = committed_repository();
916 let session_id = "0123456789abcdef0123456789abcdef";
917 let checkpoint = write_checkpoint_gate_archive(archive_directory.path(), session_id, 7);
918 let mut session = managed_worktree_session(repository.path(), session_id);
919 let worktree = session.managed_worktree.clone().unwrap();
920 session.state = SessionState::Running;
921 session.target = Some(TargetLocator::LocalBare {
922 worker_root: archive_directory.path().join(session_id),
923 });
924 session.checkpoint = Some(checkpoint.clone());
925 let mut config = HelConfig::default();
926 config
927 .targets
928 .insert("local-bare".into(), TargetTemplate::LocalBare);
929 let mut controller = Controller {
930 config,
931 state: HelState {
932 sessions: BTreeMap::from([(session_id.into(), session)]),
933 ..HelState::default()
934 },
935 };
936
937 controller
938 .force_stop_with(session_id, &ProcessExecutor, |_| Ok(()))
939 .unwrap();
940
941 let stopped = &controller.state.sessions[session_id];
942 assert_eq!(stopped.state, SessionState::Stopped);
943 assert!(stopped.target.is_none());
944 assert_eq!(stopped.checkpoint.as_ref(), Some(&checkpoint));
945 assert!(checkpoint.archive_path.is_file());
946 assert!(!worktree.worktree_root.exists());
947 assert!(
948 !test_git(
949 repository.path(),
950 &[
951 "show-ref",
952 "--hash",
953 &format!("refs/heads/{}", worktree.branch),
954 ],
955 )
956 .is_empty()
957 );
958 }
959 #[test]
960 fn force_stop_without_a_recovery_archive_does_not_touch_the_target() {
961 struct RecordingExecutor {
962 calls: RefCell<usize>,
963 }
964
965 impl CommandExecutor for RecordingExecutor {
966 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
967 *self.calls.borrow_mut() += 1;
968 Ok(CommandOutput {
969 status: 0,
970 stdout: Vec::new(),
971 stderr: Vec::new(),
972 })
973 }
974 }
975
976 let directory = tempfile::tempdir().unwrap();
977 let session_id = "0123456789abcdef0123456789abcdef";
978 let mut session = checkpoint_test_session(session_id);
979 session.state = SessionState::Running;
980 session.checkpoint = None;
981 session.target_template_id = "local".into();
982 session.target = Some(TargetLocator::LocalBare {
983 worker_root: directory.path().join(session_id),
984 });
985 let mut config = HelConfig::default();
986 config
987 .targets
988 .insert("local".into(), TargetTemplate::LocalBare);
989 let mut controller = Controller {
990 config,
991 state: HelState {
992 sessions: BTreeMap::from([(session_id.into(), session)]),
993 ..HelState::default()
994 },
995 };
996 let executor = RecordingExecutor {
997 calls: RefCell::new(0),
998 };
999
1000 let error = controller
1001 .force_stop_with(session_id, &executor, |_| Ok(()))
1002 .unwrap_err();
1003
1004 assert!(error.to_string().contains("existing recovery archive"));
1005 assert_eq!(*executor.calls.borrow(), 0);
1006 assert_eq!(
1007 controller.state.sessions[session_id].state,
1008 SessionState::Running
1009 );
1010 assert!(controller.state.sessions[session_id].target.is_some());
1011 }
1012 #[test]
1013 fn destroying_retry_blocks_cleanup_when_the_archive_gate_changed() {
1014 struct RecordingExecutor {
1015 calls: RefCell<usize>,
1016 }
1017
1018 impl CommandExecutor for RecordingExecutor {
1019 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1020 *self.calls.borrow_mut() += 1;
1021 Ok(CommandOutput {
1022 status: 0,
1023 stdout: Vec::new(),
1024 stderr: Vec::new(),
1025 })
1026 }
1027 }
1028
1029 let directory = tempfile::tempdir().unwrap();
1030 let repository = committed_repository();
1031 let session_id = "0123456789abcdef0123456789abcdef";
1032 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1033 let mut session = managed_worktree_session(repository.path(), session_id);
1034 let worktree = session.managed_worktree.clone().unwrap();
1035 session.target_template_id = "local".into();
1036 session.state = SessionState::Destroying;
1037 session.target = Some(TargetLocator::LocalBare {
1038 worker_root: directory.path().join(session_id),
1039 });
1040 session.checkpoint = Some(checkpoint.clone());
1041 let mut config = HelConfig::default();
1042 config
1043 .targets
1044 .insert("local".into(), TargetTemplate::LocalBare);
1045 let mut controller = Controller {
1046 config,
1047 state: HelState {
1048 sessions: BTreeMap::from([(session_id.into(), session)]),
1049 ..HelState::default()
1050 },
1051 };
1052 let executor = RecordingExecutor {
1053 calls: RefCell::new(0),
1054 };
1055 let persisted = RefCell::new(Vec::new());
1056 std::fs::write(&checkpoint.archive_path, b"changed after checkpoint").unwrap();
1057
1058 let error = controller
1059 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1060 persisted.borrow_mut().push(record.state);
1061 Ok(())
1062 })
1063 .unwrap_err();
1064
1065 assert!(error.to_string().contains("checkpoint SHA changed"));
1066 assert_eq!(*executor.calls.borrow(), 0);
1067 assert!(persisted.into_inner().is_empty());
1068 assert!(worktree.worktree_root.is_dir());
1069 assert_eq!(
1070 controller.state.sessions[session_id].state,
1071 SessionState::Destroying
1072 );
1073 }
1074 #[test]
1075 fn destroying_retry_finalizes_when_apple_container_is_confirmed_absent() {
1076 struct AlreadyRemovedExecutor {
1077 commands: RefCell<Vec<CommandSpec>>,
1078 }
1079
1080 impl CommandExecutor for AlreadyRemovedExecutor {
1081 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1082 self.commands.borrow_mut().push(command.clone());
1083 if command.program == "sh"
1084 && command
1085 .args
1086 .get(1)
1087 .is_some_and(|script| script.contains("container rm --force"))
1088 {
1089 Ok(CommandOutput {
1090 status: 1,
1091 stdout: Vec::new(),
1092 stderr: b"container not found".to_vec(),
1093 })
1094 } else {
1095 Ok(CommandOutput {
1096 status: 0,
1097 stdout: Vec::new(),
1098 stderr: Vec::new(),
1099 })
1100 }
1101 }
1102 }
1103
1104 let directory = tempfile::tempdir().unwrap();
1105 let session_id = "0123456789abcdef0123456789abcdef";
1106 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1107 let mut session = checkpoint_test_session(session_id);
1108 session.target_template_id = "apple".into();
1109 session.state = SessionState::Destroying;
1110 session.target = Some(TargetLocator::AppleContainer {
1111 container_id: hel_targets::resource_name(session_id).unwrap(),
1112 });
1113 session.checkpoint = Some(checkpoint.clone());
1114 let mut config = HelConfig::default();
1115 config.targets.insert(
1116 "apple".into(),
1117 TargetTemplate::AppleContainer {
1118 container: ConfigContainer {
1119 image: "test:latest".into(),
1120 pull_policy: Default::default(),
1121 platform: None,
1122 cpus: None,
1123 memory: None,
1124 environment: BTreeMap::new(),
1125 workspace_storage: Default::default(),
1126 },
1127 },
1128 );
1129 let mut controller = Controller {
1130 config,
1131 state: HelState {
1132 sessions: BTreeMap::from([(session_id.into(), session)]),
1133 ..HelState::default()
1134 },
1135 };
1136 let executor = AlreadyRemovedExecutor {
1137 commands: RefCell::new(Vec::new()),
1138 };
1139 let persisted = RefCell::new(Vec::new());
1140
1141 controller
1142 .destroy_after_verified_checkpoint_with(session_id, &checkpoint, &executor, |record| {
1143 persisted.borrow_mut().push(record.state);
1144 Ok(())
1145 })
1146 .unwrap();
1147
1148 let commands = executor.commands.borrow();
1149 assert_eq!(commands.len(), 2);
1150 assert_eq!(commands[0].program, "sh");
1151 assert!(commands[0].args[1].contains("container rm --force"));
1152 assert!(commands[0].args[1].contains(".cache/mjolnir/git/sessions"));
1153 assert_eq!(commands[1].args, ["list", "--all", "--quiet"]);
1154 assert_eq!(persisted.into_inner(), vec![SessionState::Stopped]);
1155 assert_eq!(
1156 controller.state.sessions[session_id].state,
1157 SessionState::Stopped
1158 );
1159 }
1160 #[cfg(unix)]
1165 #[test]
1166 fn every_session_ending_retires_its_local_git_broker() {
1167 const RETIREMENT_TEST_CHILD: &str = "MJ_TEST_BROKER_RETIREMENT_CHILD";
1168
1169 struct SucceedingExecutor;
1170
1171 impl CommandExecutor for SucceedingExecutor {
1172 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1173 Ok(CommandOutput {
1174 status: 0,
1175 stdout: Vec::new(),
1176 stderr: Vec::new(),
1177 })
1178 }
1179 }
1180
1181 if std::env::var_os(RETIREMENT_TEST_CHILD).is_none() {
1184 let directory = tempfile::tempdir().unwrap();
1185 let test_name = format!(
1186 "{}::every_session_ending_retires_its_local_git_broker",
1187 module_path!()
1188 .strip_prefix("mj_controller::")
1189 .unwrap_or(module_path!())
1190 );
1191 let output = std::process::Command::new(std::env::current_exe().unwrap())
1192 .args(["--exact", &test_name, "--nocapture"])
1193 .env(RETIREMENT_TEST_CHILD, "1")
1194 .env("MJ_DATA_DIR", directory.path())
1195 .output()
1196 .unwrap();
1197 let reported = String::from_utf8_lossy(&output.stdout).into_owned();
1198 assert!(
1199 output.status.success(),
1200 "isolated broker retirement test failed\nstdout:\n{reported}\nstderr:\n{}",
1201 String::from_utf8_lossy(&output.stderr)
1202 );
1203 assert!(
1206 reported.contains("1 passed"),
1207 "the isolated broker retirement test never ran\nstdout:\n{reported}"
1208 );
1209 return;
1210 }
1211 let _writer = hel::hel_database::install_isolated_test_writer();
1213
1214 let brokers = hel::hel_config::data_dir().join("git-brokers");
1215 std::fs::create_dir_all(&brokers).unwrap();
1216 let seed_broker = |session_id: &str| {
1217 for (extension, contents) in [
1218 ("json", "{}"),
1219 ("pid", "424242"),
1223 ("ready", "ready\n"),
1224 ("log", "broker log\n"),
1225 ] {
1226 std::fs::write(brokers.join(format!("{session_id}.{extension}")), contents)
1227 .unwrap();
1228 }
1229 };
1230 let assert_retired = |session_id: &str| {
1231 for extension in ["json", "pid", "ready"] {
1232 let path = brokers.join(format!("{session_id}.{extension}"));
1233 assert!(!path.exists(), "{} outlived its session", path.display());
1234 }
1235 assert_eq!(
1236 std::fs::read_to_string(brokers.join(format!("{session_id}.log"))).unwrap(),
1237 "broker log\n",
1238 "the broker log must survive its session"
1239 );
1240 };
1241
1242 let directory = tempfile::tempdir().unwrap();
1243 let closing = "0123456789abcdef0123456789abcdef";
1244 let force_stopped = "0123456789abcdef0123456789abcdee";
1245 let destroyed = "0123456789abcdef0123456789abcded";
1246 let checkpoint = write_checkpoint_gate_archive(directory.path(), closing, 7);
1247 let mut closing_session = checkpoint_test_session(closing);
1248 closing_session.target_template_id = "local".into();
1249 closing_session.state = SessionState::Closing;
1250 closing_session.target = Some(TargetLocator::LocalBare {
1251 worker_root: directory.path().join(closing),
1252 });
1253 closing_session.checkpoint = Some(checkpoint.clone());
1254 let force_stop_checkpoint =
1255 write_checkpoint_gate_archive(directory.path(), force_stopped, 7);
1256 let mut force_stopped_session = checkpoint_test_session(force_stopped);
1257 force_stopped_session.target_template_id = "local".into();
1258 force_stopped_session.state = SessionState::Running;
1259 force_stopped_session.target = Some(TargetLocator::LocalBare {
1260 worker_root: directory.path().join(force_stopped),
1261 });
1262 force_stopped_session.checkpoint = Some(force_stop_checkpoint);
1263 let mut destroyed_session = checkpoint_test_session(destroyed);
1264 destroyed_session.target_template_id = "local".into();
1265 destroyed_session.state = SessionState::Stopped;
1266 let mut config = HelConfig::default();
1267 config
1268 .targets
1269 .insert("local".into(), TargetTemplate::LocalBare);
1270 let mut controller = Controller {
1271 config,
1272 state: HelState {
1273 sessions: BTreeMap::from([
1274 (closing.into(), closing_session),
1275 (force_stopped.into(), force_stopped_session),
1276 (destroyed.into(), destroyed_session),
1277 ]),
1278 ..HelState::default()
1279 },
1280 };
1281 for session_id in [closing, force_stopped, destroyed] {
1282 seed_broker(session_id);
1283 }
1284
1285 controller
1286 .destroy_after_verified_checkpoint_with(
1287 closing,
1288 &checkpoint,
1289 &SucceedingExecutor,
1290 |_| Ok(()),
1291 )
1292 .unwrap();
1293 assert_retired(closing);
1294
1295 controller
1296 .force_stop_with(force_stopped, &SucceedingExecutor, |_| Ok(()))
1297 .unwrap();
1298 assert_retired(force_stopped);
1299
1300 controller
1301 .destroy_session_controlled(destroyed, &SucceedingExecutor)
1302 .unwrap();
1303 assert_retired(destroyed);
1304 }
1305 #[test]
1306 fn interrupted_close_error_preserves_destroying_phase() {
1307 let session_id = "0123456789abcdef0123456789abcdef";
1308 let mut session = checkpoint_test_session(session_id);
1309 session.state = SessionState::Destroying;
1310
1311 apply_interrupted_close_error(
1312 &mut session,
1313 &anyhow::anyhow!("podman unavailable"),
1314 "2026-08-14T12:00:00Z",
1315 );
1316
1317 assert_eq!(session.state, SessionState::Destroying);
1318 assert_eq!(session.updated_at, "2026-08-14T12:00:00Z");
1319 assert!(
1320 session
1321 .last_error
1322 .as_deref()
1323 .is_some_and(|error| error.contains("cleanup is safely retryable"))
1324 );
1325 }
1326
1327 struct FailingExecutor;
1328
1329 impl CommandExecutor for FailingExecutor {
1330 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
1331 Ok(CommandOutput {
1332 status: 1,
1333 stdout: Vec::new(),
1334 stderr: b"teardown unavailable".to_vec(),
1335 })
1336 }
1337 }
1338
1339 fn branch_exists(repository: &std::path::Path, branch: &str) -> bool {
1340 std::process::Command::new("git")
1341 .arg("-C")
1342 .arg(repository)
1343 .args(["show-ref", "--verify", "--quiet"])
1344 .arg(format!("refs/heads/{branch}"))
1345 .output()
1346 .unwrap()
1347 .status
1348 .success()
1349 }
1350
1351 #[test]
1352 fn force_destroy_from_running_removes_target_worktree_branch_and_archive() {
1353 let directory = tempfile::tempdir().unwrap();
1354 let repository = committed_repository();
1355 let session_id = "0123456789abcdef0123456789abcdef";
1356 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1357 let worker_root = directory.path().join(session_id);
1358 std::fs::create_dir_all(&worker_root).unwrap();
1359 let mut session = managed_worktree_session(repository.path(), session_id);
1360 session.state = SessionState::Running;
1361 session.target_template_id = "local".into();
1362 session.target = Some(TargetLocator::LocalBare {
1363 worker_root: worker_root.clone(),
1364 });
1365 session.checkpoint = Some(checkpoint.clone());
1366 let mut config = HelConfig::default();
1367 config
1368 .targets
1369 .insert("local".into(), TargetTemplate::LocalBare);
1370 let mut controller = Controller {
1371 config,
1372 state: HelState {
1373 sessions: BTreeMap::from([(session_id.into(), session)]),
1374 ..HelState::default()
1375 },
1376 };
1377 let deleted = RefCell::new(Vec::new());
1378
1379 controller
1380 .force_destroy_session_with(session_id, &ProcessExecutor, |id| {
1381 deleted.borrow_mut().push(id.to_owned());
1382 Ok(())
1383 })
1384 .unwrap();
1385
1386 assert!(!worker_root.exists(), "local target must be removed");
1387 let worktree_root = repository.path().join(".mj/worktrees").join(session_id);
1388 assert!(!worktree_root.exists(), "managed worktree must be removed");
1389 assert!(
1390 !branch_exists(repository.path(), &format!("mj/{session_id}")),
1391 "generated branch must be removed"
1392 );
1393 assert!(!checkpoint.archive_path.exists(), "archive must be removed");
1394 assert!(!controller.state.sessions.contains_key(session_id));
1395 assert_eq!(deleted.into_inner(), vec![session_id.to_owned()]);
1396 }
1397
1398 #[test]
1399 fn force_destroy_without_a_target_or_archive_still_removes_the_record() {
1400 let session_id = "0123456789abcdef0123456789abcdef";
1401 let mut session = checkpoint_test_session(session_id);
1402 session.state = SessionState::Provisioning;
1403 session.target = None;
1404 session.checkpoint = None;
1405 let mut controller = Controller {
1406 config: HelConfig::default(),
1407 state: HelState {
1408 sessions: BTreeMap::from([(session_id.into(), session)]),
1409 ..HelState::default()
1410 },
1411 };
1412 let deleted = RefCell::new(Vec::new());
1413
1414 controller
1415 .force_destroy_session_with(session_id, &ProcessExecutor, |id| {
1416 deleted.borrow_mut().push(id.to_owned());
1417 Ok(())
1418 })
1419 .unwrap();
1420
1421 assert!(!controller.state.sessions.contains_key(session_id));
1422 assert_eq!(deleted.into_inner(), vec![session_id.to_owned()]);
1423 }
1424
1425 #[test]
1426 fn force_destroy_aborts_and_keeps_the_record_when_the_target_survives() {
1427 let directory = tempfile::tempdir().unwrap();
1428 let session_id = "0123456789abcdef0123456789abcdef";
1429 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1430 let mut session = checkpoint_test_session(session_id);
1431 session.target_template_id = "local".into();
1432 session.state = SessionState::Running;
1433 session.target = Some(TargetLocator::LocalBare {
1434 worker_root: directory.path().join(session_id),
1435 });
1436 session.checkpoint = Some(checkpoint.clone());
1437 let mut config = HelConfig::default();
1438 config
1439 .targets
1440 .insert("local".into(), TargetTemplate::LocalBare);
1441 let mut controller = Controller {
1442 config,
1443 state: HelState {
1444 sessions: BTreeMap::from([(session_id.into(), session)]),
1445 ..HelState::default()
1446 },
1447 };
1448 let deleted = RefCell::new(Vec::new());
1449
1450 let error = controller
1451 .force_destroy_session_with(session_id, &FailingExecutor, |id| {
1452 deleted.borrow_mut().push(id.to_owned());
1453 Ok(())
1454 })
1455 .unwrap_err();
1456
1457 assert!(
1458 error.to_string().contains("teardown unavailable"),
1459 "{error:#}"
1460 );
1461 assert!(
1462 controller.state.sessions.contains_key(session_id),
1463 "a surviving target must keep the record for a retry"
1464 );
1465 assert!(
1466 checkpoint.archive_path.exists(),
1467 "a surviving target must keep the recovery archive"
1468 );
1469 assert!(deleted.into_inner().is_empty());
1470 }
1471
1472 #[test]
1473 fn force_destroy_tolerates_a_missing_archive() {
1474 let directory = tempfile::tempdir().unwrap();
1475 let session_id = "0123456789abcdef0123456789abcdef";
1476 let checkpoint = write_checkpoint_gate_archive(directory.path(), session_id, 7);
1477 std::fs::remove_file(&checkpoint.archive_path).unwrap();
1478 let mut session = checkpoint_test_session(session_id);
1479 session.state = SessionState::Error;
1480 session.checkpoint = Some(checkpoint);
1481 let mut controller = Controller {
1482 config: HelConfig::default(),
1483 state: HelState {
1484 sessions: BTreeMap::from([(session_id.into(), session)]),
1485 ..HelState::default()
1486 },
1487 };
1488
1489 controller
1490 .force_destroy_session_with(session_id, &ProcessExecutor, |_| Ok(()))
1491 .unwrap();
1492
1493 assert!(!controller.state.sessions.contains_key(session_id));
1494 }
1495}