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