Skip to main content

winx_code_agent/daemon/
server.rs

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