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, Copy, Debug, Eq, PartialEq)]
170pub enum ShellSessionTransition {
171 FirstCall,
172 ModeChange,
173 Reset,
174 WorkspaceChange,
175}
176
177pub trait ShellRuntime: Send + Sync {
179 fn configure_session<'a>(
180 &'a self,
181 bash_state: &'a mut BashState,
182 transition: ShellSessionTransition,
183 ) -> ShellRuntimeConfigureFuture<'a>;
184
185 fn attach_session<'a>(
189 &'a self,
190 bash_state: &'a mut BashState,
191 ) -> ShellRuntimeConfigureFuture<'a> {
192 self.configure_session(bash_state, ShellSessionTransition::ModeChange)
193 }
194
195 fn run_action<'a>(
196 &'a self,
197 bash_state: &'a Arc<Mutex<Option<BashState>>>,
198 command: BashCommand,
199 ) -> ShellRuntimeFuture<'a>;
200
201 fn run_action_detailed<'a>(
204 &'a self,
205 bash_state: &'a Arc<Mutex<Option<BashState>>>,
206 command: BashCommand,
207 _options: ShellActionOptions,
208 ) -> ShellRuntimeDetailedFuture<'a> {
209 Box::pin(async move {
210 self.run_action(bash_state, command).await.map(BashCommandRuntimeResult::legacy)
211 })
212 }
213
214 fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
215 Box::pin(async { Ok(false) })
216 }
217
218 fn supports_generation_bound_actions_for<'a>(
222 &'a self,
223 _bash_state: &'a Arc<Mutex<Option<BashState>>>,
224 ) -> ShellRuntimeBoolFuture<'a> {
225 self.supports_generation_bound_actions()
226 }
227
228 fn interrupt<'a>(
229 &'a self,
230 bash_state: &'a Arc<Mutex<Option<BashState>>>,
231 ) -> ShellRuntimeUnitFuture<'a>;
232
233 fn interrupt_generation<'a>(
236 &'a self,
237 bash_state: &'a Arc<Mutex<Option<BashState>>>,
238 expected_generation: Option<u64>,
239 ) -> ShellRuntimeBoolFuture<'a> {
240 Box::pin(async move {
241 if expected_generation.is_some() {
242 return Ok(false);
243 }
244 self.interrupt(bash_state).await?;
245 Ok(true)
246 })
247 }
248
249 fn interrupt_execution<'a>(
252 &'a self,
253 bash_state: &'a Arc<Mutex<Option<BashState>>>,
254 expected: Option<ShellExecutionToken>,
255 ) -> ShellRuntimeBoolFuture<'a> {
256 self.interrupt_generation(bash_state, expected.map(|token| token.generation))
257 }
258
259 fn cancel_pending_action<'a>(
263 &'a self,
264 _bash_state: &'a Arc<Mutex<Option<BashState>>>,
265 _cancellation_key: &'a str,
266 ) -> ShellRuntimeBoolFuture<'a> {
267 Box::pin(async { Ok(false) })
268 }
269
270 fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a>;
272}
273
274#[derive(Clone, Copy, Debug, Default)]
277pub struct EmbeddedShellRuntime;
278
279impl ShellRuntime for EmbeddedShellRuntime {
280 fn configure_session<'a>(
281 &'a self,
282 bash_state: &'a mut BashState,
283 transition: ShellSessionTransition,
284 ) -> ShellRuntimeConfigureFuture<'a> {
285 Box::pin(async move {
286 let operation_barrier =
287 lock_session_store().operation_barrier(&bash_state.current_thread_id);
288 let _operation = operation_barrier.write().await;
289 if matches!(
290 transition,
291 ShellSessionTransition::FirstCall | ShellSessionTransition::Reset
292 ) && (transition == ShellSessionTransition::Reset || bash_state.cwd.exists())
293 {
294 bash_state.init_pty_shell().await?;
295 }
296 let attach_hint = {
297 let shell = bash_state.pty_shell.lock().await;
298 shell.as_ref().and_then(|shell| shell.attach_hint.clone())
299 };
300 Ok(ShellSessionConfiguration { attach_hint, attached_existing: false })
301 })
302 }
303
304 fn attach_session<'a>(
305 &'a self,
306 _bash_state: &'a mut BashState,
307 ) -> ShellRuntimeConfigureFuture<'a> {
308 Box::pin(async {
309 Ok(ShellSessionConfiguration { attach_hint: None, attached_existing: true })
310 })
311 }
312
313 fn run_action<'a>(
314 &'a self,
315 bash_state: &'a Arc<Mutex<Option<BashState>>>,
316 command: BashCommand,
317 ) -> ShellRuntimeFuture<'a> {
318 Box::pin(crate::tools::bash_command::handle_embedded_tool_call(bash_state, command))
319 }
320
321 fn run_action_detailed<'a>(
322 &'a self,
323 bash_state: &'a Arc<Mutex<Option<BashState>>>,
324 command: BashCommand,
325 options: ShellActionOptions,
326 ) -> ShellRuntimeDetailedFuture<'a> {
327 Box::pin(crate::tools::bash_command::handle_embedded_tool_call_detailed(
328 bash_state, command, options,
329 ))
330 }
331
332 fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
333 Box::pin(async { Ok(true) })
334 }
335
336 fn interrupt<'a>(
337 &'a self,
338 bash_state: &'a Arc<Mutex<Option<BashState>>>,
339 ) -> ShellRuntimeUnitFuture<'a> {
340 Box::pin(interrupt_embedded(bash_state))
341 }
342
343 fn interrupt_generation<'a>(
344 &'a self,
345 bash_state: &'a Arc<Mutex<Option<BashState>>>,
346 expected_generation: Option<u64>,
347 ) -> ShellRuntimeBoolFuture<'a> {
348 Box::pin(interrupt_embedded_generation(bash_state, expected_generation))
349 }
350
351 fn interrupt_execution<'a>(
352 &'a self,
353 bash_state: &'a Arc<Mutex<Option<BashState>>>,
354 expected: Option<ShellExecutionToken>,
355 ) -> ShellRuntimeBoolFuture<'a> {
356 Box::pin(interrupt_embedded_execution(bash_state, expected))
357 }
358
359 fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
360 Box::pin(async move {
361 let operation_barrier = lock_session_store().operation_barrier(thread_id);
362 let _operation = operation_barrier.write().await;
363 let shell = lock_session_store().resolve(thread_id, &ShellTarget::Main);
364 if let Some(shell) = shell {
365 *shell.lock().await = None;
366 }
367 Ok(())
368 })
369 }
370}
371
372async fn interrupt_embedded(bash_state: &Arc<Mutex<Option<BashState>>>) -> Result<()> {
373 let _ = interrupt_embedded_generation(bash_state, None).await?;
374 Ok(())
375}
376
377const INTERRUPT_RECOVERY_TIMEOUT: Duration = Duration::from_secs(3);
378const INTERRUPT_RETRY_INTERVAL: Duration = Duration::from_millis(250);
379const INTERRUPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
380const MAX_INTERRUPT_ATTEMPTS: u32 = 3;
381
382pub(crate) async fn interrupt_pty_until_recovered(
388 shell: &SharedPtyShell,
389 expected_generation: Option<u64>,
390) -> Result<bool> {
391 let deadline = Instant::now() + INTERRUPT_RECOVERY_TIMEOUT;
392 let mut next_interrupt = Instant::now();
393 let mut attempts = 0_u32;
394
395 loop {
396 let now = Instant::now();
397 let recovered = {
398 let mut guard = shell.lock().await;
399 let Some(pty) = guard.as_mut() else { return Ok(false) };
400 if expected_generation.is_some_and(|expected| pty.command_generation() != expected) {
401 return Ok(false);
402 }
403
404 if pty.poll_output_nonblocking() || !pty.command_running {
405 if attempts == 0 {
406 return Ok(false);
407 }
408 true
409 } else {
410 if attempts < MAX_INTERRUPT_ATTEMPTS && now >= next_interrupt {
411 pty.send_interrupt().map_err(|error| {
412 WinxError::CommandExecutionError(format!(
413 "failed to interrupt shell: {error}"
414 ))
415 })?;
416 attempts += 1;
417 next_interrupt = Instant::now() + INTERRUPT_RETRY_INTERVAL;
418 if attempts > 1 {
419 tracing::debug!(attempts, "retrying Ctrl+C for active PTY generation");
420 }
421 }
422 false
423 }
424 };
425
426 if recovered {
427 return Ok(true);
428 }
429 if Instant::now() >= deadline {
430 break;
431 }
432 tokio::time::sleep(INTERRUPT_POLL_INTERVAL).await;
433 }
434
435 Err(WinxError::CommandExecutionError(
436 "interrupted shell did not return to a prompt within 3 seconds".to_string(),
437 ))
438}
439
440async fn interrupt_embedded_generation(
441 bash_state: &Arc<Mutex<Option<BashState>>>,
442 expected_generation: Option<u64>,
443) -> Result<bool> {
444 interrupt_embedded_guarded(bash_state, expected_generation, None).await
445}
446
447async fn interrupt_embedded_execution(
448 bash_state: &Arc<Mutex<Option<BashState>>>,
449 expected: Option<ShellExecutionToken>,
450) -> Result<bool> {
451 interrupt_embedded_guarded(
452 bash_state,
453 expected.as_ref().map(|token| token.generation),
454 expected.as_ref(),
455 )
456 .await
457}
458
459async fn interrupt_embedded_guarded(
460 bash_state: &Arc<Mutex<Option<BashState>>>,
461 expected_generation: Option<u64>,
462 expected_execution: Option<&ShellExecutionToken>,
463) -> Result<bool> {
464 let (shell, _operation) = {
467 let state = bash_state.lock().await;
468 let Some(state) = state.as_ref() else { return Ok(false) };
469 let operation_barrier = lock_session_store().operation_barrier(&state.current_thread_id);
470 let operation = operation_barrier.read_owned().await;
471 (state.pty_shell.clone(), operation)
472 };
473 {
474 let guard = shell.lock().await;
475 if let Some(pty) = guard.as_ref() {
476 if expected_execution.is_some_and(|expected| {
477 expected.guardian_epoch != "embedded"
478 || expected.session_epoch != format!("{:016x}", pty.incarnation())
479 }) {
480 return Ok(false);
481 }
482 }
483 }
484 interrupt_pty_until_recovered(&shell, expected_generation).await
485}