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
22#[cfg(unix)]
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 generation_bound_actions: bool,
50 pub dropped_output_file: Option<PathBuf>,
54 pub output_truncated: bool,
57}
58
59impl BashCommandRuntimeResult {
60 pub fn legacy(result: BashCommandResult) -> Self {
61 Self {
62 result,
63 compact_output: None,
64 command_generation: None,
65 execution_token: None,
66 generation_bound_actions: false,
67 dropped_output_file: None,
68 output_truncated: false,
69 }
70 }
71
72 pub(crate) fn with_dropped_output_file(mut self, path: Option<PathBuf>) -> Self {
73 self.dropped_output_file = path;
74 self
75 }
76}
77
78#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
79pub struct ShellExecutionToken {
80 pub guardian_epoch: String,
81 pub session_epoch: String,
82 pub generation: u64,
83}
84
85#[derive(Clone, Debug, Default, Serialize, Deserialize)]
86pub struct ShellActionOptions {
87 pub compact_output: bool,
88 pub expected_generation: Option<u64>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub expected_execution: Option<ShellExecutionToken>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub expected_guardian_epoch: Option<String>,
97 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
100 pub require_generation_binding: bool,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub cancellation_key: Option<String>,
105 #[serde(skip)]
108 pub launch_cancelled: Option<Arc<AtomicBool>>,
109 #[cfg(test)]
112 #[serde(skip)]
113 pub(crate) force_clear_to_run_failure: bool,
114}
115
116impl ShellActionOptions {
117 pub(crate) fn is_default(&self) -> bool {
118 let is_default = !self.compact_output
119 && self.expected_generation.is_none()
120 && self.expected_execution.is_none()
121 && self.expected_guardian_epoch.is_none()
122 && !self.require_generation_binding
123 && self.cancellation_key.is_none();
124 #[cfg(test)]
125 {
126 is_default && !self.force_clear_to_run_failure
127 }
128 #[cfg(not(test))]
129 {
130 is_default
131 }
132 }
133
134 pub(crate) fn is_launch_cancelled(&self) -> bool {
135 self.launch_cancelled.as_ref().is_some_and(|flag| flag.load(Ordering::SeqCst))
136 }
137}
138
139impl PartialEq for ShellActionOptions {
140 fn eq(&self, other: &Self) -> bool {
141 let equal = self.compact_output == other.compact_output
142 && self.expected_generation == other.expected_generation
143 && self.expected_execution == other.expected_execution
144 && self.expected_guardian_epoch == other.expected_guardian_epoch
145 && self.require_generation_binding == other.require_generation_binding
146 && self.cancellation_key == other.cancellation_key;
147 #[cfg(test)]
148 {
149 equal && self.force_clear_to_run_failure == other.force_clear_to_run_failure
150 }
151 #[cfg(not(test))]
152 {
153 equal
154 }
155 }
156}
157
158impl Eq for ShellActionOptions {}
159
160#[derive(Clone, Debug, Default, Eq, PartialEq)]
161pub struct ShellSessionConfiguration {
162 pub attach_hint: Option<String>,
163 pub attached_existing: bool,
164}
165
166pub type ShellRuntimeConfigureFuture<'a> =
167 Pin<Box<dyn Future<Output = Result<ShellSessionConfiguration>> + Send + 'a>>;
168
169#[derive(Clone, Copy, Debug, Eq, PartialEq)]
171pub enum ShellSessionTransition {
172 FirstCall,
173 ModeChange,
174 Reset,
175 WorkspaceChange,
176}
177
178pub trait ShellRuntime: Send + Sync {
180 fn configure_session<'a>(
181 &'a self,
182 bash_state: &'a mut BashState,
183 transition: ShellSessionTransition,
184 ) -> ShellRuntimeConfigureFuture<'a>;
185
186 fn attach_session<'a>(
190 &'a self,
191 bash_state: &'a mut BashState,
192 ) -> ShellRuntimeConfigureFuture<'a> {
193 self.configure_session(bash_state, ShellSessionTransition::ModeChange)
194 }
195
196 fn run_action<'a>(
197 &'a self,
198 bash_state: &'a Arc<Mutex<Option<BashState>>>,
199 command: BashCommand,
200 ) -> ShellRuntimeFuture<'a>;
201
202 fn run_action_detailed<'a>(
205 &'a self,
206 bash_state: &'a Arc<Mutex<Option<BashState>>>,
207 command: BashCommand,
208 _options: ShellActionOptions,
209 ) -> ShellRuntimeDetailedFuture<'a> {
210 Box::pin(async move {
211 self.run_action(bash_state, command).await.map(BashCommandRuntimeResult::legacy)
212 })
213 }
214
215 fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
216 Box::pin(async { Ok(false) })
217 }
218
219 fn supports_generation_bound_actions_for<'a>(
223 &'a self,
224 _bash_state: &'a Arc<Mutex<Option<BashState>>>,
225 ) -> ShellRuntimeBoolFuture<'a> {
226 self.supports_generation_bound_actions()
227 }
228
229 fn interrupt<'a>(
230 &'a self,
231 bash_state: &'a Arc<Mutex<Option<BashState>>>,
232 ) -> ShellRuntimeUnitFuture<'a>;
233
234 fn interrupt_generation<'a>(
237 &'a self,
238 bash_state: &'a Arc<Mutex<Option<BashState>>>,
239 expected_generation: Option<u64>,
240 ) -> ShellRuntimeBoolFuture<'a> {
241 Box::pin(async move {
242 if expected_generation.is_some() {
243 return Ok(false);
244 }
245 self.interrupt(bash_state).await?;
246 Ok(true)
247 })
248 }
249
250 fn interrupt_execution<'a>(
253 &'a self,
254 bash_state: &'a Arc<Mutex<Option<BashState>>>,
255 expected: Option<ShellExecutionToken>,
256 ) -> ShellRuntimeBoolFuture<'a> {
257 self.interrupt_generation(bash_state, expected.map(|token| token.generation))
258 }
259
260 fn cancel_pending_action<'a>(
264 &'a self,
265 _bash_state: &'a Arc<Mutex<Option<BashState>>>,
266 _cancellation_key: &'a str,
267 ) -> ShellRuntimeBoolFuture<'a> {
268 Box::pin(async { Ok(false) })
269 }
270
271 fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a>;
273}
274
275#[derive(Clone, Copy, Debug, Default)]
278pub struct EmbeddedShellRuntime;
279
280impl ShellRuntime for EmbeddedShellRuntime {
281 fn configure_session<'a>(
282 &'a self,
283 bash_state: &'a mut BashState,
284 transition: ShellSessionTransition,
285 ) -> ShellRuntimeConfigureFuture<'a> {
286 Box::pin(async move {
287 let operation_barrier =
288 lock_session_store().operation_barrier(&bash_state.current_thread_id);
289 let _operation = operation_barrier.write().await;
290 if matches!(
291 transition,
292 ShellSessionTransition::FirstCall | ShellSessionTransition::Reset
293 ) && (transition == ShellSessionTransition::Reset || bash_state.cwd.exists())
294 {
295 bash_state.init_pty_shell().await?;
296 }
297 let attach_hint = {
298 let shell = bash_state.pty_shell.lock().await;
299 shell.as_ref().and_then(|shell| shell.attach_hint.clone())
300 };
301 Ok(ShellSessionConfiguration { attach_hint, attached_existing: false })
302 })
303 }
304
305 fn attach_session<'a>(
306 &'a self,
307 _bash_state: &'a mut BashState,
308 ) -> ShellRuntimeConfigureFuture<'a> {
309 Box::pin(async {
310 Ok(ShellSessionConfiguration { attach_hint: None, attached_existing: true })
311 })
312 }
313
314 fn run_action<'a>(
315 &'a self,
316 bash_state: &'a Arc<Mutex<Option<BashState>>>,
317 command: BashCommand,
318 ) -> ShellRuntimeFuture<'a> {
319 Box::pin(crate::tools::bash_command::handle_embedded_tool_call(bash_state, command))
320 }
321
322 fn run_action_detailed<'a>(
323 &'a self,
324 bash_state: &'a Arc<Mutex<Option<BashState>>>,
325 command: BashCommand,
326 options: ShellActionOptions,
327 ) -> ShellRuntimeDetailedFuture<'a> {
328 Box::pin(crate::tools::bash_command::handle_embedded_tool_call_detailed(
329 bash_state, command, options,
330 ))
331 }
332
333 fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
334 Box::pin(async { Ok(true) })
335 }
336
337 fn interrupt<'a>(
338 &'a self,
339 bash_state: &'a Arc<Mutex<Option<BashState>>>,
340 ) -> ShellRuntimeUnitFuture<'a> {
341 Box::pin(interrupt_embedded(bash_state))
342 }
343
344 fn interrupt_generation<'a>(
345 &'a self,
346 bash_state: &'a Arc<Mutex<Option<BashState>>>,
347 expected_generation: Option<u64>,
348 ) -> ShellRuntimeBoolFuture<'a> {
349 Box::pin(interrupt_embedded_generation(bash_state, expected_generation))
350 }
351
352 fn interrupt_execution<'a>(
353 &'a self,
354 bash_state: &'a Arc<Mutex<Option<BashState>>>,
355 expected: Option<ShellExecutionToken>,
356 ) -> ShellRuntimeBoolFuture<'a> {
357 Box::pin(interrupt_embedded_execution(bash_state, expected))
358 }
359
360 fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
361 Box::pin(async move {
362 let operation_barrier = lock_session_store().operation_barrier(thread_id);
363 let _operation = operation_barrier.write().await;
364 let shell = lock_session_store().resolve(thread_id, &ShellTarget::Main);
365 if let Some(shell) = shell {
366 *shell.lock().await = None;
367 }
368 Ok(())
369 })
370 }
371}
372
373async fn interrupt_embedded(bash_state: &Arc<Mutex<Option<BashState>>>) -> Result<()> {
374 let _ = interrupt_embedded_generation(bash_state, None).await?;
375 Ok(())
376}
377
378const INTERRUPT_RECOVERY_TIMEOUT: Duration = Duration::from_secs(3);
379const INTERRUPT_RETRY_INTERVAL: Duration = Duration::from_millis(250);
380const INTERRUPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
381const MAX_INTERRUPT_ATTEMPTS: u32 = 3;
382
383pub(crate) async fn interrupt_pty_until_recovered(
389 shell: &SharedPtyShell,
390 expected_generation: Option<u64>,
391) -> Result<bool> {
392 let deadline = Instant::now() + INTERRUPT_RECOVERY_TIMEOUT;
393 let mut next_interrupt = Instant::now();
394 let mut attempts = 0_u32;
395
396 loop {
397 let now = Instant::now();
398 let recovered = {
399 let mut guard = shell.lock().await;
400 let Some(pty) = guard.as_mut() else { return Ok(false) };
401 if expected_generation.is_some_and(|expected| pty.command_generation() != expected) {
402 return Ok(false);
403 }
404
405 if pty.poll_output_nonblocking() || !pty.command_running {
406 if attempts == 0 {
407 return Ok(false);
408 }
409 true
410 } else {
411 if attempts < MAX_INTERRUPT_ATTEMPTS && now >= next_interrupt {
412 pty.send_interrupt().map_err(|error| {
413 WinxError::CommandExecutionError(format!(
414 "failed to interrupt shell: {error}"
415 ))
416 })?;
417 attempts += 1;
418 next_interrupt = Instant::now() + INTERRUPT_RETRY_INTERVAL;
419 if attempts > 1 {
420 tracing::debug!(attempts, "retrying Ctrl+C for active PTY generation");
421 }
422 }
423 false
424 }
425 };
426
427 if recovered {
428 return Ok(true);
429 }
430 if Instant::now() >= deadline {
431 break;
432 }
433 tokio::time::sleep(INTERRUPT_POLL_INTERVAL).await;
434 }
435
436 Err(WinxError::CommandExecutionError(
437 "interrupted shell did not return to a prompt within 3 seconds".to_string(),
438 ))
439}
440
441async fn interrupt_embedded_generation(
442 bash_state: &Arc<Mutex<Option<BashState>>>,
443 expected_generation: Option<u64>,
444) -> Result<bool> {
445 interrupt_embedded_guarded(bash_state, expected_generation, None).await
446}
447
448async fn interrupt_embedded_execution(
449 bash_state: &Arc<Mutex<Option<BashState>>>,
450 expected: Option<ShellExecutionToken>,
451) -> Result<bool> {
452 interrupt_embedded_guarded(
453 bash_state,
454 expected.as_ref().map(|token| token.generation),
455 expected.as_ref(),
456 )
457 .await
458}
459
460async fn interrupt_embedded_guarded(
461 bash_state: &Arc<Mutex<Option<BashState>>>,
462 expected_generation: Option<u64>,
463 expected_execution: Option<&ShellExecutionToken>,
464) -> Result<bool> {
465 let (shell, _operation) = {
468 let state = bash_state.lock().await;
469 let Some(state) = state.as_ref() else { return Ok(false) };
470 let operation_barrier = lock_session_store().operation_barrier(&state.current_thread_id);
471 let operation = operation_barrier.read_owned().await;
472 (state.pty_shell.clone(), operation)
473 };
474 {
475 let guard = shell.lock().await;
476 if let Some(pty) = guard.as_ref() {
477 if expected_execution.is_some_and(|expected| {
478 expected.guardian_epoch != "embedded"
479 || expected.session_epoch != format!("{:016x}", pty.incarnation())
480 }) {
481 return Ok(false);
482 }
483 }
484 }
485 interrupt_pty_until_recovered(&shell, expected_generation).await
486}