1use std::collections::{HashMap, HashSet, VecDeque};
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
5use std::sync::Arc;
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use tokio::sync::{watch, Mutex, Notify, RwLock};
9
10use super::protocol::{
11 read_json_frame, write_json_frame, CancelActionParams, ConfigureSessionParams,
12 ConfigureSessionResult, ConfigureSessionTransition, DaemonProcessRole, HelloResult,
13 JournalRead, JournalReadParams, RpcError, RpcRequest, RpcResponse, RunActionParams,
14 RunActionResult, SessionInfo, SessionParams, WireShellError, ATTACH_OR_CREATE_CAPABILITY,
15 CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY, COMPACT_ACTION_OUTPUT_CAPABILITY,
16 GENERATION_BOUND_ACTIONS_CAPABILITY, TYPED_ACTION_RESULT_CAPABILITY,
17};
18use crate::errors::{Result, WinxError};
19use crate::runtime::{lock_session_store, ShellExecutionToken, ShellTarget};
20use crate::state::bash_state::BashState;
21use crate::state::persistence::BashStateSnapshot;
22use crate::state::pty::SharedPtyShell;
23use crate::tools::bash_command::{guardian_foreground_shell_needs_reset, ShellDeliveryCursor};
24use crate::types::normalize_thread_id;
25
26type SharedState = Arc<Mutex<Option<BashState>>>;
27
28struct TimedEntry<T> {
29 value: T,
30 last_seen: Instant,
31}
32
33type CachedActionResult = std::result::Result<RunActionResult, String>;
34
35#[derive(Clone)]
36enum ActionCompletion {
37 InFlight(Arc<watch::Sender<Option<CachedActionResult>>>),
38 Complete(Box<CachedActionResult>),
39}
40
41enum ActionReservation {
42 Owner(Arc<watch::Sender<Option<CachedActionResult>>>),
43 Waiter(Arc<watch::Sender<Option<CachedActionResult>>>),
44 Cached(Box<CachedActionResult>),
45 Rejected { error: WinxError, preserve_cancellation: bool },
46}
47
48struct ActionOwnerGuard {
49 completion: Arc<watch::Sender<Option<CachedActionResult>>>,
50 armed: bool,
51}
52
53impl ActionOwnerGuard {
54 fn new(completion: Arc<watch::Sender<Option<CachedActionResult>>>) -> Self {
55 Self { completion, armed: true }
56 }
57
58 fn disarm(&mut self) {
59 self.armed = false;
60 }
61}
62
63impl Drop for ActionOwnerGuard {
64 fn drop(&mut self) {
65 if self.armed {
66 self.completion.send_replace(Some(Err(
67 "single-flight shell action owner disappeared before completion".to_string(),
68 )));
69 }
70 }
71}
72
73struct ActionEntry {
74 fingerprint: Vec<u8>,
75 cancellation_key: Option<String>,
76 completion: ActionCompletion,
77 last_seen: Instant,
78}
79
80struct DaemonSession {
81 state: SharedState,
82 operation_barrier: Arc<RwLock<()>>,
86 completed: Mutex<HashMap<String, ActionEntry>>,
87 journal: Mutex<OutputJournal>,
88 observed: Mutex<HashMap<String, (u64, String)>>,
89 background_ids: Mutex<HashSet<String>>,
90 command_id: Mutex<Option<String>>,
91 delivery_cursors: Mutex<HashMap<String, TimedEntry<Arc<Mutex<ShellDeliveryCursor>>>>>,
92 launch_reservations: Mutex<HashMap<String, TimedEntry<Arc<AtomicBool>>>>,
93 created_at_unix_ms: u64,
94 last_activity_unix_ms: AtomicU64,
95 last_command_at_unix_ms: AtomicU64,
96 ever_ran_command: AtomicBool,
97 drainer_started: AtomicBool,
98 activity: Notify,
99 session_epoch: Arc<Mutex<String>>,
100}
101
102impl DaemonSession {
103 fn new() -> Self {
104 let now = unix_ms();
105 Self {
106 state: Arc::new(Mutex::new(None)),
107 operation_barrier: Arc::new(RwLock::new(())),
108 completed: Mutex::new(HashMap::new()),
109 journal: Mutex::new(OutputJournal::default()),
110 observed: Mutex::new(HashMap::new()),
111 background_ids: Mutex::new(HashSet::new()),
112 command_id: Mutex::new(None),
113 delivery_cursors: Mutex::new(HashMap::new()),
114 launch_reservations: Mutex::new(HashMap::new()),
115 created_at_unix_ms: now,
116 last_activity_unix_ms: AtomicU64::new(now),
117 last_command_at_unix_ms: AtomicU64::new(0),
118 ever_ran_command: AtomicBool::new(false),
119 drainer_started: AtomicBool::new(false),
120 activity: Notify::new(),
121 session_epoch: Arc::new(Mutex::new(new_epoch())),
122 }
123 }
124
125 fn note_activity(&self, command: bool) {
126 let now = unix_ms();
127 self.last_activity_unix_ms.store(now, Ordering::Release);
128 if command {
129 self.last_command_at_unix_ms.store(now, Ordering::Release);
130 self.ever_ran_command.store(true, Ordering::Release);
131 }
132 self.activity.notify_one();
133 }
134}
135
136fn new_epoch() -> String {
137 format!("{:016x}", rand::random::<u64>())
138}
139
140const MAX_JOURNAL_BYTES: usize = 4 * 1024 * 1024;
141const MAX_SESSION_CACHE_ENTRIES: usize = 256;
142const MAX_IDENTIFIER_BYTES: usize = 256;
143const SESSION_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
144const ACTION_OWNER_TIMEOUT: Duration = Duration::from_secs(11 * 60);
145const ATTACH_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(2);
146const CONFIGURE_BARRIER_TIMEOUT: Duration = Duration::from_secs(5);
147const FOREGROUND_ADMISSION_TIMEOUT: Duration = Duration::from_millis(100);
148const DRAIN_CHANGED_INTERVAL: Duration = Duration::from_millis(20);
149const DRAIN_ACTIVE_INTERVAL: Duration = Duration::from_millis(100);
150const DRAIN_IDLE_INTERVAL: Duration = Duration::from_secs(1);
151
152#[derive(Clone, Copy, Default)]
153struct CaptureState {
154 changed: bool,
155 active: bool,
156}
157
158impl CaptureState {
159 fn merge(&mut self, other: Self) {
160 self.changed |= other.changed;
161 self.active |= other.active;
162 }
163
164 fn next_delay(self) -> Duration {
165 if self.changed {
166 DRAIN_CHANGED_INTERVAL
167 } else if self.active {
168 DRAIN_ACTIVE_INTERVAL
169 } else {
170 DRAIN_IDLE_INTERVAL
171 }
172 }
173}
174
175#[derive(Default)]
176struct OutputJournal {
177 chunks: VecDeque<(u64, String)>,
178 cursors: HashMap<String, TimedEntry<u64>>,
179 next_seq: u64,
180 bytes: usize,
181}
182
183fn prune_timed_entries<T>(entries: &mut HashMap<String, TimedEntry<T>>, now: Instant) {
184 entries.retain(|_, entry| now.duration_since(entry.last_seen) <= SESSION_CACHE_TTL);
185}
186
187fn evict_oldest_if_full<T>(entries: &mut HashMap<String, TimedEntry<T>>, incoming: &str) {
188 if entries.contains_key(incoming) || entries.len() < MAX_SESSION_CACHE_ENTRIES {
189 return;
190 }
191 if let Some(oldest) =
192 entries.iter().min_by_key(|(_, entry)| entry.last_seen).map(|(key, _)| key.clone())
193 {
194 entries.remove(&oldest);
195 }
196}
197
198async fn launch_cancellation_flag(
199 session: &DaemonSession,
200 cancellation_key: &str,
201) -> Result<Arc<AtomicBool>> {
202 let now = Instant::now();
203 let mut reservations = session.launch_reservations.lock().await;
204 reservations.retain(|_, entry| {
205 now.duration_since(entry.last_seen) <= SESSION_CACHE_TTL
206 || Arc::strong_count(&entry.value) > 1
207 });
208 if !reservations.contains_key(cancellation_key)
209 && reservations.len() >= MAX_SESSION_CACHE_ENTRIES
210 {
211 let oldest = reservations
212 .iter()
213 .filter(|(_, entry)| {
214 Arc::strong_count(&entry.value) == 1 && !entry.value.load(Ordering::Acquire)
215 })
216 .min_by_key(|(_, entry)| entry.last_seen)
217 .map(|(key, _)| key.clone());
218 if let Some(oldest) = oldest {
219 reservations.remove(&oldest);
220 }
221 }
222 if !reservations.contains_key(cancellation_key)
223 && reservations.len() >= MAX_SESSION_CACHE_ENTRIES
224 {
225 return Err(WinxError::ResourceAllocationError {
226 message: format!(
227 "all {MAX_SESSION_CACHE_ENTRIES} action cancellation reservations are active"
228 ),
229 });
230 }
231 let entry = reservations
232 .entry(cancellation_key.to_string())
233 .or_insert_with(|| TimedEntry { value: Arc::new(AtomicBool::new(false)), last_seen: now });
234 entry.last_seen = now;
235 Ok(Arc::clone(&entry.value))
236}
237
238async fn consume_launch_cancellation_flag(
239 session: &DaemonSession,
240 cancellation_key: &str,
241 expected: &Arc<AtomicBool>,
242) {
243 let mut reservations = session.launch_reservations.lock().await;
244 if reservations.get(cancellation_key).is_some_and(|entry| Arc::ptr_eq(&entry.value, expected)) {
245 reservations.remove(cancellation_key);
246 }
247}
248
249async fn consume_unowned_launch_cancellation_flag(
250 session: &DaemonSession,
251 cancellation_key: Option<&str>,
252) {
253 let Some(cancellation_key) = cancellation_key else {
254 return;
255 };
256
257 let completed = session.completed.lock().await;
261 let owned = completed.values().any(|entry| {
262 entry.cancellation_key.as_deref() == Some(cancellation_key)
263 && matches!(entry.completion, ActionCompletion::InFlight(_))
264 && !action_entry_is_terminal(entry)
265 });
266 if owned {
267 return;
268 }
269 let mut reservations = session.launch_reservations.lock().await;
270 if reservations.get(cancellation_key).is_some_and(|entry| Arc::strong_count(&entry.value) == 1)
271 {
272 reservations.remove(cancellation_key);
273 }
274}
275
276fn validate_identifier(label: &str, value: &str) -> Result<()> {
277 if value.len() > MAX_IDENTIFIER_BYTES {
278 return Err(WinxError::InvalidInput(format!(
279 "{label} exceeds the {MAX_IDENTIFIER_BYTES}-byte limit"
280 )));
281 }
282 Ok(())
283}
284
285impl OutputJournal {
286 fn append(&mut self, output: String) {
287 if output.is_empty() {
288 return;
289 }
290 self.next_seq = self.next_seq.saturating_add(1);
291 self.bytes = self.bytes.saturating_add(output.len());
292 self.chunks.push_back((self.next_seq, output));
293 while self.bytes > MAX_JOURNAL_BYTES && self.chunks.len() > 1 {
294 if let Some((_, removed)) = self.chunks.pop_front() {
295 self.bytes = self.bytes.saturating_sub(removed.len());
296 }
297 }
298 }
299
300 fn read(&mut self, consumer_id: &str) -> JournalRead {
301 let now = Instant::now();
302 prune_timed_entries(&mut self.cursors, now);
303 evict_oldest_if_full(&mut self.cursors, consumer_id);
304
305 let oldest = self.chunks.front().map_or(self.next_seq.saturating_add(1), |(seq, _)| *seq);
306 let cursor = self.cursors.get(consumer_id).map_or(0, |entry| entry.value);
307 let gap = cursor.saturating_add(1) < oldest;
308 let mut output = String::new();
309 let mut next_seq = cursor;
310 for (seq, chunk) in self.chunks.iter().filter(|(seq, _)| *seq > cursor) {
311 output.push_str(chunk);
312 next_seq = *seq;
313 }
314 self.cursors
315 .insert(consumer_id.to_string(), TimedEntry { value: next_seq, last_seen: now });
316 JournalRead { output, next_seq, gap }
317 }
318}
319
320pub struct DaemonServer {
322 listener: crate::daemon::transport::DaemonListener,
323 socket_path: PathBuf,
324 sessions: Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
325 epoch: String,
326}
327
328impl DaemonServer {
329 pub async fn bind(socket_path: impl AsRef<Path>) -> Result<Self> {
330 let socket_path = socket_path.as_ref().to_path_buf();
331 let parent = socket_path.parent().ok_or_else(|| {
332 WinxError::ConfigurationError("daemon socket must have a parent directory".to_string())
333 })?;
334 tokio::fs::create_dir_all(parent).await?;
335 crate::daemon::transport::restrict_to_owner(parent, 0o700).await?;
336
337 if tokio::fs::try_exists(&socket_path).await? {
338 if crate::daemon::transport::connect(&socket_path).await.is_ok() {
339 return Err(WinxError::ConfigurationError(format!(
340 "a winxd instance is already listening at {}",
341 socket_path.display()
342 )));
343 }
344 tokio::fs::remove_file(&socket_path).await?;
345 }
346
347 let listener = crate::daemon::transport::DaemonListener::bind(&socket_path)?;
348 crate::daemon::transport::restrict_to_owner(&socket_path, 0o600).await?;
349 let epoch = format!("{:016x}", rand::random::<u64>());
350 Ok(Self { listener, socket_path, sessions: Arc::new(Mutex::new(HashMap::new())), epoch })
351 }
352
353 pub async fn serve(mut self) -> Result<()> {
354 loop {
355 let stream = self.listener.accept().await?;
356 if !crate::daemon::transport::peer_is_same_user(&stream)? {
357 tracing::warn!("Rejected winxd connection from a different uid");
358 continue;
359 }
360 let sessions = self.sessions.clone();
361 let epoch = self.epoch.clone();
362 tokio::spawn(async move {
363 if let Err(error) = serve_connection(stream, sessions, epoch).await {
364 tracing::debug!("winxd client disconnected: {error}");
365 }
366 });
367 }
368 }
369}
370
371impl Drop for DaemonServer {
372 fn drop(&mut self) {
373 let _ = std::fs::remove_file(&self.socket_path);
374 }
375}
376
377async fn serve_connection(
378 mut stream: crate::daemon::transport::DaemonStream,
379 sessions: Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
380 epoch: String,
381) -> Result<()> {
382 loop {
383 let request: RpcRequest = match read_json_frame(&mut stream).await {
384 Ok(request) => request,
385 Err(error) if error.kind() == ErrorKind::UnexpectedEof => return Ok(()),
386 Err(error) => return Err(error.into()),
387 };
388 let response = dispatch(request, &sessions, &epoch).await;
389 write_json_frame(&mut stream, &response).await?;
390 }
391}
392
393#[allow(clippy::too_many_lines)]
394async fn dispatch(
395 request: RpcRequest,
396 sessions: &Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
397 epoch: &str,
398) -> RpcResponse {
399 if request.jsonrpc != "2.0" {
400 return rpc_error(request.id, -32600, "JSON-RPC version must be 2.0");
401 }
402
403 match request.method.as_str() {
404 "winx.hello" | "session.negotiate" => rpc_result(
405 request.id,
406 &HelloResult::current(
407 DaemonProcessRole::Guardian,
408 vec![
409 "session.configure".to_string(),
410 "session.negotiate".to_string(),
411 "shell.run_action".to_string(),
412 TYPED_ACTION_RESULT_CAPABILITY.to_string(),
413 COMPACT_ACTION_OUTPUT_CAPABILITY.to_string(),
414 GENERATION_BOUND_ACTIONS_CAPABILITY.to_string(),
415 CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY.to_string(),
416 "session.list".to_string(),
417 "session.read_output".to_string(),
418 "session.kill".to_string(),
419 "multi_consumer_cursors".to_string(),
420 "idempotency".to_string(),
421 "session_activity_timestamps".to_string(),
422 ATTACH_OR_CREATE_CAPABILITY.to_string(),
423 crate::daemon::protocol::PROCESS_SHUTDOWN_CAPABILITY.to_string(),
424 ],
425 epoch,
426 ),
427 ),
428 "winx.shutdown" => {
429 schedule_process_exit("winx-guardian");
430 rpc_result(request.id, &serde_json::json!({ "shutting_down": true }))
431 }
432 "session.configure" => {
433 let params: ConfigureSessionParams = match serde_json::from_value(request.params) {
434 Ok(params) => params,
435 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
436 };
437 match configure_session(sessions, params).await {
438 Ok(result) => rpc_result(request.id, &result),
439 Err(error) => rpc_error(request.id, -32603, &error.to_string()),
440 }
441 }
442 "shell.run_action" => {
443 let params: RunActionParams = match serde_json::from_value(request.params) {
444 Ok(params) => params,
445 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
446 };
447 match run_action(sessions, params, epoch).await {
448 Ok(result) => rpc_result(request.id, &result),
449 Err(error) => rpc_error(request.id, -32603, &error.to_string()),
450 }
451 }
452 "shell.cancel_action" => {
453 let params: CancelActionParams = match serde_json::from_value(request.params) {
454 Ok(params) => params,
455 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
456 };
457 if params.expected_guardian_epoch.as_deref().is_some_and(|expected| expected != epoch) {
458 return rpc_result(request.id, &false);
459 }
460 if let Err(error) = validate_identifier("cancellation_key", ¶ms.cancellation_key) {
461 return rpc_error(request.id, -32602, &error.to_string());
462 }
463 let session =
464 sessions.lock().await.get(&normalize_thread_id(¶ms.thread_id)).cloned();
465 match session {
466 Some(session) => {
467 match launch_cancellation_flag(&session, ¶ms.cancellation_key).await {
468 Ok(flag) => {
469 flag.store(true, Ordering::SeqCst);
470 rpc_result(request.id, &true)
471 }
472 Err(error) => rpc_error(request.id, -32603, &error.to_string()),
473 }
474 }
475 None => rpc_result(request.id, &false),
476 }
477 }
478 "session.list" => {
479 let entries = sessions.lock().await.values().cloned().collect::<Vec<_>>();
480 let mut infos = Vec::with_capacity(entries.len());
481 for session in entries {
482 if let Some(info) = session_info(&session).await {
483 infos.push(info);
484 }
485 }
486 infos.sort_by(|left, right| left.thread_id.cmp(&right.thread_id));
487 rpc_result(request.id, &infos)
488 }
489 "session.info" => {
490 let params: SessionParams = match serde_json::from_value(request.params) {
491 Ok(params) => params,
492 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
493 };
494 let session =
495 sessions.lock().await.get(&normalize_thread_id(¶ms.thread_id)).cloned();
496 match session {
497 Some(session) => match session_info(&session).await {
498 Some(info) => rpc_result(request.id, &info),
499 None => rpc_error(request.id, -32004, "session not initialized"),
500 },
501 None => rpc_error(request.id, -32004, "session not found"),
502 }
503 }
504 "session.read_output" => {
505 let params: JournalReadParams = match serde_json::from_value(request.params) {
506 Ok(params) => params,
507 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
508 };
509 if let Err(error) = validate_identifier("consumer_id", ¶ms.consumer_id) {
510 return rpc_error(request.id, -32602, &error.to_string());
511 }
512 let thread_id = normalize_thread_id(¶ms.thread_id);
513 let session = sessions.lock().await.get(&thread_id).cloned();
514 match session {
515 Some(session) => {
516 session.note_activity(false);
517 capture_session_outputs(&session).await;
518 let read = session.journal.lock().await.read(¶ms.consumer_id);
519 rpc_result(request.id, &read)
520 }
521 None => rpc_error(request.id, -32004, "session not found"),
522 }
523 }
524 "session.kill" => {
525 let params: SessionParams = match serde_json::from_value(request.params) {
526 Ok(params) => params,
527 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
528 };
529 let session = sessions.lock().await.remove(&normalize_thread_id(¶ms.thread_id));
530 match session {
531 Some(session) => {
532 let _operation = session.operation_barrier.write().await;
533 let background_ids = session.background_ids.lock().await.clone();
534 {
535 let mut store = lock_session_store();
536 for id in background_ids {
537 store.remove_shell(&id);
538 }
539 }
540 *session.state.lock().await = None;
541 rpc_result(request.id, &true)
542 }
543 None => rpc_result(request.id, &false),
544 }
545 }
546 "session.interrupt" => {
547 let params: SessionParams = match serde_json::from_value(request.params) {
548 Ok(params) => params,
549 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
550 };
551 let session =
552 sessions.lock().await.get(&normalize_thread_id(¶ms.thread_id)).cloned();
553 match session {
554 Some(session) => match interrupt_session(&session, ¶ms, epoch).await {
555 Ok(interrupted) => rpc_result(request.id, &interrupted),
556 Err(error) => rpc_error(request.id, -32005, &error.to_string()),
557 },
558 None => rpc_error(request.id, -32004, "session not found"),
559 }
560 }
561 _ => rpc_error(request.id, -32601, "method not found"),
562 }
563}
564
565async fn attach_existing_session(
568 session: &Arc<DaemonSession>,
569) -> Result<Option<ConfigureSessionResult>> {
570 let snapshot = tokio::time::timeout(ATTACH_SNAPSHOT_TIMEOUT, session.state.lock())
571 .await
572 .map_err(|_| WinxError::CommandTimeout {
573 command: "attach to the durable shell session".to_string(),
574 timeout_seconds: ATTACH_SNAPSHOT_TIMEOUT.as_secs(),
575 })?
576 .as_ref()
577 .map(BashState::snapshot);
578 Ok(snapshot.map(|snapshot| ConfigureSessionResult {
579 attach_hint: None,
580 snapshot: Some(snapshot),
581 attached_existing: true,
582 }))
583}
584
585async fn configure_session(
586 sessions: &Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
587 params: ConfigureSessionParams,
588) -> Result<ConfigureSessionResult> {
589 let thread_id = normalize_thread_id(¶ms.snapshot.chat_id);
590 if thread_id.is_empty() {
591 return Err(WinxError::ThreadIdMismatch(
592 "daemon session configuration requires an explicit thread_id".to_string(),
593 ));
594 }
595
596 let (session, entry_created) = {
597 let mut sessions = sessions.lock().await;
598 match params.transition {
599 ConfigureSessionTransition::FirstCall => match sessions.entry(thread_id) {
600 std::collections::hash_map::Entry::Occupied(entry) => (entry.get().clone(), false),
601 std::collections::hash_map::Entry::Vacant(entry) => {
602 let session = Arc::new(DaemonSession::new());
603 entry.insert(session.clone());
604 (session, true)
605 }
606 },
607 _ => (
608 sessions.get(&thread_id).cloned().ok_or(WinxError::BashStateNotInitialized)?,
609 false,
610 ),
611 }
612 };
613 ensure_drainer(&session);
614 session.note_activity(false);
615
616 if matches!(params.transition, ConfigureSessionTransition::FirstCall) && !entry_created {
621 if let Some(attached) = attach_existing_session(&session).await? {
622 session.activity.notify_one();
623 return Ok(attached);
624 }
625 }
626
627 let _operation =
628 tokio::time::timeout(CONFIGURE_BARRIER_TIMEOUT, session.operation_barrier.write())
629 .await
630 .map_err(|_| WinxError::CommandTimeout {
631 command: "configure the shell while another operation is active".to_string(),
632 timeout_seconds: CONFIGURE_BARRIER_TIMEOUT.as_secs(),
633 })?;
634
635 let (attach_hint, snapshot, attached_existing) = {
636 let mut guard = session.state.lock().await;
637 let mut attached_existing = false;
638 match params.transition {
639 ConfigureSessionTransition::FirstCall => {
640 if entry_created || guard.is_none() {
641 let mut state = BashState::new();
642 state.apply_snapshot(¶ms.snapshot);
643 if state.cwd.exists() {
644 state.init_pty_shell().await?;
645 }
646 *guard = Some(state);
647 } else {
648 attached_existing = true;
651 }
652 }
653 ConfigureSessionTransition::ModeChange => {
654 let state = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
655 let daemon_cwd = state.cwd.clone();
656 state.apply_snapshot(¶ms.snapshot);
657 state.cwd = daemon_cwd;
658 }
659 ConfigureSessionTransition::Reset => {
660 let state = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
661 let daemon_cwd = state.cwd.clone();
662 state.apply_snapshot(¶ms.snapshot);
663 state.cwd = daemon_cwd;
664 state.init_pty_shell().await?;
665 *session.session_epoch.lock().await = new_epoch();
666 }
667 ConfigureSessionTransition::WorkspaceChange => {
668 let state = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
669 state.apply_snapshot(¶ms.snapshot);
670 }
671 }
672 let state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
673 let attach_hint = {
674 let shell = state.pty_shell.lock().await;
675 shell.as_ref().and_then(|shell| shell.attach_hint.clone())
676 };
677 (attach_hint, state.snapshot(), attached_existing)
678 };
679
680 if matches!(params.transition, ConfigureSessionTransition::Reset)
681 || (matches!(params.transition, ConfigureSessionTransition::FirstCall)
682 && !attached_existing)
683 {
684 *session.command_id.lock().await = None;
685 session.delivery_cursors.lock().await.clear();
689 session.observed.lock().await.remove("main");
690 }
691 capture_session_outputs_locked(&session).await;
692 session.activity.notify_one();
693 Ok(ConfigureSessionResult { attach_hint, snapshot: Some(snapshot), attached_existing })
694}
695
696#[allow(clippy::too_many_lines)] async fn run_action(
698 sessions: &Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
699 mut params: RunActionParams,
700 guardian_epoch: &str,
701) -> Result<RunActionResult> {
702 validate_identifier("request_key", ¶ms.request_key)?;
703 validate_identifier("consumer_id", ¶ms.consumer_id)?;
704 if let Some(key) = params.options.cancellation_key.as_deref() {
705 validate_identifier("cancellation_key", key)?;
706 }
707 let thread_id = normalize_thread_id(¶ms.command.thread_id);
708 if thread_id.is_empty() {
709 return Err(WinxError::ThreadIdMismatch(
710 "daemon requests require an explicit thread_id".to_string(),
711 ));
712 }
713
714 let session = {
715 let mut sessions = sessions.lock().await;
716 sessions.entry(thread_id.clone()).or_insert_with(|| Arc::new(DaemonSession::new())).clone()
717 };
718 let cancellation_key = params.options.cancellation_key.clone();
719 let fingerprint = canonical_action_fingerprint(¶ms)?;
720
721 let request_key = params.request_key.clone();
722 let reservation = {
723 let mut completed = session.completed.lock().await;
724 let now = Instant::now();
725 prune_action_entries(&mut completed, now);
726 if let Some(entry) = completed.get_mut(&request_key) {
727 if entry.fingerprint == fingerprint {
728 entry.last_seen = now;
729 match &entry.completion {
730 ActionCompletion::Complete(result) => {
731 ActionReservation::Cached(Box::new((**result).clone()))
732 }
733 ActionCompletion::InFlight(sender) => {
734 ActionReservation::Waiter(Arc::clone(sender))
735 }
736 }
737 } else {
738 let preserve_cancellation = entry.cancellation_key == cancellation_key
739 && matches!(entry.completion, ActionCompletion::InFlight(_))
740 && !action_entry_is_terminal(entry);
741 ActionReservation::Rejected {
742 error: WinxError::InvalidInput(
743 "request_key was reused with different shell action parameters".to_string(),
744 ),
745 preserve_cancellation,
746 }
747 }
748 } else if evict_oldest_action_if_full(&mut completed) {
749 let completion = Arc::new(watch::channel(None).0);
750 completed.insert(
751 request_key.clone(),
752 ActionEntry {
753 fingerprint,
754 cancellation_key: cancellation_key.clone(),
755 completion: ActionCompletion::InFlight(Arc::clone(&completion)),
756 last_seen: now,
757 },
758 );
759 ActionReservation::Owner(completion)
760 } else {
761 ActionReservation::Rejected {
762 error: WinxError::ResourceAllocationError {
763 message: format!(
764 "all {MAX_SESSION_CACHE_ENTRIES} shell action reservations are in flight; retry after one completes"
765 ),
766 },
767 preserve_cancellation: false,
768 }
769 }
770 };
771
772 match reservation {
773 ActionReservation::Cached(result) => {
774 consume_unowned_launch_cancellation_flag(&session, cancellation_key.as_deref()).await;
775 cached_action_result(*result)
776 }
777 ActionReservation::Rejected { error, preserve_cancellation } => {
778 if !preserve_cancellation {
779 consume_unowned_launch_cancellation_flag(&session, cancellation_key.as_deref())
780 .await;
781 }
782 Err(error)
783 }
784 ActionReservation::Waiter(completion) => {
785 wait_for_action_completion(completion.subscribe()).await
786 }
787 ActionReservation::Owner(completion) => {
788 let launch_reservation = if let Some(key) = cancellation_key {
789 match launch_cancellation_flag(&session, &key).await {
790 Ok(flag) => {
791 params.options.launch_cancelled = Some(Arc::clone(&flag));
792 Some((key, flag))
793 }
794 Err(error) => {
795 complete_action(
796 &session,
797 &request_key,
798 Err(error.to_string()),
799 &completion,
800 )
801 .await;
802 return Err(error);
803 }
804 }
805 } else {
806 None
807 };
808 let owner_session = Arc::clone(&session);
809 let owner_epoch = guardian_epoch.to_string();
810 let owner_completion = Arc::clone(&completion);
811 tokio::spawn(async move {
812 let mut owner_guard = ActionOwnerGuard::new(Arc::clone(&owner_completion));
813 let outcome = match tokio::time::timeout(
814 ACTION_OWNER_TIMEOUT,
815 execute_action(&owner_session, params, &owner_epoch),
816 )
817 .await
818 {
819 Ok(outcome) => outcome,
820 Err(_) => Err(WinxError::CommandTimeout {
821 command: "guardian shell action".to_string(),
822 timeout_seconds: ACTION_OWNER_TIMEOUT.as_secs(),
823 }),
824 };
825 if let Some((key, flag)) = launch_reservation {
826 consume_launch_cancellation_flag(&owner_session, &key, &flag).await;
827 }
828 let cached = outcome.map_err(|error| error.to_string());
829 complete_action(&owner_session, &request_key, cached, &owner_completion).await;
830 owner_guard.disarm();
831 });
832 wait_for_action_completion(completion.subscribe()).await
833 }
834 }
835}
836
837fn cached_action_result(cached: CachedActionResult) -> Result<RunActionResult> {
838 cached.map_err(WinxError::CommandExecutionError)
839}
840
841async fn wait_for_action_completion(
842 mut completion: watch::Receiver<Option<CachedActionResult>>,
843) -> Result<RunActionResult> {
844 loop {
845 if let Some(result) = completion.borrow().clone() {
846 return cached_action_result(result);
847 }
848 if completion.changed().await.is_err() {
849 return Err(WinxError::CommandExecutionError(
850 "single-flight shell action owner disappeared".to_string(),
851 ));
852 }
853 }
854}
855
856async fn complete_action(
857 session: &DaemonSession,
858 request_key: &str,
859 cached: CachedActionResult,
860 completion: &watch::Sender<Option<CachedActionResult>>,
861) {
862 {
863 let mut completed = session.completed.lock().await;
864 if let Some(entry) = completed.get_mut(request_key) {
865 entry.completion = ActionCompletion::Complete(Box::new(cached.clone()));
866 entry.last_seen = Instant::now();
867 }
868 }
869 completion.send_replace(Some(cached));
870}
871
872fn canonical_action_fingerprint(params: &RunActionParams) -> Result<Vec<u8>> {
873 let value = serde_json::to_value((
874 ¶ms.snapshot,
875 ¶ms.command,
876 ¶ms.consumer_id,
877 ¶ms.options,
878 ))
879 .map_err(|error| WinxError::SerializationError(error.to_string()))?;
880 serde_json::to_vec(&canonical_json(value))
881 .map_err(|error| WinxError::SerializationError(error.to_string()))
882}
883
884fn canonical_json(value: serde_json::Value) -> serde_json::Value {
885 match value {
886 serde_json::Value::Array(values) => {
887 serde_json::Value::Array(values.into_iter().map(canonical_json).collect())
888 }
889 serde_json::Value::Object(values) => {
890 let mut entries = values.into_iter().collect::<Vec<_>>();
891 entries.sort_by(|(left, _), (right, _)| left.cmp(right));
892 serde_json::Value::Object(
893 entries.into_iter().map(|(key, value)| (key, canonical_json(value))).collect(),
894 )
895 }
896 scalar => scalar,
897 }
898}
899
900#[allow(clippy::too_many_lines)]
901async fn execute_action(
902 session: &Arc<DaemonSession>,
903 params: RunActionParams,
904 guardian_epoch: &str,
905) -> Result<RunActionResult> {
906 let foreground_command = matches!(
907 ¶ms.command.action_json,
908 crate::types::BashCommandAction::Command { is_background: false, .. }
909 );
910 let (write_operation, read_operation) = if foreground_command {
916 match tokio::time::timeout(
917 FOREGROUND_ADMISSION_TIMEOUT,
918 Arc::clone(&session.operation_barrier).write_owned(),
919 )
920 .await
921 {
922 Ok(operation) => (Some(operation), None),
923 Err(_) => {
924 return foreground_admission_busy_result(session, params.snapshot.clone()).await;
925 }
926 }
927 } else {
928 (None, Some(Arc::clone(&session.operation_barrier).read_owned().await))
929 };
930 if params
931 .options
932 .expected_guardian_epoch
933 .as_deref()
934 .is_some_and(|expected| expected != guardian_epoch)
935 || params
936 .options
937 .expected_execution
938 .as_ref()
939 .is_some_and(|expected| expected.guardian_epoch != guardian_epoch)
940 {
941 return Err(WinxError::InvalidInput(
942 "execution precondition refers to a different guardian epoch".to_string(),
943 ));
944 }
945 let session_epoch = session.session_epoch.lock().await.clone();
946 if params.options.expected_execution.as_ref().is_some_and(|expected| {
947 expected.session_epoch != session_epoch
948 || params
949 .options
950 .expected_generation
951 .is_some_and(|generation| generation != expected.generation)
952 }) {
953 return Err(WinxError::InvalidInput(
954 "execution precondition refers to a different session incarnation".to_string(),
955 ));
956 }
957 let is_command =
958 matches!(¶ms.command.action_json, crate::types::BashCommandAction::Command { .. });
959 ensure_drainer(session);
960 session.note_activity(is_command);
961
962 let preflight_reset = {
963 let mut guard = session.state.lock().await;
964 let state = if let Some(state) = guard.as_mut() {
965 let daemon_cwd = state.cwd.clone();
966 state.apply_snapshot(¶ms.snapshot);
967 state.cwd = daemon_cwd;
968 state
969 } else {
970 let mut state = BashState::new();
971 state.apply_snapshot(¶ms.snapshot);
972 guard.insert(state)
973 };
974 let needs_shell = state.pty_shell.lock().await.is_none();
975 if needs_shell && state.cwd.exists() {
976 state.init_pty_shell().await?;
977 }
978 if foreground_command && !params.options.is_launch_cancelled() {
979 guardian_foreground_shell_needs_reset(state, ¶ms.options).await?
980 } else {
981 false
982 }
983 };
984 if preflight_reset {
985 let mut guard = session.state.lock().await;
986 let state = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
987 *session.session_epoch.lock().await = new_epoch();
988 if let Err(error) = state.init_pty_shell().await {
989 tracing::warn!(%error, "failed to reset guardian PTY during exclusive preflight");
990 }
991 }
992 let _operation = if let Some(operation) = write_operation {
993 Some(operation.downgrade())
994 } else {
995 read_operation
996 };
997
998 if is_command {
999 *session.command_id.lock().await = Some(params.request_key.clone());
1000 }
1001 let consumer_id = if params.consumer_id.is_empty() {
1002 "legacy-adapter".to_string()
1003 } else {
1004 params.consumer_id
1005 };
1006 let cursor_key = delivery_cursor_key(&consumer_id, ¶ms.command.action_json);
1007 let cursor = {
1008 let now = Instant::now();
1009 let mut cursors = session.delivery_cursors.lock().await;
1010 prune_timed_entries(&mut cursors, now);
1011 evict_oldest_if_full(&mut cursors, &cursor_key);
1012 let entry = cursors.entry(cursor_key).or_insert_with(|| TimedEntry {
1013 value: Arc::new(Mutex::new(ShellDeliveryCursor::default())),
1014 last_seen: now,
1015 });
1016 entry.last_seen = now;
1017 entry.value.clone()
1018 };
1019 let outcome = crate::tools::bash_command::handle_embedded_tool_call_with_cursor_detailed(
1020 &session.state,
1021 params.command,
1022 &cursor,
1023 params.options,
1024 foreground_command,
1025 )
1026 .await;
1027 let session_epoch = session.session_epoch.lock().await.clone();
1028 if let Ok(result) = &outcome {
1029 if let Some(id) = result.result.state.background_id.as_ref() {
1030 session.background_ids.lock().await.insert(id.clone());
1031 }
1032 }
1033 capture_session_outputs_locked(session).await;
1034 let snapshot =
1035 session.state.lock().await.as_ref().ok_or(WinxError::BashStateNotInitialized)?.snapshot();
1036 let result = match outcome {
1037 Ok(result) => {
1038 let command_generation = result.command_generation;
1039 let execution_token = command_generation.map(|generation| ShellExecutionToken {
1040 guardian_epoch: guardian_epoch.to_string(),
1041 session_epoch,
1042 generation,
1043 });
1044 let (output, compact_output) = match result.compact_output {
1047 Some(compact) => (None, Some(compact)),
1048 None => (Some(result.result.output), None),
1049 };
1050 RunActionResult {
1051 output,
1052 compact_output,
1053 command_generation,
1054 execution_token,
1055 dropped_output_file: result.dropped_output_file,
1056 output_truncated: result.output_truncated,
1057 state: Some(result.result.state),
1058 snapshot,
1059 error: None,
1060 }
1061 }
1062 Err(error) => RunActionResult {
1063 output: None,
1064 compact_output: None,
1065 command_generation: None,
1066 execution_token: None,
1067 dropped_output_file: None,
1068 output_truncated: false,
1069 state: None,
1070 snapshot,
1071 error: Some(to_wire_error(error)),
1072 },
1073 };
1074
1075 session.activity.notify_one();
1076 Ok(result)
1077}
1078
1079async fn foreground_admission_busy_result(
1080 session: &Arc<DaemonSession>,
1081 fallback_snapshot: BashStateSnapshot,
1082) -> Result<RunActionResult> {
1083 let state = tokio::time::timeout(FOREGROUND_ADMISSION_TIMEOUT, async {
1084 session.state.lock().await.as_ref().map(|state| (state.snapshot(), state.pty_shell.clone()))
1085 })
1086 .await
1087 .ok()
1088 .flatten();
1089 let (snapshot, shell) =
1090 state.map_or((fallback_snapshot, None), |(snapshot, shell)| (snapshot, Some(shell)));
1091 let active = if let Some(shell) = shell {
1092 tokio::time::timeout(FOREGROUND_ADMISSION_TIMEOUT, shell.lock()).await.ok().and_then(
1093 |guard| {
1094 guard.as_ref().filter(|shell| shell.command_running).map(|shell| {
1095 (
1096 shell.last_command.clone(),
1097 shell.command_elapsed().map_or(0.0, |elapsed| elapsed.as_secs_f64()),
1098 )
1099 })
1100 },
1101 )
1102 } else {
1103 None
1104 };
1105 let (current_command, duration_seconds) = active
1106 .unwrap_or_else(|| ("another operation in this shared workspace session".to_string(), 0.0));
1107 session.note_activity(false);
1108 Ok(RunActionResult {
1109 output: None,
1110 compact_output: None,
1111 command_generation: None,
1112 execution_token: None,
1113 dropped_output_file: None,
1114 output_truncated: false,
1115 state: None,
1116 snapshot,
1117 error: Some(WireShellError::CommandAlreadyRunning { current_command, duration_seconds }),
1118 })
1119}
1120
1121fn prune_action_entries(entries: &mut HashMap<String, ActionEntry>, now: Instant) {
1122 entries.retain(|_, entry| {
1123 !action_entry_is_terminal(entry) || now.duration_since(entry.last_seen) <= SESSION_CACHE_TTL
1124 });
1125}
1126
1127fn action_entry_is_terminal(entry: &ActionEntry) -> bool {
1128 match &entry.completion {
1129 ActionCompletion::Complete(_) => true,
1130 ActionCompletion::InFlight(completion) => completion.borrow().is_some(),
1131 }
1132}
1133
1134fn evict_oldest_action_if_full(entries: &mut HashMap<String, ActionEntry>) -> bool {
1135 if entries.len() < MAX_SESSION_CACHE_ENTRIES {
1136 return true;
1137 }
1138 if let Some(oldest) = entries
1139 .iter()
1140 .filter(|(_, entry)| action_entry_is_terminal(entry))
1141 .min_by_key(|(_, entry)| entry.last_seen)
1142 .map(|(key, _)| key.clone())
1143 {
1144 entries.remove(&oldest);
1145 }
1146 entries.len() < MAX_SESSION_CACHE_ENTRIES
1147}
1148
1149fn delivery_cursor_key(consumer_id: &str, action: &crate::types::BashCommandAction) -> String {
1150 let background_id = match action {
1151 crate::types::BashCommandAction::StatusCheck { bg_command_id, .. }
1152 | crate::types::BashCommandAction::SendText { bg_command_id, .. }
1153 | crate::types::BashCommandAction::SendSpecials { bg_command_id, .. }
1154 | crate::types::BashCommandAction::SendAscii { bg_command_id, .. }
1155 | crate::types::BashCommandAction::Screen { bg_command_id, .. }
1156 | crate::types::BashCommandAction::WaitForTurn { bg_command_id, .. } => {
1157 bg_command_id.as_deref()
1158 }
1159 crate::types::BashCommandAction::Command { .. } => None,
1160 };
1161 match background_id {
1162 Some(id) => format!("{consumer_id}:background:{id}"),
1163 None => format!("{consumer_id}:main"),
1164 }
1165}
1166
1167fn ensure_drainer(session: &Arc<DaemonSession>) {
1168 if session.drainer_started.swap(true, Ordering::AcqRel) {
1169 session.activity.notify_one();
1170 return;
1171 }
1172 let session = session.clone();
1173 tokio::spawn(async move {
1174 loop {
1175 if Arc::strong_count(&session) == 1 {
1176 return;
1177 }
1178 let capture = capture_session_outputs(&session).await;
1179 tokio::select! {
1180 () = tokio::time::sleep(capture.next_delay()) => {}
1181 () = session.activity.notified() => {}
1182 }
1183 }
1184 });
1185}
1186
1187async fn capture_session_outputs(session: &Arc<DaemonSession>) -> CaptureState {
1188 let _operation = session.operation_barrier.read().await;
1189 capture_session_outputs_locked(session).await
1190}
1191
1192async fn capture_session_outputs_locked(session: &Arc<DaemonSession>) -> CaptureState {
1193 let (thread_id, main) = {
1194 let state = session.state.lock().await;
1195 let Some(state) = state.as_ref() else { return CaptureState::default() };
1196 (state.current_thread_id.clone(), state.pty_shell.clone())
1197 };
1198 let mut capture = capture_shell_output(session, "main", &main).await;
1199
1200 let background_ids = session.background_ids.lock().await.clone();
1201 for id in background_ids {
1202 let shell = {
1203 let store = lock_session_store();
1204 store.resolve(&thread_id, &ShellTarget::Background(id.clone()))
1205 };
1206 if let Some(shell) = shell {
1207 capture.merge(capture_shell_output(session, &id, &shell).await);
1208 }
1209 }
1210 capture
1211}
1212
1213async fn capture_shell_output(
1214 session: &Arc<DaemonSession>,
1215 terminal_id: &str,
1216 shell: &SharedPtyShell,
1217) -> CaptureState {
1218 let (generation, snapshot, active) = {
1219 let mut guard = shell.lock().await;
1220 let Some(shell) = guard.as_mut() else { return CaptureState::default() };
1221 shell.poll_output_nonblocking();
1222 (shell.command_generation(), shell.output_snapshot(), shell.command_running)
1223 };
1224 let delta = {
1225 let mut observed = session.observed.lock().await;
1226 let (previous_generation, previous) =
1227 observed.entry(terminal_id.to_string()).or_insert_with(|| (generation, String::new()));
1228 if *previous_generation != generation {
1229 *previous_generation = generation;
1230 previous.clear();
1231 }
1232 let delta = snapshot
1233 .strip_prefix(previous.as_str())
1234 .map_or_else(|| snapshot.clone(), str::to_string);
1235 previous.clone_from(&snapshot);
1236 delta
1237 };
1238 let changed = !delta.is_empty();
1239 if changed {
1240 session.last_activity_unix_ms.store(unix_ms(), Ordering::Release);
1241 session.journal.lock().await.append(delta);
1242 }
1243 CaptureState { changed, active }
1244}
1245
1246async fn session_info(session: &Arc<DaemonSession>) -> Option<SessionInfo> {
1247 let _operation = session.operation_barrier.read().await;
1248 let (thread_id, cwd, main) = {
1249 let state = session.state.lock().await;
1250 let state = state.as_ref()?;
1251 (
1252 state.current_thread_id.clone(),
1253 state.cwd.to_string_lossy().into_owned(),
1254 state.pty_shell.clone(),
1255 )
1256 };
1257 let (shell_pid, running) = {
1258 let guard = main.lock().await;
1259 guard.as_ref().map_or((None, false), |shell| (shell.process_id(), shell.command_running))
1260 };
1261 let background_command_ids = active_background_command_ids(session, &thread_id).await;
1262 let last_command_at_unix_ms = session.last_command_at_unix_ms.load(Ordering::Acquire);
1263 Some(SessionInfo {
1264 thread_id,
1265 cwd,
1266 shell_pid,
1267 command_id: session.command_id.lock().await.clone(),
1268 running,
1269 background_command_ids,
1270 created_at_unix_ms: Some(session.created_at_unix_ms),
1271 last_activity_unix_ms: Some(session.last_activity_unix_ms.load(Ordering::Acquire)),
1272 last_command_at_unix_ms: (last_command_at_unix_ms > 0).then_some(last_command_at_unix_ms),
1273 ever_ran_command: session.ever_ran_command.load(Ordering::Acquire),
1274 runtime: None,
1275 })
1276}
1277
1278async fn active_background_command_ids(
1279 session: &Arc<DaemonSession>,
1280 thread_id: &str,
1281) -> Vec<String> {
1282 let candidates = session.background_ids.lock().await.clone();
1283 let mut active = Vec::new();
1284 for id in candidates {
1285 let shell = {
1286 let store = lock_session_store();
1287 store.resolve(thread_id, &ShellTarget::Background(id.clone()))
1288 };
1289 let Some(shell) = shell else { continue };
1290 capture_shell_output(session, &id, &shell).await;
1294 let running = {
1295 let guard = shell.lock().await;
1296 guard.as_ref().is_some_and(|shell| shell.command_running)
1297 };
1298 if running {
1299 active.push(id);
1300 }
1301 }
1302
1303 let active_set = active.iter().cloned().collect::<HashSet<_>>();
1304 session.background_ids.lock().await.retain(|id| active_set.contains(id));
1305 active.sort();
1306 active
1307}
1308
1309async fn interrupt_session(
1310 session: &Arc<DaemonSession>,
1311 params: &SessionParams,
1312 guardian_epoch: &str,
1313) -> Result<bool> {
1314 session.note_activity(false);
1315 let _operation = session.operation_barrier.read().await;
1316 if params.expected_guardian_epoch.as_deref().is_some_and(|expected| expected != guardian_epoch)
1317 || params
1318 .expected_execution
1319 .as_ref()
1320 .is_some_and(|expected| expected.guardian_epoch != guardian_epoch)
1321 {
1322 return Ok(false);
1323 }
1324 let session_epoch = session.session_epoch.lock().await;
1325 if params.expected_execution.as_ref().is_some_and(|expected| {
1326 expected.session_epoch != *session_epoch
1327 || params
1328 .expected_generation
1329 .is_some_and(|generation| generation != expected.generation)
1330 }) {
1331 return Ok(false);
1332 }
1333 drop(session_epoch);
1334 let shell = {
1335 let state = session.state.lock().await;
1336 state.as_ref().map(|state| state.pty_shell.clone())
1337 };
1338 let Some(shell) = shell else { return Ok(false) };
1339 let interrupted =
1340 crate::runtime::interrupt_pty_until_recovered(&shell, params.expected_generation).await?;
1341 if interrupted {
1342 capture_session_outputs_locked(session).await;
1343 }
1344 Ok(interrupted)
1345}
1346
1347fn unix_ms() -> u64 {
1348 SystemTime::now()
1349 .duration_since(UNIX_EPOCH)
1350 .ok()
1351 .and_then(|duration| u64::try_from(duration.as_millis()).ok())
1352 .unwrap_or(0)
1353}
1354
1355fn to_wire_error(error: WinxError) -> WireShellError {
1356 match error {
1357 WinxError::BashStateNotInitialized => WireShellError::BashStateNotInitialized,
1358 WinxError::ShellInitializationError(message) => {
1359 WireShellError::ShellInitialization(message)
1360 }
1361 WinxError::CommandExecutionError(message) => WireShellError::CommandExecution(message),
1362 WinxError::NoActiveCommand(message) => WireShellError::NoActiveCommand(message),
1363 WinxError::BackgroundSessionNotFound(message) => {
1364 WireShellError::BackgroundSessionNotFound(message)
1365 }
1366 WinxError::EmptyInteractiveInput { action } => {
1367 WireShellError::EmptyInteractiveInput(action)
1368 }
1369 WinxError::InteractiveTargetNotRunning(message) => {
1370 WireShellError::InteractiveTargetNotRunning(message)
1371 }
1372 WinxError::CommandAlreadyRunning { current_command, duration_seconds } => {
1373 WireShellError::CommandAlreadyRunning { current_command, duration_seconds }
1374 }
1375 WinxError::CommandNotAllowed(message) => WireShellError::CommandNotAllowed(message),
1376 WinxError::ThreadIdMismatch(message) => WireShellError::ThreadIdMismatch(message),
1377 WinxError::InvalidInput(message) | WinxError::ArgumentParseError(message) => {
1378 WireShellError::InvalidInput(message)
1379 }
1380 other => WireShellError::Other(other.to_string()),
1381 }
1382}
1383
1384fn rpc_result<T: serde::Serialize>(id: u64, result: &T) -> RpcResponse {
1385 match serde_json::to_value(result) {
1386 Ok(result) => {
1387 RpcResponse { jsonrpc: "2.0".to_string(), id, result: Some(result), error: None }
1388 }
1389 Err(error) => rpc_error(id, -32603, &error.to_string()),
1390 }
1391}
1392
1393fn rpc_error(id: u64, code: i32, message: &str) -> RpcResponse {
1394 RpcResponse {
1395 jsonrpc: "2.0".to_string(),
1396 id,
1397 result: None,
1398 error: Some(RpcError { code, message: message.to_string() }),
1399 }
1400}
1401
1402pub(crate) fn schedule_process_exit(role: &'static str) {
1405 tokio::spawn(async move {
1406 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1407 tracing::info!("{role} exiting on winx.shutdown request");
1408 std::process::exit(0);
1409 });
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414 #![allow(clippy::expect_used)]
1415 use std::collections::HashMap;
1416 use std::sync::atomic::Ordering;
1417 use std::sync::Arc;
1418 use std::time::{Duration, Instant};
1419
1420 use tokio::sync::{watch, Mutex};
1421
1422 use super::{
1423 canonical_action_fingerprint, configure_session, consume_launch_cancellation_flag,
1424 interrupt_session, launch_cancellation_flag, run_action, validate_identifier,
1425 ActionCompletion, ActionEntry, ActionOwnerGuard, CaptureState, DaemonSession,
1426 OutputJournal, MAX_SESSION_CACHE_ENTRIES,
1427 };
1428 use crate::daemon::protocol::{
1429 ConfigureSessionParams, ConfigureSessionTransition, RunActionParams, SessionParams,
1430 WireShellError,
1431 };
1432 use crate::errors::WinxError;
1433 use crate::runtime::ShellActionOptions;
1434 use crate::state::bash_state::BashState;
1435 use crate::types::BashCommand;
1436
1437 fn shell_path(path: &std::path::Path) -> String {
1441 crate::utils::path::display_relative(path)
1442 }
1443
1444 fn action_params(state: &BashState, command: &str, request_key: &str) -> RunActionParams {
1445 let command: BashCommand = serde_json::from_value(serde_json::json!({
1446 "action_json": {
1447 "type": "command",
1448 "command": command,
1449 "is_background": false,
1450 "allow_multi": true
1451 },
1452 "thread_id": state.current_thread_id,
1453 "wait_for_seconds": 2.0
1454 }))
1455 .expect("valid command");
1456 RunActionParams {
1457 snapshot: state.snapshot(),
1458 command,
1459 request_key: request_key.to_string(),
1460 consumer_id: "single-flight-test".to_string(),
1461 options: ShellActionOptions::default(),
1462 }
1463 }
1464
1465 #[tokio::test]
1466 async fn existing_session_attach_bypasses_busy_operation_barrier() {
1467 let thread_id = "busy_attach";
1468 let session = Arc::new(DaemonSession::new());
1469 let mut state = BashState::new();
1470 state.current_thread_id = thread_id.to_string();
1471 state.initialized = true;
1472 let snapshot = state.snapshot();
1473 *session.state.lock().await = Some(state);
1474
1475 let sessions =
1476 Arc::new(Mutex::new(HashMap::from([(thread_id.to_string(), Arc::clone(&session))])));
1477 let _active_operation = session.operation_barrier.write().await;
1478 let attached = tokio::time::timeout(
1479 Duration::from_millis(250),
1480 configure_session(
1481 &sessions,
1482 ConfigureSessionParams {
1483 snapshot,
1484 transition: ConfigureSessionTransition::FirstCall,
1485 },
1486 ),
1487 )
1488 .await
1489 .expect("attach must not queue behind the active shell operation")
1490 .expect("attach existing session");
1491
1492 assert!(attached.attached_existing);
1493 assert_eq!(attached.snapshot.expect("authoritative daemon snapshot").chat_id, thread_id);
1494 }
1495
1496 #[test]
1497 fn late_consumer_is_told_when_journal_head_was_dropped() {
1498 let mut journal = OutputJournal::default();
1499 journal.append("a".repeat(3 * 1024 * 1024));
1500 journal.append("b".repeat(3 * 1024 * 1024));
1501
1502 let read = journal.read("late");
1503 assert!(read.gap);
1504 assert!(read.output.starts_with('b'));
1505 }
1506
1507 #[test]
1508 fn journal_consumer_cursors_are_lru_bounded() {
1509 let mut journal = OutputJournal::default();
1510 journal.append("output".to_string());
1511 for index in 0..(MAX_SESSION_CACHE_ENTRIES + 100) {
1512 let _ = journal.read(&format!("consumer-{index}"));
1513 }
1514 assert_eq!(journal.cursors.len(), MAX_SESSION_CACHE_ENTRIES);
1515 }
1516
1517 #[test]
1518 fn daemon_identifiers_have_a_small_memory_bound() {
1519 assert!(validate_identifier("consumer_id", &"x".repeat(256)).is_ok());
1520 assert!(validate_identifier("consumer_id", &"x".repeat(257)).is_err());
1521 }
1522
1523 #[test]
1524 fn drainer_backoff_tracks_output_and_command_activity() {
1525 assert!(
1526 CaptureState { changed: true, active: false }.next_delay()
1527 < CaptureState { changed: false, active: true }.next_delay()
1528 );
1529 assert!(
1530 CaptureState { changed: false, active: true }.next_delay()
1531 < CaptureState { changed: false, active: false }.next_delay()
1532 );
1533 }
1534
1535 #[cfg_attr(windows, ignore = "flaky under Git Bash cold start on shared Windows CI runners")]
1539 #[tokio::test]
1540 async fn request_key_is_single_flight_and_replays_lost_response() {
1541 let temp = tempfile::tempdir().expect("temporary directory");
1542 let marker = temp.path().join("single-flight.marker");
1543 let mut state = BashState::new();
1544 state.cwd = temp.path().to_path_buf();
1545 state.workspace_root = temp.path().to_path_buf();
1546 state.current_thread_id = "single_flight".to_string();
1547 let command = format!("printf x >> '{}'; sleep 0.1", shell_path(&marker));
1548 let params = action_params(&state, &command, "same-request");
1549 let sessions = Arc::new(Mutex::new(HashMap::<String, Arc<DaemonSession>>::new()));
1550
1551 let (first, concurrent) = tokio::join!(
1552 run_action(&sessions, params.clone(), "guardian-a"),
1553 run_action(&sessions, params.clone(), "guardian-a")
1554 );
1555 assert!(first.expect("owner result").error.is_none());
1556 assert!(concurrent.expect("shared result").error.is_none());
1557 let replay = run_action(&sessions, params, "guardian-a").await.expect("cached replay");
1558 assert!(replay.error.is_none());
1559 assert_eq!(std::fs::read_to_string(marker).expect("marker"), "x");
1560 }
1561
1562 #[tokio::test]
1563 async fn request_key_collision_rejects_different_parameters() {
1564 let temp = tempfile::tempdir().expect("temporary directory");
1565 let mut state = BashState::new();
1566 state.cwd = temp.path().to_path_buf();
1567 state.workspace_root = temp.path().to_path_buf();
1568 state.current_thread_id = "request_collision".to_string();
1569 let sessions = Arc::new(Mutex::new(HashMap::<String, Arc<DaemonSession>>::new()));
1570 run_action(&sessions, action_params(&state, "printf first", "collision"), "guardian-a")
1571 .await
1572 .expect("first action");
1573
1574 let error = run_action(
1575 &sessions,
1576 action_params(&state, "printf second", "collision"),
1577 "guardian-a",
1578 )
1579 .await
1580 .expect_err("collision must fail");
1581 assert!(error.to_string().contains("different shell action parameters"));
1582 }
1583
1584 #[tokio::test]
1585 async fn non_launching_action_paths_consume_unowned_cancellation_tombstones() {
1586 let temp = tempfile::tempdir().expect("temporary directory");
1587 let mut state = BashState::new();
1588 state.cwd = temp.path().to_path_buf();
1589 state.workspace_root = temp.path().to_path_buf();
1590 state.current_thread_id = "early_cancellation_cleanup".to_string();
1591 let session = Arc::new(DaemonSession::new());
1592 let sessions = Arc::new(Mutex::new(HashMap::from([(
1593 state.current_thread_id.clone(),
1594 Arc::clone(&session),
1595 )])));
1596
1597 let mut replay = action_params(&state, "printf replay", "replay-request");
1598 replay.options.cancellation_key = Some("replay-cancel".to_string());
1599 run_action(&sessions, replay.clone(), "guardian-a").await.expect("initial replay action");
1600 let replay_tombstone =
1601 launch_cancellation_flag(&session, "replay-cancel").await.expect("replay tombstone");
1602 replay_tombstone.store(true, Ordering::Release);
1603 drop(replay_tombstone);
1604 run_action(&sessions, replay, "guardian-a").await.expect("cached replay");
1605 assert!(!session.launch_reservations.lock().await.contains_key("replay-cancel"));
1606
1607 let mut first_collision =
1608 action_params(&state, "printf collision-one", "collision-request");
1609 first_collision.options.cancellation_key = Some("collision-cancel".to_string());
1610 run_action(&sessions, first_collision, "guardian-a")
1611 .await
1612 .expect("initial collision action");
1613 let collision_tombstone = launch_cancellation_flag(&session, "collision-cancel")
1614 .await
1615 .expect("collision tombstone");
1616 collision_tombstone.store(true, Ordering::Release);
1617 drop(collision_tombstone);
1618 let mut collision = action_params(&state, "printf collision-two", "collision-request");
1619 collision.options.cancellation_key = Some("collision-cancel".to_string());
1620 assert!(run_action(&sessions, collision, "guardian-a").await.is_err());
1621 assert!(!session.launch_reservations.lock().await.contains_key("collision-cancel"));
1622
1623 {
1624 let mut completed = session.completed.lock().await;
1625 completed.clear();
1626 for index in 0..MAX_SESSION_CACHE_ENTRIES {
1627 completed.insert(
1628 format!("working-{index}"),
1629 ActionEntry {
1630 fingerprint: vec![u8::try_from(index % 255).unwrap_or_default()],
1631 cancellation_key: None,
1632 completion: ActionCompletion::InFlight(Arc::new(watch::channel(None).0)),
1633 last_seen: Instant::now(),
1634 },
1635 );
1636 }
1637 }
1638 let saturation_tombstone = launch_cancellation_flag(&session, "saturation-cancel")
1639 .await
1640 .expect("saturation tombstone");
1641 saturation_tombstone.store(true, Ordering::Release);
1642 drop(saturation_tombstone);
1643 let mut saturated = action_params(&state, "printf blocked", "overflow-request");
1644 saturated.options.cancellation_key = Some("saturation-cancel".to_string());
1645 assert!(matches!(
1646 run_action(&sessions, saturated, "guardian-a").await,
1647 Err(WinxError::ResourceAllocationError { .. })
1648 ));
1649 assert!(!session.launch_reservations.lock().await.contains_key("saturation-cancel"));
1650 }
1651
1652 #[cfg_attr(windows, ignore = "flaky under Git Bash cold start on shared Windows CI runners")]
1656 #[tokio::test]
1657 async fn action_waiter_does_not_consume_the_owners_cancellation_reservation() {
1658 let temp = tempfile::tempdir().expect("temporary directory");
1659 let started = temp.path().join("single-flight-started.marker");
1660 let release = temp.path().join("single-flight-release.marker");
1661 let mut state = BashState::new();
1662 state.cwd = temp.path().to_path_buf();
1663 state.workspace_root = temp.path().to_path_buf();
1664 state.current_thread_id = "waiter_cancellation_owner".to_string();
1665 let session = Arc::new(DaemonSession::new());
1666 let sessions = Arc::new(Mutex::new(HashMap::from([(
1667 state.current_thread_id.clone(),
1668 Arc::clone(&session),
1669 )])));
1670 let mut params = action_params(
1671 &state,
1672 &format!(
1673 "sh -c 'touch \"{}\"; while [ ! -e \"{}\" ]; do sleep 0.01; done'",
1674 shell_path(&started),
1675 shell_path(&release)
1676 ),
1677 "shared-cancel-request",
1678 );
1679 params.options.cancellation_key = Some("shared-cancel-key".to_string());
1680 params.command.wait_for_seconds = Some(30.0);
1688
1689 let owner_sessions = Arc::clone(&sessions);
1690 let owner_params = params.clone();
1691 let owner =
1692 tokio::spawn(
1693 async move { run_action(&owner_sessions, owner_params, "guardian-a").await },
1694 );
1695 tokio::time::timeout(Duration::from_secs(10), async {
1696 while !started.exists() {
1697 tokio::time::sleep(Duration::from_millis(5)).await;
1698 }
1699 })
1700 .await
1701 .expect("owner command never started");
1702
1703 let waiter_sessions = Arc::clone(&sessions);
1704 let waiter =
1705 tokio::spawn(async move { run_action(&waiter_sessions, params, "guardian-a").await });
1706 tokio::time::sleep(Duration::from_millis(25)).await;
1707 assert!(
1708 session.launch_reservations.lock().await.contains_key("shared-cancel-key"),
1709 "a waiter consumed the owner's launch reservation"
1710 );
1711
1712 std::fs::write(release, b"release").expect("release owner command");
1713 owner.await.expect("owner join").expect("owner result");
1714 waiter.await.expect("waiter join").expect("waiter result");
1715 assert!(!session.launch_reservations.lock().await.contains_key("shared-cancel-key"));
1716 }
1717
1718 #[test]
1719 fn action_fingerprint_is_canonical_across_map_insertion_order() {
1720 let mut first = BashState::new();
1721 first.current_thread_id = "canonical".to_string();
1722 first.whitelist_for_overwrite.insert(
1723 "z".to_string(),
1724 crate::state::bash_state::FileWhitelistData::new("z".into(), vec![(1, 1)], 1),
1725 );
1726 first.whitelist_for_overwrite.insert(
1727 "a".to_string(),
1728 crate::state::bash_state::FileWhitelistData::new("a".into(), vec![(1, 1)], 1),
1729 );
1730 let mut second = BashState::new();
1731 second.current_thread_id = "canonical".to_string();
1732 second.whitelist_for_overwrite.insert(
1733 "a".to_string(),
1734 crate::state::bash_state::FileWhitelistData::new("a".into(), vec![(1, 1)], 1),
1735 );
1736 second.whitelist_for_overwrite.insert(
1737 "z".to_string(),
1738 crate::state::bash_state::FileWhitelistData::new("z".into(), vec![(1, 1)], 1),
1739 );
1740
1741 assert_eq!(
1742 canonical_action_fingerprint(&action_params(&first, "printf stable", "key"))
1743 .expect("first fingerprint"),
1744 canonical_action_fingerprint(&action_params(&second, "printf stable", "key"))
1745 .expect("second fingerprint")
1746 );
1747 }
1748
1749 #[tokio::test]
1750 async fn action_cache_applies_backpressure_when_every_entry_is_in_flight() {
1751 let state = BashState::new();
1752 let session = Arc::new(DaemonSession::new());
1753 {
1754 let mut completed = session.completed.lock().await;
1755 for index in 0..MAX_SESSION_CACHE_ENTRIES {
1756 completed.insert(
1757 format!("working-{index}"),
1758 ActionEntry {
1759 fingerprint: vec![u8::try_from(index % 255).unwrap_or_default()],
1760 cancellation_key: None,
1761 completion: ActionCompletion::InFlight(Arc::new(watch::channel(None).0)),
1762 last_seen: Instant::now(),
1763 },
1764 );
1765 }
1766 }
1767 let sessions =
1768 Arc::new(Mutex::new(HashMap::from([(state.current_thread_id.clone(), session)])));
1769 let error = run_action(
1770 &sessions,
1771 action_params(&state, "printf blocked", "overflow"),
1772 "guardian-a",
1773 )
1774 .await
1775 .expect_err("all-in-flight cache must reject new reservations");
1776 assert!(matches!(error, WinxError::ResourceAllocationError { .. }));
1777 }
1778
1779 #[tokio::test]
1780 async fn cancellation_reservations_never_evict_active_flags() {
1781 let session = DaemonSession::new();
1782 let mut active = Vec::new();
1783 for index in 0..MAX_SESSION_CACHE_ENTRIES {
1784 active.push(
1785 launch_cancellation_flag(&session, &format!("active-{index}"))
1786 .await
1787 .expect("active cancellation reservation"),
1788 );
1789 }
1790 let error = launch_cancellation_flag(&session, "overflow")
1791 .await
1792 .expect_err("all active cancellation flags must apply backpressure");
1793 assert!(matches!(error, WinxError::ResourceAllocationError { .. }));
1794 assert_eq!(session.launch_reservations.lock().await.len(), MAX_SESSION_CACHE_ENTRIES);
1795
1796 drop(active.pop());
1797 launch_cancellation_flag(&session, "overflow")
1798 .await
1799 .expect("inactive cancellation flag should be evictable");
1800 assert_eq!(session.launch_reservations.lock().await.len(), MAX_SESSION_CACHE_ENTRIES);
1801 }
1802
1803 #[tokio::test]
1804 async fn cancelled_tombstones_apply_backpressure_until_consumed() {
1805 let session = DaemonSession::new();
1806 for index in 0..MAX_SESSION_CACHE_ENTRIES {
1807 let flag = launch_cancellation_flag(&session, &format!("cancelled-{index}"))
1808 .await
1809 .expect("cancel tombstone");
1810 flag.store(true, Ordering::Release);
1811 }
1812
1813 let error = launch_cancellation_flag(&session, "overflow")
1814 .await
1815 .expect_err("cancel tombstones must not be evicted for capacity");
1816 assert!(matches!(error, WinxError::ResourceAllocationError { .. }));
1817
1818 let consumed = session
1819 .launch_reservations
1820 .lock()
1821 .await
1822 .get("cancelled-0")
1823 .expect("preserved tombstone")
1824 .value
1825 .clone();
1826 consume_launch_cancellation_flag(&session, "cancelled-0", &consumed).await;
1827 launch_cancellation_flag(&session, "replacement")
1828 .await
1829 .expect("consumed tombstone releases capacity");
1830 }
1831
1832 #[tokio::test]
1833 async fn cancel_before_action_survives_capacity_churn_and_prevents_launch() {
1834 let temp = tempfile::tempdir().expect("temporary directory");
1835 let marker = temp.path().join("cancelled-before-action.marker");
1836 let mut state = BashState::new();
1837 state.cwd = temp.path().to_path_buf();
1838 state.workspace_root = temp.path().to_path_buf();
1839 state.current_thread_id = "cancel_tombstone_churn".to_string();
1840 let session = Arc::new(DaemonSession::new());
1841 let sessions = Arc::new(Mutex::new(HashMap::from([(
1842 state.current_thread_id.clone(),
1843 Arc::clone(&session),
1844 )])));
1845
1846 let cancelled = launch_cancellation_flag(&session, "cancel-before-action")
1847 .await
1848 .expect("cancel reservation");
1849 cancelled.store(true, Ordering::Release);
1850 drop(cancelled);
1851 for index in 0..(MAX_SESSION_CACHE_ENTRIES * 2) {
1852 drop(
1853 launch_cancellation_flag(&session, &format!("churn-{index}"))
1854 .await
1855 .expect("inactive churn reservation"),
1856 );
1857 }
1858 assert!(
1859 session
1860 .launch_reservations
1861 .lock()
1862 .await
1863 .get("cancel-before-action")
1864 .is_some_and(|entry| entry.value.load(Ordering::Acquire)),
1865 "capacity churn evicted the cancellation tombstone"
1866 );
1867
1868 let mut params = action_params(
1869 &state,
1870 &format!("printf forbidden > '{}'", shell_path(&marker)),
1871 "cancelled-action-request",
1872 );
1873 params.options.cancellation_key = Some("cancel-before-action".to_string());
1874 let result =
1875 run_action(&sessions, params, "guardian-a").await.expect("cancelled action result");
1876 assert!(matches!(
1877 result.error,
1878 Some(WireShellError::CommandExecution(ref message))
1879 if message.contains("cancelled before command launch")
1880 ));
1881 assert!(!marker.exists(), "cancelled action reached the PTY");
1882 assert!(
1883 !session.launch_reservations.lock().await.contains_key("cancel-before-action"),
1884 "the action did not consume its cancellation tombstone"
1885 );
1886 }
1887
1888 #[cfg_attr(windows, ignore = "flaky under Git Bash cold start on shared Windows CI runners")]
1892 #[tokio::test]
1893 async fn automatic_pty_reset_rotates_before_stale_operations_reach_the_new_pty() {
1894 let temp = tempfile::tempdir().expect("temporary directory");
1895 let marker = temp.path().join("new-incarnation-running.marker");
1896 let completed_marker = temp.path().join("new-incarnation-completed.marker");
1897 let mut state = BashState::new();
1898 state.cwd = temp.path().to_path_buf();
1899 state.workspace_root = temp.path().to_path_buf();
1900 state.current_thread_id = "automatic_pty_reset".to_string();
1901 let sessions = Arc::new(Mutex::new(HashMap::new()));
1902
1903 let first = run_action(
1904 &sessions,
1905 action_params(&state, "printf first-incarnation", "before-auto-reset"),
1906 "guardian-a",
1907 )
1908 .await
1909 .expect("first action");
1910 let prior_token = first.execution_token.expect("first execution token");
1911 let session =
1912 sessions.lock().await.get("automatic_pty_reset").expect("daemon session").clone();
1913
1914 let mut forced = action_params(
1915 &state,
1916 &format!(
1917 "sh -c 'touch \"{}\"; sleep 0.4; printf completed > \"{}\"'",
1918 shell_path(&marker),
1919 shell_path(&completed_marker)
1920 ),
1921 "forced-auto-reset",
1922 );
1923 forced.options.force_clear_to_run_failure = true;
1924 let reset_sessions = Arc::clone(&sessions);
1925 let reset =
1926 tokio::spawn(async move { run_action(&reset_sessions, forced, "guardian-a").await });
1927 tokio::time::timeout(Duration::from_secs(2), async {
1928 while !marker.exists() {
1929 tokio::time::sleep(Duration::from_millis(5)).await;
1930 }
1931 })
1932 .await
1933 .expect("replacement PTY command never started");
1934 assert_ne!(
1935 *session.session_epoch.lock().await,
1936 prior_token.session_epoch,
1937 "epoch was not rotated before the replacement command launched"
1938 );
1939 assert!(!reset.is_finished(), "reset action returned before the interleaving probe");
1940
1941 let mut stale_status = action_params(&state, "printf unused", "stale-after-auto-reset");
1942 stale_status.command = serde_json::from_value(serde_json::json!({
1943 "action_json": {"type": "status_check", "status_check": true},
1944 "thread_id": "automatic_pty_reset",
1945 "wait_for_seconds": 0.0
1946 }))
1947 .expect("status command");
1948 stale_status.options.expected_generation = Some(prior_token.generation);
1949 stale_status.options.expected_execution = Some(prior_token.clone());
1950 assert!(run_action(&sessions, stale_status, "guardian-a").await.is_err());
1951
1952 assert!(!interrupt_session(
1953 &session,
1954 &SessionParams {
1955 thread_id: "automatic_pty_reset".to_string(),
1956 expected_generation: Some(prior_token.generation),
1957 expected_execution: Some(prior_token.clone()),
1958 expected_guardian_epoch: Some("guardian-a".to_string()),
1959 },
1960 "guardian-a",
1961 )
1962 .await
1963 .expect("stale interrupt result"));
1964
1965 let reset = reset.await.expect("reset action join").expect("reset action result");
1966 let current = reset.execution_token.clone().expect("reset execution token");
1967 assert_eq!(prior_token.generation, current.generation);
1968 assert_ne!(prior_token.session_epoch, current.session_epoch);
1969 assert_eq!(
1970 std::fs::read_to_string(completed_marker).expect("replacement completion marker"),
1971 "completed",
1972 "a stale operation disturbed the replacement command: {reset:?}"
1973 );
1974 }
1975
1976 #[tokio::test]
1977 async fn abandoned_action_owner_notifies_waiters() {
1978 let completion = Arc::new(watch::channel(None).0);
1979 let receiver = completion.subscribe();
1980 drop(ActionOwnerGuard::new(completion));
1981 let error = super::wait_for_action_completion(receiver)
1982 .await
1983 .expect_err("abandoned owner must fail its waiters");
1984 assert!(error.to_string().contains("owner disappeared"));
1985 }
1986
1987 #[tokio::test]
1988 #[allow(clippy::too_many_lines)] async fn reset_is_atomic_with_action_status_and_interrupt_across_generation_collision() {
1990 let temp = tempfile::tempdir().expect("temporary directory");
1991 let mut state = BashState::new();
1992 state.cwd = temp.path().to_path_buf();
1993 state.workspace_root = temp.path().to_path_buf();
1994 state.current_thread_id = "atomic_reset".to_string();
1995 let session = Arc::new(DaemonSession::new());
1996 let sessions = Arc::new(Mutex::new(HashMap::from([(
1997 state.current_thread_id.clone(),
1998 Arc::clone(&session),
1999 )])));
2000 let held = session.operation_barrier.write().await;
2001 let owner_sessions = Arc::clone(&sessions);
2002 let first_params =
2003 action_params(&state, "sh -c 'sleep 0.25; printf old-incarnation'", "old-action");
2004 let first =
2005 tokio::spawn(
2006 async move { run_action(&owner_sessions, first_params, "guardian-a").await },
2007 );
2008
2009 while !session.completed.lock().await.contains_key("old-action") {
2010 tokio::task::yield_now().await;
2011 }
2012 tokio::time::sleep(Duration::from_millis(10)).await;
2015 let reset_sessions = Arc::clone(&sessions);
2016 let reset_snapshot = state.snapshot();
2017 let mut reset = tokio::spawn(async move {
2018 configure_session(
2019 &reset_sessions,
2020 ConfigureSessionParams {
2021 snapshot: reset_snapshot,
2022 transition: ConfigureSessionTransition::Reset,
2023 },
2024 )
2025 .await
2026 });
2027 drop(held);
2028 assert!(
2029 tokio::time::timeout(Duration::from_millis(50), &mut reset).await.is_err(),
2030 "reset crossed an in-flight action's session barrier"
2031 );
2032
2033 let first = tokio::time::timeout(Duration::from_secs(5), first)
2034 .await
2035 .expect("first action timeout")
2036 .expect("first join")
2037 .expect("first result");
2038 let stale_execution = first.execution_token.expect("old execution token");
2039 tokio::time::timeout(Duration::from_secs(5), reset)
2040 .await
2041 .expect("reset timeout")
2042 .expect("reset join")
2043 .expect("reset result");
2044
2045 let mut new_action =
2046 action_params(&state, "sh -c 'sleep 0.15; printf new-incarnation'", "new-action");
2047 new_action.command.wait_for_seconds = Some(0.001);
2048 let current = tokio::time::timeout(
2049 Duration::from_secs(5),
2050 run_action(&sessions, new_action, "guardian-a"),
2051 )
2052 .await
2053 .expect("new action timeout")
2054 .expect("new action");
2055 let current_token = current.execution_token.expect("current execution token");
2056 assert_eq!(stale_execution.generation, current_token.generation);
2057 assert_ne!(stale_execution.session_epoch, current_token.session_epoch);
2058
2059 let mut stale_status = action_params(&state, "printf unused", "stale-status");
2060 stale_status.command = serde_json::from_value(serde_json::json!({
2061 "action_json": {"type": "status_check", "status_check": true},
2062 "thread_id": "atomic_reset",
2063 "wait_for_seconds": 0.01
2064 }))
2065 .expect("status command");
2066 stale_status.options.expected_generation = Some(stale_execution.generation);
2067 stale_status.options.expected_execution = Some(stale_execution.clone());
2068 assert!(tokio::time::timeout(
2069 Duration::from_secs(5),
2070 run_action(&sessions, stale_status, "guardian-a"),
2071 )
2072 .await
2073 .expect("stale status timeout")
2074 .is_err());
2075
2076 assert!(!interrupt_session(
2077 &session,
2078 &SessionParams {
2079 thread_id: "atomic_reset".to_string(),
2080 expected_generation: Some(stale_execution.generation),
2081 expected_execution: Some(stale_execution),
2082 expected_guardian_epoch: Some("guardian-a".to_string()),
2083 },
2084 "guardian-a",
2085 )
2086 .await
2087 .expect("stale interrupt"));
2088
2089 let mut current_status = action_params(&state, "printf unused", "current-status");
2090 current_status.command = serde_json::from_value(serde_json::json!({
2091 "action_json": {"type": "status_check", "status_check": true},
2092 "thread_id": "atomic_reset",
2093 "wait_for_seconds": 1.0
2094 }))
2095 .expect("current status command");
2096 current_status.options.expected_generation = Some(current_token.generation);
2097 current_status.options.expected_execution = Some(current_token);
2098 let drained = tokio::time::timeout(
2099 Duration::from_secs(5),
2100 run_action(&sessions, current_status, "guardian-a"),
2101 )
2102 .await
2103 .expect("current status timeout")
2104 .expect("current status");
2105 assert!(
2106 drained.output.as_deref().is_some_and(|output| output.contains("new-incarnation")),
2107 "stale operations consumed or interrupted the new incarnation: {drained:?}"
2108 );
2109 }
2110}