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