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