1mod selection;
4mod session_store;
5
6use std::future::Future;
7use std::path::PathBuf;
8use std::pin::Pin;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13use serde::{Deserialize, Serialize};
14use tokio::sync::Mutex;
15
16use crate::errors::{Result, WinxError};
17use crate::state::bash_state::BashState;
18use crate::state::pty::SharedPtyShell;
19use crate::tools::bash_command::BashCommandResult;
20use crate::types::BashCommand;
21
22pub use selection::{
23 configured_daemon_binary, ensure_control_daemon_at, ensure_daemon_at, restart_control_daemon_at,
24};
25pub use selection::{
26 configured_runtime_mode, configured_shell_runtime, select_runtime_mode, RuntimeMode,
27};
28pub(crate) use session_store::lock_session_store;
29pub use session_store::{SessionStore, ShellTarget};
30
31pub type ShellRuntimeFuture<'a> =
33 Pin<Box<dyn Future<Output = Result<BashCommandResult>> + Send + 'a>>;
34pub type ShellRuntimeUnitFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
35pub type ShellRuntimeBoolFuture<'a> = Pin<Box<dyn Future<Output = Result<bool>> + Send + 'a>>;
36pub type ShellRuntimeDetailedFuture<'a> =
37 Pin<Box<dyn Future<Output = Result<BashCommandRuntimeResult>> + Send + 'a>>;
38
39#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct BashCommandRuntimeResult {
44 pub result: BashCommandResult,
45 pub compact_output: Option<String>,
46 pub command_generation: Option<u64>,
47 pub execution_token: Option<ShellExecutionToken>,
48 pub generation_bound_actions: bool,
49 pub dropped_output_file: Option<PathBuf>,
53 pub output_truncated: bool,
56}
57
58impl BashCommandRuntimeResult {
59 pub fn legacy(result: BashCommandResult) -> Self {
60 Self {
61 result,
62 compact_output: None,
63 command_generation: None,
64 execution_token: None,
65 generation_bound_actions: false,
66 dropped_output_file: None,
67 output_truncated: false,
68 }
69 }
70
71 pub(crate) fn with_dropped_output_file(mut self, path: Option<PathBuf>) -> Self {
72 self.dropped_output_file = path;
73 self
74 }
75}
76
77#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
78pub struct ShellExecutionToken {
79 pub guardian_epoch: String,
80 pub session_epoch: String,
81 pub generation: u64,
82}
83
84#[derive(Clone, Debug, Default, Serialize, Deserialize)]
85pub struct ShellActionOptions {
86 pub compact_output: bool,
87 pub expected_generation: Option<u64>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub expected_execution: Option<ShellExecutionToken>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub expected_guardian_epoch: Option<String>,
96 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
99 pub require_generation_binding: bool,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub cancellation_key: Option<String>,
104 #[serde(skip)]
107 pub launch_cancelled: Option<Arc<AtomicBool>>,
108 #[cfg(test)]
111 #[serde(skip)]
112 pub(crate) force_clear_to_run_failure: bool,
113}
114
115impl ShellActionOptions {
116 pub(crate) fn is_default(&self) -> bool {
117 let is_default = !self.compact_output
118 && self.expected_generation.is_none()
119 && self.expected_execution.is_none()
120 && self.expected_guardian_epoch.is_none()
121 && !self.require_generation_binding
122 && self.cancellation_key.is_none();
123 #[cfg(test)]
124 {
125 is_default && !self.force_clear_to_run_failure
126 }
127 #[cfg(not(test))]
128 {
129 is_default
130 }
131 }
132
133 pub(crate) fn is_launch_cancelled(&self) -> bool {
134 self.launch_cancelled.as_ref().is_some_and(|flag| flag.load(Ordering::SeqCst))
135 }
136}
137
138impl PartialEq for ShellActionOptions {
139 fn eq(&self, other: &Self) -> bool {
140 let equal = self.compact_output == other.compact_output
141 && self.expected_generation == other.expected_generation
142 && self.expected_execution == other.expected_execution
143 && self.expected_guardian_epoch == other.expected_guardian_epoch
144 && self.require_generation_binding == other.require_generation_binding
145 && self.cancellation_key == other.cancellation_key;
146 #[cfg(test)]
147 {
148 equal && self.force_clear_to_run_failure == other.force_clear_to_run_failure
149 }
150 #[cfg(not(test))]
151 {
152 equal
153 }
154 }
155}
156
157impl Eq for ShellActionOptions {}
158
159#[derive(Clone, Debug, Default, Eq, PartialEq)]
160pub struct ShellSessionConfiguration {
161 pub attach_hint: Option<String>,
162 pub attached_existing: bool,
163}
164
165pub type ShellRuntimeConfigureFuture<'a> =
166 Pin<Box<dyn Future<Output = Result<ShellSessionConfiguration>> + Send + 'a>>;
167
168#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize)]
171pub struct ShellSessionStatus {
172 pub runtime: &'static str,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub guardian_pid: Option<u32>,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub guardian_epoch: Option<String>,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub guardian_build: Option<String>,
181 pub command_running: bool,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub running_command_id: Option<String>,
184 pub background_command_ids: Vec<String>,
185 #[serde(skip_serializing_if = "Option::is_none")]
186 pub last_activity_unix_ms: Option<u64>,
187}
188
189pub type ShellRuntimeStatusFuture<'a> =
190 Pin<Box<dyn Future<Output = Result<ShellSessionStatus>> + Send + 'a>>;
191
192#[derive(Clone, Copy, Debug, Eq, PartialEq)]
194pub enum ShellSessionTransition {
195 FirstCall,
196 ModeChange,
197 Reset,
198 WorkspaceChange,
199}
200
201pub trait ShellRuntime: Send + Sync {
203 fn configure_session<'a>(
204 &'a self,
205 bash_state: &'a mut BashState,
206 transition: ShellSessionTransition,
207 ) -> ShellRuntimeConfigureFuture<'a>;
208
209 fn attach_session<'a>(
213 &'a self,
214 bash_state: &'a mut BashState,
215 ) -> ShellRuntimeConfigureFuture<'a> {
216 self.configure_session(bash_state, ShellSessionTransition::ModeChange)
217 }
218
219 fn run_action<'a>(
220 &'a self,
221 bash_state: &'a Arc<Mutex<Option<BashState>>>,
222 command: BashCommand,
223 ) -> ShellRuntimeFuture<'a>;
224
225 fn run_action_detailed<'a>(
228 &'a self,
229 bash_state: &'a Arc<Mutex<Option<BashState>>>,
230 command: BashCommand,
231 _options: ShellActionOptions,
232 ) -> ShellRuntimeDetailedFuture<'a> {
233 Box::pin(async move {
234 self.run_action(bash_state, command).await.map(BashCommandRuntimeResult::legacy)
235 })
236 }
237
238 fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
239 Box::pin(async { Ok(false) })
240 }
241
242 fn supports_generation_bound_actions_for<'a>(
246 &'a self,
247 _bash_state: &'a Arc<Mutex<Option<BashState>>>,
248 ) -> ShellRuntimeBoolFuture<'a> {
249 self.supports_generation_bound_actions()
250 }
251
252 fn describe_session<'a>(
255 &'a self,
256 bash_state: &'a Arc<Mutex<Option<BashState>>>,
257 ) -> ShellRuntimeStatusFuture<'a> {
258 Box::pin(async move {
259 let guard = bash_state.lock().await;
260 let state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
261 let (command_running, running_command_id) = {
262 let shell = state.pty_shell.lock().await;
263 shell.as_ref().map_or((false, None), |shell| {
264 (
265 shell.command_running,
266 shell.command_running.then(|| shell.last_command.clone()),
267 )
268 })
269 };
270 let background_command_ids =
271 lock_session_store().background_ids_for(&state.current_thread_id);
272 Ok(ShellSessionStatus {
273 runtime: "embedded",
274 command_running,
275 running_command_id,
276 background_command_ids,
277 ..ShellSessionStatus::default()
278 })
279 })
280 }
281
282 fn interrupt<'a>(
283 &'a self,
284 bash_state: &'a Arc<Mutex<Option<BashState>>>,
285 ) -> ShellRuntimeUnitFuture<'a>;
286
287 fn interrupt_generation<'a>(
290 &'a self,
291 bash_state: &'a Arc<Mutex<Option<BashState>>>,
292 expected_generation: Option<u64>,
293 ) -> ShellRuntimeBoolFuture<'a> {
294 Box::pin(async move {
295 if expected_generation.is_some() {
296 return Ok(false);
297 }
298 self.interrupt(bash_state).await?;
299 Ok(true)
300 })
301 }
302
303 fn interrupt_execution<'a>(
306 &'a self,
307 bash_state: &'a Arc<Mutex<Option<BashState>>>,
308 expected: Option<ShellExecutionToken>,
309 ) -> ShellRuntimeBoolFuture<'a> {
310 self.interrupt_generation(bash_state, expected.map(|token| token.generation))
311 }
312
313 fn cancel_pending_action<'a>(
317 &'a self,
318 _bash_state: &'a Arc<Mutex<Option<BashState>>>,
319 _cancellation_key: &'a str,
320 ) -> ShellRuntimeBoolFuture<'a> {
321 Box::pin(async { Ok(false) })
322 }
323
324 fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a>;
326}
327
328#[derive(Clone, Copy, Debug, Default)]
331pub struct EmbeddedShellRuntime;
332
333impl ShellRuntime for EmbeddedShellRuntime {
334 fn configure_session<'a>(
335 &'a self,
336 bash_state: &'a mut BashState,
337 transition: ShellSessionTransition,
338 ) -> ShellRuntimeConfigureFuture<'a> {
339 Box::pin(async move {
340 let operation_barrier =
341 lock_session_store().operation_barrier(&bash_state.current_thread_id);
342 let _operation = operation_barrier.write().await;
343 if matches!(
344 transition,
345 ShellSessionTransition::FirstCall | ShellSessionTransition::Reset
346 ) && (transition == ShellSessionTransition::Reset || bash_state.cwd.exists())
347 {
348 bash_state.init_pty_shell().await?;
349 }
350 let attach_hint = {
351 let shell = bash_state.pty_shell.lock().await;
352 shell.as_ref().and_then(|shell| shell.attach_hint.clone())
353 };
354 Ok(ShellSessionConfiguration { attach_hint, attached_existing: false })
355 })
356 }
357
358 fn attach_session<'a>(
359 &'a self,
360 _bash_state: &'a mut BashState,
361 ) -> ShellRuntimeConfigureFuture<'a> {
362 Box::pin(async {
363 Ok(ShellSessionConfiguration { attach_hint: None, attached_existing: true })
364 })
365 }
366
367 fn run_action<'a>(
368 &'a self,
369 bash_state: &'a Arc<Mutex<Option<BashState>>>,
370 command: BashCommand,
371 ) -> ShellRuntimeFuture<'a> {
372 Box::pin(crate::tools::bash_command::handle_embedded_tool_call(bash_state, command))
373 }
374
375 fn run_action_detailed<'a>(
376 &'a self,
377 bash_state: &'a Arc<Mutex<Option<BashState>>>,
378 command: BashCommand,
379 options: ShellActionOptions,
380 ) -> ShellRuntimeDetailedFuture<'a> {
381 Box::pin(crate::tools::bash_command::handle_embedded_tool_call_detailed(
382 bash_state, command, options,
383 ))
384 }
385
386 fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
387 Box::pin(async { Ok(true) })
388 }
389
390 fn interrupt<'a>(
391 &'a self,
392 bash_state: &'a Arc<Mutex<Option<BashState>>>,
393 ) -> ShellRuntimeUnitFuture<'a> {
394 Box::pin(interrupt_embedded(bash_state))
395 }
396
397 fn interrupt_generation<'a>(
398 &'a self,
399 bash_state: &'a Arc<Mutex<Option<BashState>>>,
400 expected_generation: Option<u64>,
401 ) -> ShellRuntimeBoolFuture<'a> {
402 Box::pin(interrupt_embedded_generation(bash_state, expected_generation))
403 }
404
405 fn interrupt_execution<'a>(
406 &'a self,
407 bash_state: &'a Arc<Mutex<Option<BashState>>>,
408 expected: Option<ShellExecutionToken>,
409 ) -> ShellRuntimeBoolFuture<'a> {
410 Box::pin(interrupt_embedded_execution(bash_state, expected))
411 }
412
413 fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
414 Box::pin(async move {
415 let operation_barrier = lock_session_store().operation_barrier(thread_id);
416 let _operation = operation_barrier.write().await;
417 let shell = lock_session_store().resolve(thread_id, &ShellTarget::Main);
418 if let Some(shell) = shell {
419 *shell.lock().await = None;
420 }
421 Ok(())
422 })
423 }
424}
425
426async fn interrupt_embedded(bash_state: &Arc<Mutex<Option<BashState>>>) -> Result<()> {
427 let _ = interrupt_embedded_generation(bash_state, None).await?;
428 Ok(())
429}
430
431const INTERRUPT_RECOVERY_TIMEOUT: Duration = Duration::from_secs(3);
432const INTERRUPT_RETRY_INTERVAL: Duration = Duration::from_millis(250);
433const INTERRUPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
434const MAX_INTERRUPT_ATTEMPTS: u32 = 3;
435
436pub(crate) async fn interrupt_pty_until_recovered(
442 shell: &SharedPtyShell,
443 expected_generation: Option<u64>,
444) -> Result<bool> {
445 let deadline = Instant::now() + INTERRUPT_RECOVERY_TIMEOUT;
446 let mut next_interrupt = Instant::now();
447 let mut attempts = 0_u32;
448
449 loop {
450 let now = Instant::now();
451 let recovered = {
452 let mut guard = shell.lock().await;
453 let Some(pty) = guard.as_mut() else { return Ok(false) };
454 if expected_generation.is_some_and(|expected| pty.command_generation() != expected) {
455 return Ok(false);
456 }
457
458 if pty.poll_output_nonblocking() || !pty.command_running {
459 if attempts == 0 {
460 return Ok(false);
461 }
462 true
463 } else {
464 if attempts < MAX_INTERRUPT_ATTEMPTS && now >= next_interrupt {
465 pty.send_interrupt().map_err(|error| {
466 WinxError::CommandExecutionError(format!(
467 "failed to interrupt shell: {error}"
468 ))
469 })?;
470 attempts += 1;
471 next_interrupt = Instant::now() + INTERRUPT_RETRY_INTERVAL;
472 if attempts > 1 {
473 tracing::debug!(attempts, "retrying Ctrl+C for active PTY generation");
474 }
475 }
476 false
477 }
478 };
479
480 if recovered {
481 return Ok(true);
482 }
483 if Instant::now() >= deadline {
484 break;
485 }
486 tokio::time::sleep(INTERRUPT_POLL_INTERVAL).await;
487 }
488
489 Err(WinxError::CommandExecutionError(
490 "interrupted shell did not return to a prompt within 3 seconds".to_string(),
491 ))
492}
493
494async fn interrupt_embedded_generation(
495 bash_state: &Arc<Mutex<Option<BashState>>>,
496 expected_generation: Option<u64>,
497) -> Result<bool> {
498 interrupt_embedded_guarded(bash_state, expected_generation, None).await
499}
500
501async fn interrupt_embedded_execution(
502 bash_state: &Arc<Mutex<Option<BashState>>>,
503 expected: Option<ShellExecutionToken>,
504) -> Result<bool> {
505 interrupt_embedded_guarded(
506 bash_state,
507 expected.as_ref().map(|token| token.generation),
508 expected.as_ref(),
509 )
510 .await
511}
512
513async fn interrupt_embedded_guarded(
514 bash_state: &Arc<Mutex<Option<BashState>>>,
515 expected_generation: Option<u64>,
516 expected_execution: Option<&ShellExecutionToken>,
517) -> Result<bool> {
518 let (shell, _operation) = {
521 let state = bash_state.lock().await;
522 let Some(state) = state.as_ref() else { return Ok(false) };
523 let operation_barrier = lock_session_store().operation_barrier(&state.current_thread_id);
524 let operation = operation_barrier.read_owned().await;
525 (state.pty_shell.clone(), operation)
526 };
527 {
528 let guard = shell.lock().await;
529 if let Some(pty) = guard.as_ref() {
530 if expected_execution.is_some_and(|expected| {
531 expected.guardian_epoch != "embedded"
532 || expected.session_epoch != format!("{:016x}", pty.incarnation())
533 }) {
534 return Ok(false);
535 }
536 }
537 }
538 interrupt_pty_until_recovered(&shell, expected_generation).await
539}