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