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