1mod selection;
4mod session_store;
5pub mod workflow;
6
7use std::future::Future;
8use std::path::PathBuf;
9use std::pin::Pin;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use serde::{Deserialize, Serialize};
15use tokio::sync::Mutex;
16
17use crate::errors::{Result, WinxError};
18use crate::state::bash_state::BashState;
19use crate::state::pty::SharedPtyShell;
20use crate::tools::bash_command::BashCommandResult;
21use crate::types::BashCommand;
22
23pub use selection::{
24 configured_daemon_binary, ensure_control_daemon_at, ensure_daemon_at, restart_control_daemon_at,
25};
26pub use selection::{
27 configured_runtime_mode, configured_shell_runtime, select_runtime_mode, RuntimeMode,
28};
29pub(crate) use session_store::lock_session_store;
30pub use session_store::{SessionStore, ShellTarget};
31
32pub type ShellRuntimeFuture<'a> =
34 Pin<Box<dyn Future<Output = Result<BashCommandResult>> + Send + 'a>>;
35pub type ShellRuntimeUnitFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
36pub type ShellRuntimeBoolFuture<'a> = Pin<Box<dyn Future<Output = Result<bool>> + Send + 'a>>;
37pub type ShellRuntimeDetailedFuture<'a> =
38 Pin<Box<dyn Future<Output = Result<BashCommandRuntimeResult>> + Send + 'a>>;
39
40#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct BashCommandRuntimeResult {
45 pub result: BashCommandResult,
46 pub compact_output: Option<String>,
47 pub command_generation: Option<u64>,
48 pub execution_token: Option<ShellExecutionToken>,
49 pub action_context: Option<ShellActionContext>,
50 pub generation_bound_actions: bool,
51 pub dropped_output_file: Option<PathBuf>,
55 pub output_truncated: bool,
58 pub workflow: Option<workflow::WorkflowObservation>,
59}
60
61impl BashCommandRuntimeResult {
62 pub fn legacy(result: BashCommandResult) -> Self {
63 Self {
64 result,
65 compact_output: None,
66 command_generation: None,
67 execution_token: None,
68 action_context: None,
69 generation_bound_actions: false,
70 dropped_output_file: None,
71 output_truncated: false,
72 workflow: None,
73 }
74 }
75
76 pub(crate) fn with_dropped_output_file(mut self, path: Option<PathBuf>) -> Self {
77 self.dropped_output_file = path;
78 self
79 }
80}
81
82#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
85pub struct ShellActionContext {
86 pub thread_id: String,
87 pub workspace_root: PathBuf,
88}
89
90#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
91pub struct ShellExecutionToken {
92 pub guardian_epoch: String,
93 pub session_epoch: String,
94 pub generation: u64,
95}
96
97#[allow(clippy::struct_excessive_bools)]
99#[derive(Clone, Debug, Default, Serialize, Deserialize)]
100pub struct ShellActionOptions {
101 pub compact_output: bool,
102 pub expected_generation: Option<u64>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub expected_execution: Option<ShellExecutionToken>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub expected_guardian_epoch: Option<String>,
111 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
114 pub require_generation_binding: bool,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub cancellation_key: Option<String>,
119 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
123 pub return_when_awaiting_input: bool,
124 #[serde(skip)]
127 pub launch_cancelled: Option<Arc<AtomicBool>>,
128 #[cfg(test)]
131 #[serde(skip)]
132 pub(crate) force_clear_to_run_failure: bool,
133}
134
135impl ShellActionOptions {
136 pub(crate) fn is_default(&self) -> bool {
137 let is_default = !self.compact_output
138 && self.expected_generation.is_none()
139 && self.expected_execution.is_none()
140 && self.expected_guardian_epoch.is_none()
141 && !self.require_generation_binding
142 && self.cancellation_key.is_none()
143 && !self.return_when_awaiting_input;
144 #[cfg(test)]
145 {
146 is_default && !self.force_clear_to_run_failure
147 }
148 #[cfg(not(test))]
149 {
150 is_default
151 }
152 }
153
154 pub(crate) fn is_launch_cancelled(&self) -> bool {
155 self.launch_cancelled.as_ref().is_some_and(|flag| flag.load(Ordering::SeqCst))
156 }
157}
158
159impl PartialEq for ShellActionOptions {
160 fn eq(&self, other: &Self) -> bool {
161 let equal = self.compact_output == other.compact_output
162 && self.expected_generation == other.expected_generation
163 && self.expected_execution == other.expected_execution
164 && self.expected_guardian_epoch == other.expected_guardian_epoch
165 && self.require_generation_binding == other.require_generation_binding
166 && self.cancellation_key == other.cancellation_key;
167 #[cfg(test)]
168 {
169 equal && self.force_clear_to_run_failure == other.force_clear_to_run_failure
170 }
171 #[cfg(not(test))]
172 {
173 equal
174 }
175 }
176}
177
178impl Eq for ShellActionOptions {}
179
180#[derive(Clone, Debug, Default, Eq, PartialEq)]
181pub struct ShellSessionConfiguration {
182 pub attach_hint: Option<String>,
183 pub attached_existing: bool,
184}
185
186pub type ShellRuntimeConfigureFuture<'a> =
187 Pin<Box<dyn Future<Output = Result<ShellSessionConfiguration>> + Send + 'a>>;
188
189#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize)]
192pub struct ShellSessionStatus {
193 pub runtime: &'static str,
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub guardian_pid: Option<u32>,
198 #[serde(skip_serializing_if = "Option::is_none")]
199 pub guardian_epoch: Option<String>,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub guardian_build: Option<String>,
202 pub command_running: bool,
203 #[serde(skip_serializing_if = "Option::is_none")]
204 pub running_command_id: Option<String>,
205 pub background_command_ids: Vec<String>,
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub last_activity_unix_ms: Option<u64>,
208}
209
210pub type ShellRuntimeStatusFuture<'a> =
211 Pin<Box<dyn Future<Output = Result<ShellSessionStatus>> + Send + 'a>>;
212
213pub type ShellRuntimeTemporaryReleaseFuture<'a> = Pin<
214 Box<
215 dyn Future<Output = Result<crate::utils::agent_temp::release::TemporaryReleaseResult>>
216 + Send
217 + 'a,
218 >,
219>;
220
221#[derive(Clone, Copy, Debug, Eq, PartialEq)]
223pub enum ShellSessionTransition {
224 FirstCall,
225 ModeChange,
226 Reset,
227 WorkspaceChange,
228}
229
230pub trait ShellRuntime: Send + Sync {
232 fn release_temporary<'a>(
234 &'a self,
235 _state: &'a Arc<Mutex<Option<BashState>>>,
236 _request: crate::utils::agent_temp::release::TemporaryReleaseRequest,
237 ) -> ShellRuntimeTemporaryReleaseFuture<'a> {
238 Box::pin(async {
239 Err(WinxError::InvalidInput(
240 "unsupported: safe temporary release requires a capable durable guardian on Linux"
241 .into(),
242 ))
243 })
244 }
245
246 fn configure_session<'a>(
247 &'a self,
248 bash_state: &'a mut BashState,
249 transition: ShellSessionTransition,
250 ) -> ShellRuntimeConfigureFuture<'a>;
251
252 fn attach_session<'a>(
256 &'a self,
257 bash_state: &'a mut BashState,
258 ) -> ShellRuntimeConfigureFuture<'a> {
259 self.configure_session(bash_state, ShellSessionTransition::ModeChange)
260 }
261
262 fn run_action<'a>(
263 &'a self,
264 bash_state: &'a Arc<Mutex<Option<BashState>>>,
265 command: BashCommand,
266 ) -> ShellRuntimeFuture<'a>;
267
268 fn run_action_detailed<'a>(
271 &'a self,
272 bash_state: &'a Arc<Mutex<Option<BashState>>>,
273 command: BashCommand,
274 _options: ShellActionOptions,
275 ) -> ShellRuntimeDetailedFuture<'a> {
276 Box::pin(async move {
277 self.run_action(bash_state, command).await.map(BashCommandRuntimeResult::legacy)
278 })
279 }
280
281 fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
282 Box::pin(async { Ok(false) })
283 }
284
285 fn supports_generation_bound_actions_for<'a>(
289 &'a self,
290 _bash_state: &'a Arc<Mutex<Option<BashState>>>,
291 ) -> ShellRuntimeBoolFuture<'a> {
292 self.supports_generation_bound_actions()
293 }
294 fn supports_agent_workflows_for<'a>(
296 &'a self,
297 _bash_state: &'a Arc<Mutex<Option<BashState>>>,
298 ) -> ShellRuntimeBoolFuture<'a> {
299 Box::pin(async { Ok(false) })
300 }
301
302 fn describe_session<'a>(
305 &'a self,
306 bash_state: &'a Arc<Mutex<Option<BashState>>>,
307 ) -> ShellRuntimeStatusFuture<'a> {
308 Box::pin(async move {
309 let guard = bash_state.lock().await;
310 let state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
311 let (command_running, running_command_id) = {
312 let shell = state.pty_shell.lock().await;
313 shell.as_ref().map_or((false, None), |shell| {
314 (
315 shell.command_running,
316 shell.command_running.then(|| shell.last_command.clone()),
317 )
318 })
319 };
320 let background_command_ids =
321 lock_session_store().background_ids_for(&state.current_thread_id);
322 Ok(ShellSessionStatus {
323 runtime: "embedded",
324 command_running,
325 running_command_id,
326 background_command_ids,
327 ..ShellSessionStatus::default()
328 })
329 })
330 }
331
332 fn interrupt<'a>(
333 &'a self,
334 bash_state: &'a Arc<Mutex<Option<BashState>>>,
335 ) -> ShellRuntimeUnitFuture<'a>;
336
337 fn interrupt_generation<'a>(
340 &'a self,
341 bash_state: &'a Arc<Mutex<Option<BashState>>>,
342 expected_generation: Option<u64>,
343 ) -> ShellRuntimeBoolFuture<'a> {
344 Box::pin(async move {
345 if expected_generation.is_some() {
346 return Ok(false);
347 }
348 self.interrupt(bash_state).await?;
349 Ok(true)
350 })
351 }
352
353 fn interrupt_execution<'a>(
356 &'a self,
357 bash_state: &'a Arc<Mutex<Option<BashState>>>,
358 expected: Option<ShellExecutionToken>,
359 ) -> ShellRuntimeBoolFuture<'a> {
360 self.interrupt_generation(bash_state, expected.map(|token| token.generation))
361 }
362
363 fn cancel_pending_action<'a>(
367 &'a self,
368 _bash_state: &'a Arc<Mutex<Option<BashState>>>,
369 _cancellation_key: &'a str,
370 ) -> ShellRuntimeBoolFuture<'a> {
371 Box::pin(async { Ok(false) })
372 }
373
374 fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a>;
376}
377
378#[derive(Clone, Copy, Debug, Default)]
381pub struct EmbeddedShellRuntime;
382
383impl ShellRuntime for EmbeddedShellRuntime {
384 fn configure_session<'a>(
385 &'a self,
386 bash_state: &'a mut BashState,
387 transition: ShellSessionTransition,
388 ) -> ShellRuntimeConfigureFuture<'a> {
389 Box::pin(async move {
390 let operation_barrier =
391 lock_session_store().operation_barrier(&bash_state.current_thread_id);
392 let _operation = operation_barrier.write().await;
393 if matches!(
394 transition,
395 ShellSessionTransition::FirstCall | ShellSessionTransition::Reset
396 ) && (transition == ShellSessionTransition::Reset || bash_state.cwd.exists())
397 {
398 bash_state.init_pty_shell().await?;
399 }
400 let attach_hint = {
401 let shell = bash_state.pty_shell.lock().await;
402 shell.as_ref().and_then(|shell| shell.attach_hint.clone())
403 };
404 Ok(ShellSessionConfiguration { attach_hint, attached_existing: false })
405 })
406 }
407
408 fn attach_session<'a>(
409 &'a self,
410 _bash_state: &'a mut BashState,
411 ) -> ShellRuntimeConfigureFuture<'a> {
412 Box::pin(async {
413 Ok(ShellSessionConfiguration { attach_hint: None, attached_existing: true })
414 })
415 }
416
417 fn run_action<'a>(
418 &'a self,
419 bash_state: &'a Arc<Mutex<Option<BashState>>>,
420 command: BashCommand,
421 ) -> ShellRuntimeFuture<'a> {
422 Box::pin(crate::tools::bash_command::handle_embedded_tool_call(bash_state, command))
423 }
424
425 fn run_action_detailed<'a>(
426 &'a self,
427 bash_state: &'a Arc<Mutex<Option<BashState>>>,
428 command: BashCommand,
429 options: ShellActionOptions,
430 ) -> ShellRuntimeDetailedFuture<'a> {
431 Box::pin(async move {
432 if options
433 .expected_execution
434 .as_ref()
435 .is_some_and(|expected| expected.guardian_epoch != "embedded")
436 || options
437 .expected_guardian_epoch
438 .as_deref()
439 .is_some_and(|epoch| epoch != "embedded")
440 {
441 return Err(WinxError::InvalidInput(
442 "execution precondition belongs to a different runtime guardian".to_string(),
443 ));
444 }
445 crate::tools::bash_command::handle_embedded_tool_call_detailed(
446 bash_state, command, options,
447 )
448 .await
449 })
450 }
451
452 fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
453 Box::pin(async { Ok(true) })
454 }
455
456 fn supports_agent_workflows_for<'a>(
457 &'a self,
458 _bash_state: &'a Arc<Mutex<Option<BashState>>>,
459 ) -> ShellRuntimeBoolFuture<'a> {
460 Box::pin(async { Ok(true) })
461 }
462
463 fn interrupt<'a>(
464 &'a self,
465 bash_state: &'a Arc<Mutex<Option<BashState>>>,
466 ) -> ShellRuntimeUnitFuture<'a> {
467 Box::pin(interrupt_embedded(bash_state))
468 }
469
470 fn interrupt_generation<'a>(
471 &'a self,
472 bash_state: &'a Arc<Mutex<Option<BashState>>>,
473 expected_generation: Option<u64>,
474 ) -> ShellRuntimeBoolFuture<'a> {
475 Box::pin(interrupt_embedded_generation(bash_state, expected_generation))
476 }
477
478 fn interrupt_execution<'a>(
479 &'a self,
480 bash_state: &'a Arc<Mutex<Option<BashState>>>,
481 expected: Option<ShellExecutionToken>,
482 ) -> ShellRuntimeBoolFuture<'a> {
483 Box::pin(interrupt_embedded_execution(bash_state, expected))
484 }
485
486 fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
487 Box::pin(async move {
488 let operation_barrier = lock_session_store().operation_barrier(thread_id);
489 let _operation = operation_barrier.write().await;
490 let shell = lock_session_store().resolve(thread_id, &ShellTarget::Main);
491 if let Some(shell) = shell {
492 *shell.lock().await = None;
493 }
494 Ok(())
495 })
496 }
497}
498
499async fn interrupt_embedded(bash_state: &Arc<Mutex<Option<BashState>>>) -> Result<()> {
500 let _ = interrupt_embedded_generation(bash_state, None).await?;
501 Ok(())
502}
503
504const INTERRUPT_RECOVERY_TIMEOUT: Duration = Duration::from_secs(3);
505const INTERRUPT_RETRY_INTERVAL: Duration = Duration::from_millis(250);
506const INTERRUPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
507const MAX_INTERRUPT_ATTEMPTS: u32 = 3;
508
509pub(crate) async fn interrupt_pty_until_recovered(
515 shell: &SharedPtyShell,
516 expected_generation: Option<u64>,
517) -> Result<bool> {
518 let deadline = Instant::now() + INTERRUPT_RECOVERY_TIMEOUT;
519 let mut next_interrupt = Instant::now();
520 let mut attempts = 0_u32;
521
522 loop {
523 let now = Instant::now();
524 let recovered = {
525 let mut guard = shell.lock().await;
526 let Some(pty) = guard.as_mut() else { return Ok(false) };
527 if expected_generation.is_some_and(|expected| pty.command_generation() != expected) {
528 return Ok(false);
529 }
530
531 if pty.poll_output_nonblocking() || !pty.command_running {
532 if attempts == 0 {
533 return Ok(false);
534 }
535 true
536 } else {
537 if attempts < MAX_INTERRUPT_ATTEMPTS && now >= next_interrupt {
538 pty.send_interrupt().map_err(|error| {
539 WinxError::CommandExecutionError(format!(
540 "failed to interrupt shell: {error}"
541 ))
542 })?;
543 attempts += 1;
544 next_interrupt = Instant::now() + INTERRUPT_RETRY_INTERVAL;
545 if attempts > 1 {
546 tracing::debug!(attempts, "retrying Ctrl+C for active PTY generation");
547 }
548 }
549 false
550 }
551 };
552
553 if recovered {
554 return Ok(true);
555 }
556 if Instant::now() >= deadline {
557 break;
558 }
559 tokio::time::sleep(INTERRUPT_POLL_INTERVAL).await;
560 }
561
562 Err(WinxError::CommandExecutionError(
563 "interrupted shell did not return to a prompt within 3 seconds".to_string(),
564 ))
565}
566
567async fn interrupt_embedded_generation(
568 bash_state: &Arc<Mutex<Option<BashState>>>,
569 expected_generation: Option<u64>,
570) -> Result<bool> {
571 interrupt_embedded_guarded(bash_state, expected_generation, None).await
572}
573
574async fn interrupt_embedded_execution(
575 bash_state: &Arc<Mutex<Option<BashState>>>,
576 expected: Option<ShellExecutionToken>,
577) -> Result<bool> {
578 interrupt_embedded_guarded(
579 bash_state,
580 expected.as_ref().map(|token| token.generation),
581 expected.as_ref(),
582 )
583 .await
584}
585
586async fn interrupt_embedded_guarded(
587 bash_state: &Arc<Mutex<Option<BashState>>>,
588 expected_generation: Option<u64>,
589 expected_execution: Option<&ShellExecutionToken>,
590) -> Result<bool> {
591 let (shell, _operation) = {
594 let state = bash_state.lock().await;
595 let Some(state) = state.as_ref() else { return Ok(false) };
596 let operation_barrier = lock_session_store().operation_barrier(&state.current_thread_id);
597 let operation = operation_barrier.read_owned().await;
598 (state.pty_shell.clone(), operation)
599 };
600 {
601 let guard = shell.lock().await;
602 if let Some(pty) = guard.as_ref() {
603 if expected_execution.is_some_and(|expected| {
604 expected.guardian_epoch != "embedded"
605 || expected.session_epoch != format!("{:016x}", pty.incarnation())
606 }) {
607 return Ok(false);
608 }
609 }
610 }
611 interrupt_pty_until_recovered(&shell, expected_generation).await
612}
613
614pub(crate) async fn release_temporary_at_owner(
619 slot: &Arc<Mutex<Option<BashState>>>,
620 request: crate::utils::agent_temp::release::TemporaryReleaseRequest,
621) -> Result<crate::utils::agent_temp::release::TemporaryReleaseResult> {
622 let state = slot.lock().await;
623 let state = state.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
624 if state.current_thread_id != request.thread_id
625 || state.workspace_root != request.workspace_root
626 {
627 return Err(WinxError::InvalidInput(
628 "temporary release scope differs from initialized runtime".into(),
629 ));
630 }
631 for file in &request.files {
632 if !state.is_file_edit_allowed(&file.target.file_path) {
633 return Err(WinxError::FileOperationDenied {
634 path: file.target.file_path.clone().into(),
635 message: "temporary release is not allowed in the current runtime mode".into(),
636 });
637 }
638 }
639 let barrier = lock_session_store().operation_barrier(&request.thread_id);
640 let operation = barrier
641 .try_write_owned()
642 .map_err(|_| WinxError::InvalidInput("in_use: a runtime operation is active".into()))?;
643 {
644 let shell = state.pty_shell.lock().await;
645 let shell = shell.as_ref().ok_or_else(|| {
646 WinxError::InvalidInput("unresolved_reference: runtime shell is unavailable".into())
647 })?;
648 if shell.command_running {
649 return Err(WinxError::InvalidInput("in_use: foreground execution is active".into()));
650 }
651 }
652 let backgrounds = lock_session_store().background_ids_for(&request.thread_id);
653 for id in backgrounds {
654 let shell = lock_session_store().resolve(&request.thread_id, &ShellTarget::Background(id));
655 let Some(shell) = shell else {
656 return Err(WinxError::InvalidInput(
657 "unresolved_reference: background execution state is unavailable".into(),
658 ));
659 };
660 let shell = shell.lock().await;
661 if shell.as_ref().is_none_or(|shell| shell.command_running) {
662 return Err(WinxError::InvalidInput(
663 "in_use: background execution is active or unresolved".into(),
664 ));
665 }
666 }
667 tokio::task::spawn_blocking(move || {
668 let _operation = operation;
669 crate::utils::agent_temp::release::release(&request)
670 })
671 .await
672 .map_err(|error| {
673 WinxError::CommandExecutionError(format!("temporary release worker failed: {error}"))
674 })?
675}
676
677#[cfg(all(test, target_os = "linux"))]
678mod temporary_release_runtime_tests {
679 #![allow(clippy::unwrap_used, clippy::expect_used)]
680 use super::*;
681 use crate::utils::agent_temp::release::{prepare, read_managed, TemporaryReleaseTarget};
682
683 #[tokio::test]
684 async fn live_background_execution_blocks_release_after_inline_return() -> anyhow::Result<()> {
685 let workspace = tempfile::tempdir()?;
686 let root = workspace.path().canonicalize()?;
687 let thread = crate::types::normalize_thread_id(&format!(
688 "release-runtime-{:032x}",
689 rand::random::<u128>()
690 ));
691 let helper =
692 crate::utils::agent_temp::session_info(&root, &thread).directory.join("helper");
693 std::fs::create_dir_all(helper.parent().unwrap())?;
694 std::fs::write(&helper, "keep")?;
695 let (text, metadata) = read_managed(&root, &thread, &helper)?
696 .ok_or_else(|| anyhow::anyhow!("generation missing"))?;
697 let target = TemporaryReleaseTarget {
698 file_path: helper.to_string_lossy().into_owned(),
699 expected_revision: format!(
700 "sha256:{}",
701 crate::tools::file_write_or_edit::hash_content(&text)
702 ),
703 artifact_generation: metadata.artifact_generation,
704 };
705 let request = prepare(&root, &thread, &[target])?;
706 let mut state = BashState::new();
707 state.current_thread_id = thread.clone();
708 state.workspace_root = root.clone();
709 state.cwd = root;
710 state.mode = crate::types::Modes::Wcgw;
711 state.init_pty_shell().await?;
712 let slot = Arc::new(Mutex::new(Some(state)));
713 let command: BashCommand = serde_json::from_value(serde_json::json!({
714 "thread_id": thread, "wait_for_seconds": 0.01,
715 "action_json": { "type": "command", "command": "sleep 30", "is_background": true, "allow_multi": false }
716 }))?;
717 EmbeddedShellRuntime
718 .run_action_detailed(&slot, command, ShellActionOptions::default())
719 .await?;
720 let outcome = release_temporary_at_owner(&slot, request).await;
721 let ids = lock_session_store().background_ids_for(&thread);
723 for id in ids {
724 lock_session_store().remove_shell(&id);
725 }
726 assert!(outcome.is_err(), "a returned inline wait does not end execution ownership");
727 assert_eq!(std::fs::read_to_string(helper)?, "keep");
728 Ok(())
729 }
730}