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