Skip to main content

winx_code_agent/daemon/
client.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use tokio::net::UnixStream;
8use tokio::sync::Mutex;
9
10use super::protocol::{
11    read_json_frame, write_json_frame, CancelActionParams, ConfigureSessionParams,
12    ConfigureSessionResult, ConfigureSessionTransition, HelloResult, JournalRead,
13    JournalReadParams, PruneParams, PruneResult, RpcRequest, RpcResponse, RunActionParams,
14    RunActionResult, SessionInfo, SessionParams, WireShellError,
15    CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY, COMPACT_ACTION_OUTPUT_CAPABILITY,
16    GENERATION_BOUND_ACTIONS_CAPABILITY, PROTOCOL_MAJOR, TYPED_ACTION_RESULT_CAPABILITY,
17};
18use crate::errors::{Result, WinxError};
19use crate::runtime::{
20    BashCommandRuntimeResult, ShellActionOptions, ShellExecutionToken, ShellRuntime,
21    ShellRuntimeBoolFuture, ShellRuntimeConfigureFuture, ShellRuntimeDetailedFuture,
22    ShellRuntimeFuture, ShellRuntimeUnitFuture, ShellSessionConfiguration, ShellSessionTransition,
23};
24use crate::state::bash_state::BashState;
25use crate::tools::bash_command::BashCommandResult;
26use crate::types::BashCommand;
27
28/// Shell runtime backed by a `winxd` Unix-domain socket.
29#[derive(Clone, Debug)]
30pub struct DaemonShellRuntime {
31    socket_path: PathBuf,
32    consumer_id: String,
33    negotiations: Arc<Mutex<NegotiationCache>>,
34}
35
36#[derive(Debug, Default)]
37struct NegotiationCache {
38    sessions: HashMap<String, CachedNegotiation>,
39    gates: HashMap<String, NegotiationGate>,
40    local_revisions: HashMap<String, LocalRevision>,
41}
42
43#[derive(Debug)]
44struct CachedNegotiation {
45    session: Arc<Mutex<NegotiatedSession>>,
46    last_seen: Instant,
47}
48
49#[derive(Debug)]
50struct NegotiationGate {
51    gate: Arc<Mutex<()>>,
52    last_seen: Instant,
53}
54
55#[derive(Debug)]
56struct LocalRevision {
57    revision: Arc<AtomicU64>,
58    last_seen: Instant,
59}
60
61const MAX_NEGOTIATED_SESSIONS: usize = 64;
62const NEGOTIATION_TTL: Duration = Duration::from_secs(5 * 60);
63
64#[derive(Debug)]
65struct NegotiatedSession {
66    control_epoch: String,
67    guardian: HelloResult,
68    stream: UnixStream,
69}
70
71/// Control-plane client used by the CLI and reconnection tests.
72#[derive(Clone, Debug)]
73pub struct DaemonClient {
74    socket_path: PathBuf,
75}
76
77impl DaemonClient {
78    pub fn new(socket_path: impl AsRef<Path>) -> Self {
79        Self { socket_path: socket_path.as_ref().to_path_buf() }
80    }
81
82    async fn connected_with_hello(&self) -> Result<(UnixStream, HelloResult)> {
83        let mut stream = self.connect_raw().await?;
84        let hello: HelloResult = DaemonShellRuntime::request(
85            &mut stream,
86            rand::random::<u64>(),
87            "winx.hello",
88            serde_json::json!({ "protocol_major": PROTOCOL_MAJOR }),
89        )
90        .await?;
91        if hello.protocol_major != PROTOCOL_MAJOR {
92            return Err(WinxError::ConfigurationError(format!(
93                "winxd protocol major {} is incompatible with client {}",
94                hello.protocol_major, PROTOCOL_MAJOR
95            )));
96        }
97        Ok((stream, hello))
98    }
99
100    async fn connect_raw(&self) -> Result<UnixStream> {
101        UnixStream::connect(&self.socket_path).await.map_err(|error| {
102            WinxError::ShellInitializationError(format!(
103                "cannot connect to winxd at {}: {error}",
104                self.socket_path.display()
105            ))
106        })
107    }
108
109    async fn connected(&self) -> Result<UnixStream> {
110        self.connected_with_hello().await.map(|(stream, _)| stream)
111    }
112
113    pub async fn hello(&self) -> Result<HelloResult> {
114        self.connected_with_hello().await.map(|(_, hello)| hello)
115    }
116
117    pub async fn list_sessions(&self) -> Result<Vec<SessionInfo>> {
118        let mut stream = self.connected().await?;
119        DaemonShellRuntime::request(
120            &mut stream,
121            rand::random::<u64>(),
122            "session.list",
123            serde_json::json!({}),
124        )
125        .await
126    }
127
128    pub async fn session_info(&self, thread_id: &str) -> Result<SessionInfo> {
129        let mut stream = self.connected().await?;
130        DaemonShellRuntime::request(
131            &mut stream,
132            rand::random::<u64>(),
133            "session.info",
134            serde_json::to_value(SessionParams {
135                thread_id: thread_id.to_string(),
136                expected_generation: None,
137                expected_execution: None,
138                expected_guardian_epoch: None,
139            })
140            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
141        )
142        .await
143    }
144
145    pub async fn read_output(&self, thread_id: &str, consumer_id: &str) -> Result<JournalRead> {
146        let mut stream = self.connected().await?;
147        DaemonShellRuntime::request(
148            &mut stream,
149            rand::random::<u64>(),
150            "session.read_output",
151            serde_json::to_value(JournalReadParams {
152                thread_id: thread_id.to_string(),
153                consumer_id: consumer_id.to_string(),
154            })
155            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
156        )
157        .await
158    }
159
160    pub async fn kill_session(&self, thread_id: &str) -> Result<bool> {
161        let mut stream = self.connected().await?;
162        DaemonShellRuntime::request(
163            &mut stream,
164            rand::random::<u64>(),
165            "session.kill",
166            serde_json::to_value(SessionParams {
167                thread_id: thread_id.to_string(),
168                expected_generation: None,
169                expected_execution: None,
170                expected_guardian_epoch: None,
171            })
172            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
173        )
174        .await
175    }
176
177    pub async fn prune_sessions(&self, idle_seconds: Option<u64>) -> Result<PruneResult> {
178        let mut stream = self.connected().await?;
179        DaemonShellRuntime::request(
180            &mut stream,
181            rand::random::<u64>(),
182            "session.prune",
183            serde_json::to_value(PruneParams { idle_seconds })
184                .map_err(|error| WinxError::SerializationError(error.to_string()))?,
185        )
186        .await
187    }
188
189    pub async fn interrupt_session(&self, thread_id: &str) -> Result<()> {
190        let _ = self.interrupt_session_generation(thread_id, None).await?;
191        Ok(())
192    }
193
194    pub async fn interrupt_session_generation(
195        &self,
196        thread_id: &str,
197        expected_generation: Option<u64>,
198    ) -> Result<bool> {
199        self.interrupt_session_execution(
200            thread_id,
201            expected_generation.map(|generation| ShellExecutionToken {
202                guardian_epoch: String::new(),
203                session_epoch: String::new(),
204                generation,
205            }),
206        )
207        .await
208    }
209
210    async fn interrupt_session_execution(
211        &self,
212        thread_id: &str,
213        expected_execution: Option<ShellExecutionToken>,
214    ) -> Result<bool> {
215        // Cancellation deliberately uses a fresh connection. A long-running
216        // status request on the negotiated action channel must never delay it.
217        let (mut stream, _) = self.connected_with_hello().await?;
218        let hello: HelloResult = DaemonShellRuntime::request(
219            &mut stream,
220            rand::random::<u64>(),
221            "session.negotiate",
222            serde_json::to_value(SessionParams {
223                thread_id: thread_id.to_string(),
224                expected_generation: None,
225                expected_execution: None,
226                expected_guardian_epoch: None,
227            })
228            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
229        )
230        .await?;
231        if expected_execution.is_some()
232            && !hello
233                .capabilities
234                .iter()
235                .any(|capability| capability == GENERATION_BOUND_ACTIONS_CAPABILITY)
236        {
237            return Ok(false);
238        }
239        if let Some(expected) = expected_execution.as_ref() {
240            if !expected.guardian_epoch.is_empty() && expected.guardian_epoch != hello.daemon_epoch
241            {
242                return Ok(false);
243            }
244        }
245        let expected_generation = expected_execution.as_ref().map(|token| token.generation);
246        DaemonShellRuntime::request(
247            &mut stream,
248            rand::random::<u64>(),
249            "session.interrupt",
250            serde_json::to_value(SessionParams {
251                thread_id: thread_id.to_string(),
252                expected_generation,
253                expected_execution: expected_execution.filter(|token| {
254                    !token.guardian_epoch.is_empty() && !token.session_epoch.is_empty()
255                }),
256                expected_guardian_epoch: Some(hello.daemon_epoch),
257            })
258            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
259        )
260        .await
261    }
262
263    async fn cancel_pending_action(&self, thread_id: &str, cancellation_key: &str) -> Result<bool> {
264        // Like interruption, prelaunch cancellation must not share the action
265        // channel that may itself be queued behind another foreground command.
266        let (mut stream, _) = self.connected_with_hello().await?;
267        let hello: HelloResult = DaemonShellRuntime::request(
268            &mut stream,
269            rand::random::<u64>(),
270            "session.negotiate",
271            serde_json::to_value(SessionParams {
272                thread_id: thread_id.to_string(),
273                expected_generation: None,
274                expected_execution: None,
275                expected_guardian_epoch: None,
276            })
277            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
278        )
279        .await?;
280        if !hello
281            .capabilities
282            .iter()
283            .any(|capability| capability == CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY)
284        {
285            return Ok(false);
286        }
287        DaemonShellRuntime::request(
288            &mut stream,
289            rand::random::<u64>(),
290            "shell.cancel_action",
291            serde_json::to_value(CancelActionParams {
292                thread_id: thread_id.to_string(),
293                cancellation_key: cancellation_key.to_string(),
294                expected_guardian_epoch: Some(hello.daemon_epoch),
295            })
296            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
297        )
298        .await
299    }
300}
301
302impl DaemonShellRuntime {
303    pub fn new(socket_path: impl AsRef<Path>) -> Self {
304        Self::with_consumer_id(socket_path, format!("adapter-{:016x}", rand::random::<u64>()))
305    }
306
307    pub fn with_consumer_id(socket_path: impl AsRef<Path>, consumer_id: impl Into<String>) -> Self {
308        Self {
309            socket_path: socket_path.as_ref().to_path_buf(),
310            consumer_id: consumer_id.into(),
311            negotiations: Arc::new(Mutex::new(NegotiationCache::default())),
312        }
313    }
314
315    pub fn socket_path(&self) -> &Path {
316        &self.socket_path
317    }
318
319    async fn negotiated_session(&self, thread_id: &str) -> Result<Arc<Mutex<NegotiatedSession>>> {
320        {
321            let now = Instant::now();
322            let mut cache = self.negotiations.lock().await;
323            prune_negotiations(&mut cache, now);
324            if let Some(entry) = cache.sessions.get_mut(thread_id) {
325                entry.last_seen = now;
326                return Ok(Arc::clone(&entry.session));
327            }
328        }
329
330        let negotiation_gate = self.negotiation_gate(thread_id).await?;
331        let _single_flight = negotiation_gate.lock().await;
332        {
333            let now = Instant::now();
334            let mut cache = self.negotiations.lock().await;
335            prune_negotiations(&mut cache, now);
336            if let Some(entry) = cache.sessions.get_mut(thread_id) {
337                entry.last_seen = now;
338                return Ok(Arc::clone(&entry.session));
339            }
340        }
341
342        // Do not hold the global cache mutex across socket I/O. Per-session
343        // channels serialize their own requests after publication.
344        let client = DaemonClient::new(&self.socket_path);
345        let (mut stream, control) = client.connected_with_hello().await?;
346        let hello: HelloResult = Self::request(
347            &mut stream,
348            rand::random::<u64>(),
349            "session.negotiate",
350            serde_json::to_value(SessionParams {
351                thread_id: thread_id.to_string(),
352                expected_generation: None,
353                expected_execution: None,
354                expected_guardian_epoch: None,
355            })
356            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
357        )
358        .await?;
359        if hello.protocol_major != PROTOCOL_MAJOR {
360            return Err(WinxError::ConfigurationError(format!(
361                "guardian protocol major {} is incompatible with adapter {}",
362                hello.protocol_major, PROTOCOL_MAJOR
363            )));
364        }
365        let session = Arc::new(Mutex::new(NegotiatedSession {
366            control_epoch: control.daemon_epoch,
367            guardian: hello,
368            stream,
369        }));
370        let now = Instant::now();
371        let mut cache = self.negotiations.lock().await;
372        prune_negotiations(&mut cache, now);
373        if let Some(existing) = cache.sessions.get_mut(thread_id) {
374            existing.last_seen = now;
375            return Ok(Arc::clone(&existing.session));
376        }
377        cache_negotiation(&mut cache, thread_id, Arc::clone(&session), now)?;
378        Ok(session)
379    }
380
381    async fn local_revision(&self, thread_id: &str) -> Result<Arc<AtomicU64>> {
382        let now = Instant::now();
383        let mut cache = self.negotiations.lock().await;
384        prune_negotiations(&mut cache, now);
385        if let Some(entry) = cache.local_revisions.get_mut(thread_id) {
386            entry.last_seen = now;
387            return Ok(Arc::clone(&entry.revision));
388        }
389        if cache.local_revisions.len() >= MAX_NEGOTIATED_SESSIONS {
390            let oldest = cache
391                .local_revisions
392                .iter()
393                .filter(|(_, entry)| Arc::strong_count(&entry.revision) == 1)
394                .min_by_key(|(_, entry)| entry.last_seen)
395                .map(|(thread_id, _)| thread_id.clone());
396            if let Some(oldest) = oldest {
397                cache.local_revisions.remove(&oldest);
398            }
399        }
400        if cache.local_revisions.len() >= MAX_NEGOTIATED_SESSIONS {
401            return Err(WinxError::ResourceAllocationError {
402                message: format!(
403                    "all {MAX_NEGOTIATED_SESSIONS} daemon local session revisions are active"
404                ),
405            });
406        }
407        let revision = Arc::new(AtomicU64::new(0));
408        cache.local_revisions.insert(
409            thread_id.to_string(),
410            LocalRevision { revision: Arc::clone(&revision), last_seen: now },
411        );
412        Ok(revision)
413    }
414
415    async fn local_revision_is_current(
416        &self,
417        thread_id: &str,
418        expected: &Arc<AtomicU64>,
419        value: u64,
420    ) -> bool {
421        let mut cache = self.negotiations.lock().await;
422        let Some(current) = cache.local_revisions.get_mut(thread_id) else { return false };
423        current.last_seen = Instant::now();
424        Arc::ptr_eq(&current.revision, expected) && current.revision.load(Ordering::SeqCst) == value
425    }
426
427    async fn apply_snapshot_if_current(
428        &self,
429        bash_state: &Arc<Mutex<Option<BashState>>>,
430        thread_id: &str,
431        expected_revision: &Arc<AtomicU64>,
432        revision_value: u64,
433        snapshot: &crate::state::persistence::BashStateSnapshot,
434    ) -> bool {
435        let mut state = bash_state.lock().await;
436        let Some(state) = state.as_mut() else { return false };
437        if !self.local_revision_is_current(thread_id, expected_revision, revision_value).await {
438            return false;
439        }
440        state.apply_snapshot(snapshot);
441        true
442    }
443
444    async fn negotiation_gate(&self, thread_id: &str) -> Result<Arc<Mutex<()>>> {
445        let now = Instant::now();
446        let mut cache = self.negotiations.lock().await;
447        prune_negotiations(&mut cache, now);
448        if let Some(entry) = cache.gates.get_mut(thread_id) {
449            entry.last_seen = now;
450            return Ok(Arc::clone(&entry.gate));
451        }
452        if cache.gates.len() >= MAX_NEGOTIATED_SESSIONS {
453            let oldest = cache
454                .gates
455                .iter()
456                .filter(|(_, entry)| Arc::strong_count(&entry.gate) == 1)
457                .min_by_key(|(_, entry)| entry.last_seen)
458                .map(|(key, _)| key.clone());
459            if let Some(oldest) = oldest {
460                cache.gates.remove(&oldest);
461            }
462        }
463        if cache.gates.len() >= MAX_NEGOTIATED_SESSIONS {
464            return Err(WinxError::ResourceAllocationError {
465                message: format!(
466                    "all {MAX_NEGOTIATED_SESSIONS} daemon negotiation slots are active"
467                ),
468            });
469        }
470        let gate = Arc::new(Mutex::new(()));
471        cache.gates.insert(
472            thread_id.to_string(),
473            NegotiationGate { gate: Arc::clone(&gate), last_seen: now },
474        );
475        Ok(gate)
476    }
477
478    async fn live_negotiated_session(
479        &self,
480        thread_id: &str,
481    ) -> Result<Arc<Mutex<NegotiatedSession>>> {
482        loop {
483            let session = self.negotiated_session(thread_id).await?;
484            let (alive, control_epoch) = {
485                let session_guard = session.lock().await;
486                let mut probe = [0_u8; 1];
487                match session_guard.stream.try_read(&mut probe) {
488                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
489                        (true, session_guard.control_epoch.clone())
490                    }
491                    Ok(_) | Err(_) => (false, session_guard.control_epoch.clone()),
492                }
493            };
494            if alive {
495                return Ok(session);
496            }
497            tracing::debug!(%control_epoch, %thread_id, "invalidating closed winxd negotiation");
498            self.invalidate_negotiation(thread_id, &session).await;
499        }
500    }
501
502    async fn negotiated_guardian(&self, thread_id: &str) -> Result<HelloResult> {
503        let session = self.live_negotiated_session(thread_id).await?;
504        let hello = session.lock().await.guardian.clone();
505        Ok(hello)
506    }
507
508    async fn invalidate_negotiation(
509        &self,
510        thread_id: &str,
511        expected: &Arc<Mutex<NegotiatedSession>>,
512    ) {
513        let mut cache = self.negotiations.lock().await;
514        if cache
515            .sessions
516            .get(thread_id)
517            .is_some_and(|current| Arc::ptr_eq(&current.session, expected))
518        {
519            cache.sessions.remove(thread_id);
520        }
521    }
522
523    async fn negotiated_request<T: serde::de::DeserializeOwned>(
524        session: &Arc<Mutex<NegotiatedSession>>,
525        id: u64,
526        method: &str,
527        params: serde_json::Value,
528    ) -> Result<T> {
529        let mut session = session.lock().await;
530        Self::request(&mut session.stream, id, method, params).await
531    }
532
533    async fn negotiated_action_request<T: serde::de::DeserializeOwned>(
534        session: &Arc<Mutex<NegotiatedSession>>,
535        id: u64,
536        params: serde_json::Value,
537        options: &ShellActionOptions,
538    ) -> Result<T> {
539        let mut session = session.lock().await;
540        if options.is_launch_cancelled() {
541            return Err(WinxError::CommandExecutionError(
542                "task was cancelled before the shell action launch gate".to_string(),
543            ));
544        }
545        Self::request(&mut session.stream, id, "shell.run_action", params).await
546    }
547
548    async fn request<T: serde::de::DeserializeOwned>(
549        stream: &mut UnixStream,
550        id: u64,
551        method: &str,
552        params: serde_json::Value,
553    ) -> Result<T> {
554        write_json_frame(
555            stream,
556            &RpcRequest { jsonrpc: "2.0".to_string(), id, method: method.to_string(), params },
557        )
558        .await?;
559        let response: RpcResponse = read_json_frame(stream).await?;
560        if response.id != id || response.jsonrpc != "2.0" {
561            return Err(WinxError::ParseError("mismatched JSON-RPC response".to_string()));
562        }
563        if let Some(error) = response.error {
564            return Err(WinxError::CommandExecutionError(format!(
565                "winxd JSON-RPC error {}: {}",
566                error.code, error.message
567            )));
568        }
569        serde_json::from_value(
570            response.result.ok_or_else(|| {
571                WinxError::ParseError("JSON-RPC response omitted result".to_string())
572            })?,
573        )
574        .map_err(|error| WinxError::DeserializationError(error.to_string()))
575    }
576
577    async fn run_remote_detailed(
578        &self,
579        bash_state: &std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
580        command: BashCommand,
581        requested_options: ShellActionOptions,
582    ) -> Result<BashCommandRuntimeResult> {
583        let thread_id = command.thread_id.clone();
584        // Capture state and its adapter-local configuration revision under the
585        // BashState lock. configure_remote uses the same lock order, so a
586        // concurrent Initialize/Reset/Mode/Workspace transition cannot land
587        // between these two values.
588        let (snapshot, local_revision, captured_revision) = {
589            let state = bash_state.lock().await;
590            let state = state.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
591            let revision = self.local_revision(&thread_id).await?;
592            let value = revision.load(Ordering::SeqCst);
593            (state.snapshot(), revision, value)
594        };
595        let mut session = self.live_negotiated_session(&thread_id).await?;
596        let hello = session.lock().await.guardian.clone();
597        let (options, mut generation_bound_actions) =
598            action_options_for_guardian(&hello, requested_options.clone())?;
599        let request_key = format!("{:016x}", rand::random::<u64>());
600        let id = rand::random::<u64>();
601        let encode_params = |options| {
602            serde_json::to_value(RunActionParams {
603                snapshot: snapshot.clone(),
604                command: command.clone(),
605                request_key: request_key.clone(),
606                consumer_id: self.consumer_id.clone(),
607                options,
608            })
609            .map_err(|error| WinxError::SerializationError(error.to_string()))
610        };
611        let params = encode_params(options.clone())?;
612        let result: RunActionResult = match Self::negotiated_action_request(
613            &session, id, params, &options,
614        )
615        .await
616        {
617            Ok(result) => result,
618            Err(first_error) => {
619                // A closed negotiated channel means the control epoch changed.
620                // Re-negotiate before retrying, then enforce the new guardian's
621                // capabilities. The stable request key makes a lost response
622                // safe without executing the command twice.
623                self.invalidate_negotiation(&thread_id, &session).await;
624                session = self.live_negotiated_session(&thread_id).await?;
625                let retry_hello = session.lock().await.guardian.clone();
626                if retry_hello.daemon_epoch != hello.daemon_epoch {
627                    return Err(WinxError::CommandExecutionError(format!(
628                        "{first_error}; guardian epoch changed from {} to {}, so Winx will not retry an ambiguously delivered shell action",
629                        hello.daemon_epoch, retry_hello.daemon_epoch
630                    )));
631                }
632                let (retry_options, retry_generation_bound) =
633                    action_options_for_guardian(&retry_hello, requested_options.clone())?;
634                generation_bound_actions = retry_generation_bound;
635                Self::negotiated_action_request(
636                    &session,
637                    id,
638                    encode_params(retry_options)?,
639                    &requested_options,
640                )
641                .await
642                .map_err(|retry_error| {
643                    WinxError::CommandExecutionError(format!(
644                        "{first_error}; retry after guardian negotiation failed: {retry_error}"
645                    ))
646                })?
647            }
648        };
649
650        self.apply_snapshot_if_current(
651            bash_state,
652            &thread_id,
653            &local_revision,
654            captured_revision,
655            &result.snapshot,
656        )
657        .await;
658        match (result.output, result.compact_output, result.state, result.error) {
659            (output, compact_output, Some(state), None)
660                if output.is_some() || compact_output.is_some() => {
661                let (output, compact_output) = match (output, compact_output) {
662                    (Some(output), compact) => (output, compact),
663                    (None, Some(compact)) => (String::new(), Some(compact)),
664                    (None, None) => unreachable!(),
665                };
666                Ok(BashCommandRuntimeResult {
667                    result: BashCommandResult { output, state },
668                    compact_output,
669                    command_generation: result.command_generation,
670                    execution_token: result.execution_token,
671                    generation_bound_actions,
672                    output_truncated: result.output_truncated,
673                })
674            }
675            (_, _, _, Some(error)) => Err(from_wire_error(error)),
676            (Some(_), _, None, None) => Err(WinxError::ConfigurationError(
677                "the session guardian predates typed BashCommand results; terminate this durable session, then initialize it again so the current guardian binary is created. Winx will not reconstruct orchestration state from terminal text."
678                    .to_string(),
679            )),
680            _ => Err(WinxError::ParseError(
681                "winxd action response had neither a typed result nor an error".to_string(),
682            )),
683        }
684    }
685
686    async fn configure_remote(
687        &self,
688        bash_state: &mut BashState,
689        transition: ShellSessionTransition,
690    ) -> Result<ShellSessionConfiguration> {
691        let transition = match transition {
692            ShellSessionTransition::FirstCall => ConfigureSessionTransition::FirstCall,
693            ShellSessionTransition::ModeChange => ConfigureSessionTransition::ModeChange,
694            ShellSessionTransition::Reset => ConfigureSessionTransition::Reset,
695            ShellSessionTransition::WorkspaceChange => ConfigureSessionTransition::WorkspaceChange,
696        };
697        let thread_id = bash_state.current_thread_id.clone();
698        // The caller owns BashState for this entire future. Bump before any
699        // remote I/O so every older action response is stale even when this
700        // transition ultimately returns an error.
701        self.local_revision(&thread_id).await?.fetch_add(1, Ordering::SeqCst);
702        let session = self.live_negotiated_session(&thread_id).await?;
703        let result: ConfigureSessionResult = Self::negotiated_request(
704            &session,
705            rand::random::<u64>(),
706            "session.configure",
707            serde_json::to_value(ConfigureSessionParams {
708                snapshot: bash_state.snapshot(),
709                transition,
710            })
711            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
712        )
713        .await?;
714        if let Some(snapshot) = result.snapshot {
715            bash_state.apply_snapshot(&snapshot);
716        }
717        Ok(ShellSessionConfiguration {
718            attach_hint: result.attach_hint,
719            attached_existing: result.attached_existing,
720        })
721    }
722}
723
724fn prune_negotiations(cache: &mut NegotiationCache, now: Instant) {
725    cache.sessions.retain(|_, entry| {
726        now.duration_since(entry.last_seen) <= NEGOTIATION_TTL
727            || Arc::strong_count(&entry.session) > 1
728    });
729    cache.gates.retain(|_, entry| {
730        now.duration_since(entry.last_seen) <= NEGOTIATION_TTL || Arc::strong_count(&entry.gate) > 1
731    });
732    cache.local_revisions.retain(|_, entry| {
733        now.duration_since(entry.last_seen) <= NEGOTIATION_TTL
734            || Arc::strong_count(&entry.revision) > 1
735    });
736}
737
738fn cache_negotiation(
739    cache: &mut NegotiationCache,
740    thread_id: &str,
741    session: Arc<Mutex<NegotiatedSession>>,
742    now: Instant,
743) -> Result<()> {
744    if cache.sessions.len() >= MAX_NEGOTIATED_SESSIONS {
745        if let Some(oldest) = cache
746            .sessions
747            .iter()
748            .filter(|(_, entry)| Arc::strong_count(&entry.session) == 1)
749            .min_by_key(|(_, entry)| entry.last_seen)
750            .map(|(thread_id, _)| thread_id.clone())
751        {
752            cache.sessions.remove(&oldest);
753        }
754    }
755    if cache.sessions.len() >= MAX_NEGOTIATED_SESSIONS {
756        return Err(WinxError::ResourceAllocationError {
757            message: format!("all {MAX_NEGOTIATED_SESSIONS} daemon sessions are active"),
758        });
759    }
760    cache.sessions.insert(thread_id.to_string(), CachedNegotiation { session, last_seen: now });
761    Ok(())
762}
763
764impl ShellRuntime for DaemonShellRuntime {
765    fn configure_session<'a>(
766        &'a self,
767        bash_state: &'a mut BashState,
768        transition: ShellSessionTransition,
769    ) -> ShellRuntimeConfigureFuture<'a> {
770        Box::pin(self.configure_remote(bash_state, transition))
771    }
772
773    fn run_action<'a>(
774        &'a self,
775        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
776        command: BashCommand,
777    ) -> ShellRuntimeFuture<'a> {
778        Box::pin(async move {
779            Ok(self
780                .run_remote_detailed(bash_state, command, ShellActionOptions::default())
781                .await?
782                .result)
783        })
784    }
785
786    fn run_action_detailed<'a>(
787        &'a self,
788        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
789        command: BashCommand,
790        options: ShellActionOptions,
791    ) -> ShellRuntimeDetailedFuture<'a> {
792        Box::pin(self.run_remote_detailed(bash_state, command, options))
793    }
794
795    fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
796        // The stable control daemon cannot answer this without a session. Keep
797        // the legacy context-free probe conservative.
798        Box::pin(async { Ok(false) })
799    }
800
801    fn supports_generation_bound_actions_for<'a>(
802        &'a self,
803        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
804    ) -> ShellRuntimeBoolFuture<'a> {
805        Box::pin(async move {
806            let thread_id = bash_state
807                .lock()
808                .await
809                .as_ref()
810                .ok_or(WinxError::BashStateNotInitialized)?
811                .current_thread_id
812                .clone();
813            let hello = self.negotiated_guardian(&thread_id).await?;
814            Ok([GENERATION_BOUND_ACTIONS_CAPABILITY, CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY]
815                .iter()
816                .all(|required| hello.capabilities.iter().any(|capability| capability == required)))
817        })
818    }
819
820    fn interrupt<'a>(
821        &'a self,
822        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
823    ) -> ShellRuntimeUnitFuture<'a> {
824        Box::pin(async move {
825            let thread_id = bash_state
826                .lock()
827                .await
828                .as_ref()
829                .ok_or(WinxError::BashStateNotInitialized)?
830                .current_thread_id
831                .clone();
832            DaemonClient::new(&self.socket_path).interrupt_session(&thread_id).await
833        })
834    }
835
836    fn interrupt_generation<'a>(
837        &'a self,
838        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
839        expected_generation: Option<u64>,
840    ) -> ShellRuntimeBoolFuture<'a> {
841        Box::pin(async move {
842            let thread_id = bash_state
843                .lock()
844                .await
845                .as_ref()
846                .ok_or(WinxError::BashStateNotInitialized)?
847                .current_thread_id
848                .clone();
849            DaemonClient::new(&self.socket_path)
850                .interrupt_session_generation(&thread_id, expected_generation)
851                .await
852        })
853    }
854
855    fn interrupt_execution<'a>(
856        &'a self,
857        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
858        expected: Option<ShellExecutionToken>,
859    ) -> ShellRuntimeBoolFuture<'a> {
860        Box::pin(async move {
861            let thread_id = bash_state
862                .lock()
863                .await
864                .as_ref()
865                .ok_or(WinxError::BashStateNotInitialized)?
866                .current_thread_id
867                .clone();
868            DaemonClient::new(&self.socket_path)
869                .interrupt_session_execution(&thread_id, expected)
870                .await
871        })
872    }
873
874    fn cancel_pending_action<'a>(
875        &'a self,
876        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
877        cancellation_key: &'a str,
878    ) -> ShellRuntimeBoolFuture<'a> {
879        Box::pin(async move {
880            let thread_id = bash_state
881                .lock()
882                .await
883                .as_ref()
884                .ok_or(WinxError::BashStateNotInitialized)?
885                .current_thread_id
886                .clone();
887            DaemonClient::new(&self.socket_path)
888                .cancel_pending_action(&thread_id, cancellation_key)
889                .await
890        })
891    }
892
893    fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
894        Box::pin(async move {
895            let _ = DaemonClient::new(&self.socket_path).kill_session(thread_id).await?;
896            self.negotiations.lock().await.sessions.remove(thread_id);
897            Ok(())
898        })
899    }
900}
901
902fn action_options_for_guardian(
903    hello: &HelloResult,
904    mut options: ShellActionOptions,
905) -> Result<(ShellActionOptions, bool)> {
906    let has = |capability: &str| hello.capabilities.iter().any(|candidate| candidate == capability);
907    if !has(TYPED_ACTION_RESULT_CAPABILITY) {
908        return Err(WinxError::ConfigurationError(format!(
909            "the running guardian does not advertise {TYPED_ACTION_RESULT_CAPABILITY}; terminate this durable session and initialize it again before executing BashCommand"
910        )));
911    }
912    let generation_bound_actions = has(GENERATION_BOUND_ACTIONS_CAPABILITY);
913    if (options.expected_generation.is_some() || options.require_generation_binding)
914        && !generation_bound_actions
915    {
916        return Err(WinxError::InvalidInput(
917            "the running guardian does not support generation-bound shell actions".to_string(),
918        ));
919    }
920    if let Some(expected) = options.expected_execution.as_ref() {
921        if expected.guardian_epoch != hello.daemon_epoch {
922            return Err(WinxError::InvalidInput(
923                "execution token belongs to a different guardian epoch".to_string(),
924            ));
925        }
926        options.expected_generation = Some(expected.generation);
927    }
928    if options.expected_generation.is_some() || options.require_generation_binding {
929        options.expected_guardian_epoch = Some(hello.daemon_epoch.clone());
930    }
931    if options.cancellation_key.is_some() && !has(CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY) {
932        return Err(WinxError::InvalidInput(
933            "the running guardian does not support cancellable action reservations".to_string(),
934        ));
935    }
936    if !has(COMPACT_ACTION_OUTPUT_CAPABILITY) {
937        options.compact_output = false;
938    }
939    Ok((options, generation_bound_actions))
940}
941
942fn from_wire_error(error: WireShellError) -> WinxError {
943    match error {
944        WireShellError::BashStateNotInitialized => WinxError::BashStateNotInitialized,
945        WireShellError::ShellInitialization(message) => {
946            WinxError::ShellInitializationError(message)
947        }
948        WireShellError::CommandExecution(message) | WireShellError::Other(message) => {
949            WinxError::CommandExecutionError(message)
950        }
951        WireShellError::NoActiveCommand(message) => WinxError::NoActiveCommand(message),
952        WireShellError::BackgroundSessionNotFound(message) => {
953            WinxError::BackgroundSessionNotFound(message)
954        }
955        WireShellError::EmptyInteractiveInput(action) => {
956            WinxError::EmptyInteractiveInput { action }
957        }
958        WireShellError::InteractiveTargetNotRunning(message) => {
959            WinxError::InteractiveTargetNotRunning(message)
960        }
961        WireShellError::CommandAlreadyRunning { current_command, duration_seconds } => {
962            WinxError::CommandAlreadyRunning { current_command, duration_seconds }
963        }
964        WireShellError::CommandNotAllowed(message) => WinxError::CommandNotAllowed(message),
965        WireShellError::ThreadIdMismatch(message) => WinxError::ThreadIdMismatch(message),
966        WireShellError::InvalidInput(message) => WinxError::InvalidInput(message),
967    }
968}
969
970#[cfg(test)]
971mod tests {
972    #![allow(clippy::expect_used)]
973
974    use std::sync::atomic::{AtomicUsize, Ordering};
975
976    use super::*;
977    use crate::daemon::protocol::{RpcResponse, TYPED_ACTION_RESULT_CAPABILITY};
978
979    #[tokio::test]
980    #[allow(clippy::too_many_lines)] // full control/guardian wire exchange is intentional
981    async fn generation_capability_comes_from_effective_guardian_and_is_cached() {
982        let temp = tempfile::tempdir().expect("temporary directory");
983        let socket = temp.path().join("control.sock");
984        let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
985        let connections = Arc::new(AtomicUsize::new(0));
986        let observed = Arc::clone(&connections);
987        let server = tokio::spawn(async move {
988            let (mut stream, _) = listener.accept().await.expect("accept adapter");
989            observed.fetch_add(1, Ordering::SeqCst);
990            let hello: RpcRequest = read_json_frame(&mut stream).await.expect("control hello");
991            assert_eq!(hello.method, "winx.hello");
992            write_json_frame(
993                &mut stream,
994                &RpcResponse {
995                    jsonrpc: "2.0".to_string(),
996                    id: hello.id,
997                    result: Some(
998                        serde_json::to_value(HelloResult {
999                            protocol_major: PROTOCOL_MAJOR,
1000                            protocol_minor: 5,
1001                            capabilities: vec![GENERATION_BOUND_ACTIONS_CAPABILITY.to_string()],
1002                            max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1003                            daemon_epoch: "control-15".to_string(),
1004                            daemon_pid: 15,
1005                        })
1006                        .expect("control hello value"),
1007                    ),
1008                    error: None,
1009                },
1010            )
1011            .await
1012            .expect("write control hello");
1013
1014            let negotiation: RpcRequest =
1015                read_json_frame(&mut stream).await.expect("session negotiation");
1016            assert_eq!(negotiation.method, "session.negotiate");
1017            write_json_frame(
1018                &mut stream,
1019                &RpcResponse {
1020                    jsonrpc: "2.0".to_string(),
1021                    id: negotiation.id,
1022                    result: Some(
1023                        serde_json::to_value(HelloResult {
1024                            protocol_major: PROTOCOL_MAJOR,
1025                            protocol_minor: 4,
1026                            capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1027                            max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1028                            daemon_epoch: "guardian-14".to_string(),
1029                            daemon_pid: 14,
1030                        })
1031                        .expect("guardian hello value"),
1032                    ),
1033                    error: None,
1034                },
1035            )
1036            .await
1037            .expect("write guardian hello");
1038
1039            let action: RpcRequest =
1040                read_json_frame(&mut stream).await.expect("hot action request");
1041            assert_eq!(action.method, "shell.run_action", "hot path must not send winx.hello");
1042            assert!(
1043                action.params.get("options").is_none(),
1044                "guardian 1.4 must receive default protocol-1.4 options"
1045            );
1046            let params: RunActionParams =
1047                serde_json::from_value(action.params).expect("run action params");
1048            let cwd = params.snapshot.cwd.clone();
1049            write_json_frame(
1050                &mut stream,
1051                &RpcResponse {
1052                    jsonrpc: "2.0".to_string(),
1053                    id: action.id,
1054                    result: Some(
1055                        serde_json::to_value(RunActionResult {
1056                            output: Some("guardian-14-output".to_string()),
1057                            compact_output: None,
1058                            command_generation: None,
1059                            execution_token: None,
1060                            output_truncated: false,
1061                            state: Some(crate::tools::bash_command::BashCommandState {
1062                                process_status:
1063                                    crate::tools::bash_command::BashProcessStatus::Exited,
1064                                background_id: None,
1065                                running_for_seconds: None,
1066                                exit_code: Some(0),
1067                                cwd: cwd.into(),
1068                                turn_state: None,
1069                            }),
1070                            snapshot: params.snapshot,
1071                            error: None,
1072                        })
1073                        .expect("action result value"),
1074                    ),
1075                    error: None,
1076                },
1077            )
1078            .await
1079            .expect("write action result");
1080        });
1081
1082        let runtime = DaemonShellRuntime::new(&socket);
1083        let mut state = BashState::new();
1084        state.current_thread_id = "legacy-guardian".to_string();
1085        let state = Arc::new(Mutex::new(Some(state)));
1086        let (first_probe, concurrent_probe) = tokio::join!(
1087            runtime.supports_generation_bound_actions_for(&state),
1088            runtime.supports_generation_bound_actions_for(&state)
1089        );
1090        assert!(!first_probe.expect("first capability probe"));
1091        assert!(!concurrent_probe.expect("concurrent capability probe"));
1092        assert!(!tokio::time::timeout(
1093            std::time::Duration::from_millis(100),
1094            runtime.supports_generation_bound_actions_for(&state),
1095        )
1096        .await
1097        .expect("cached probe must not reconnect")
1098        .expect("cached capability"));
1099
1100        let outcome = runtime
1101            .run_remote_detailed(
1102                &state,
1103                BashCommand {
1104                    action_json: crate::types::BashCommandAction::Command {
1105                        command: "printf guardian-14-output".to_string(),
1106                        is_background: false,
1107                        allow_multi: false,
1108                    },
1109                    wait_for_seconds: Some(0.0),
1110                    thread_id: "legacy-guardian".to_string(),
1111                },
1112                ShellActionOptions { compact_output: true, ..ShellActionOptions::default() },
1113            )
1114            .await
1115            .expect("guardian 1.4 action");
1116        assert_eq!(outcome.result.output, "guardian-14-output");
1117        assert_eq!(outcome.compact_output, None);
1118        server.await.expect("mock server task");
1119        assert_eq!(connections.load(Ordering::SeqCst), 1);
1120    }
1121
1122    #[tokio::test]
1123    #[allow(clippy::too_many_lines)] // two complete negotiated epochs are intentional
1124    async fn ambiguous_action_is_not_retried_after_guardian_epoch_changes() {
1125        let temp = tempfile::tempdir().expect("temporary directory");
1126        let socket = temp.path().join("control-restart.sock");
1127        let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
1128        let action_count = Arc::new(AtomicUsize::new(0));
1129        let observed = Arc::clone(&action_count);
1130        let server = tokio::spawn(async move {
1131            for (control_epoch, guardian_epoch) in
1132                [("control-one", "guardian-one"), ("control-two", "guardian-two")]
1133            {
1134                let (mut stream, _) = listener.accept().await.expect("accept adapter");
1135                let control_request: RpcRequest =
1136                    read_json_frame(&mut stream).await.expect("control hello");
1137                assert_eq!(control_request.method, "winx.hello");
1138                write_json_frame(
1139                    &mut stream,
1140                    &RpcResponse {
1141                        jsonrpc: "2.0".to_string(),
1142                        id: control_request.id,
1143                        result: Some(
1144                            serde_json::to_value(HelloResult {
1145                                protocol_major: PROTOCOL_MAJOR,
1146                                protocol_minor: 5,
1147                                capabilities: vec![],
1148                                max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1149                                daemon_epoch: control_epoch.to_string(),
1150                                daemon_pid: 15,
1151                            })
1152                            .expect("control hello value"),
1153                        ),
1154                        error: None,
1155                    },
1156                )
1157                .await
1158                .expect("control hello response");
1159                let negotiation: RpcRequest =
1160                    read_json_frame(&mut stream).await.expect("session negotiation");
1161                write_json_frame(
1162                    &mut stream,
1163                    &RpcResponse {
1164                        jsonrpc: "2.0".to_string(),
1165                        id: negotiation.id,
1166                        result: Some(
1167                            serde_json::to_value(HelloResult {
1168                                protocol_major: PROTOCOL_MAJOR,
1169                                protocol_minor: 5,
1170                                capabilities: vec![
1171                                    TYPED_ACTION_RESULT_CAPABILITY.to_string(),
1172                                    GENERATION_BOUND_ACTIONS_CAPABILITY.to_string(),
1173                                ],
1174                                max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1175                                daemon_epoch: guardian_epoch.to_string(),
1176                                daemon_pid: 16,
1177                            })
1178                            .expect("guardian hello value"),
1179                        ),
1180                        error: None,
1181                    },
1182                )
1183                .await
1184                .expect("guardian hello response");
1185
1186                if guardian_epoch == "guardian-one" {
1187                    let action: RpcRequest =
1188                        read_json_frame(&mut stream).await.expect("first action");
1189                    assert_eq!(action.method, "shell.run_action");
1190                    observed.fetch_add(1, Ordering::SeqCst);
1191                    // Dropping without a response makes delivery ambiguous.
1192                } else {
1193                    let repeated = tokio::time::timeout(
1194                        std::time::Duration::from_millis(100),
1195                        read_json_frame::<_, RpcRequest>(&mut stream),
1196                    )
1197                    .await;
1198                    assert!(repeated.is_err(), "action was resent to a different guardian epoch");
1199                }
1200            }
1201        });
1202
1203        let runtime = DaemonShellRuntime::new(&socket);
1204        let mut state = BashState::new();
1205        state.current_thread_id = "ambiguous-action".to_string();
1206        let state = Arc::new(Mutex::new(Some(state)));
1207        let result = runtime
1208            .run_remote_detailed(
1209                &state,
1210                BashCommand {
1211                    action_json: crate::types::BashCommandAction::Command {
1212                        command: "printf must-run-once".to_string(),
1213                        is_background: false,
1214                        allow_multi: false,
1215                    },
1216                    wait_for_seconds: Some(0.0),
1217                    thread_id: "ambiguous-action".to_string(),
1218                },
1219                ShellActionOptions::default(),
1220            )
1221            .await;
1222        assert!(
1223            matches!(result, Err(WinxError::CommandExecutionError(ref message)) if message.contains("will not retry")),
1224            "{result:?}"
1225        );
1226        server.await.expect("mock server task");
1227        assert_eq!(action_count.load(Ordering::SeqCst), 1);
1228    }
1229
1230    #[tokio::test]
1231    async fn adapter_negotiation_cache_is_bounded_under_session_churn() {
1232        let mut cache = NegotiationCache::default();
1233        for index in 0..(MAX_NEGOTIATED_SESSIONS + 50) {
1234            let (stream, peer) = UnixStream::pair().expect("unix stream pair");
1235            drop(peer);
1236            let session = Arc::new(Mutex::new(NegotiatedSession {
1237                control_epoch: "control".to_string(),
1238                guardian: HelloResult {
1239                    protocol_major: PROTOCOL_MAJOR,
1240                    protocol_minor: 5,
1241                    capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1242                    max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1243                    daemon_epoch: format!("guardian-{index}"),
1244                    daemon_pid: u32::try_from(index).unwrap_or(u32::MAX),
1245                },
1246                stream,
1247            }));
1248            cache_negotiation(&mut cache, &format!("thread-{index}"), session, Instant::now())
1249                .expect("inactive cache entry should be evictable");
1250        }
1251        assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1252    }
1253
1254    #[tokio::test]
1255    async fn adapter_negotiation_cache_never_evicts_active_sessions() {
1256        let mut cache = NegotiationCache::default();
1257        let mut active = Vec::new();
1258        for index in 0..MAX_NEGOTIATED_SESSIONS {
1259            let (stream, peer) = UnixStream::pair().expect("unix stream pair");
1260            drop(peer);
1261            let session = Arc::new(Mutex::new(NegotiatedSession {
1262                control_epoch: "control".to_string(),
1263                guardian: HelloResult {
1264                    protocol_major: PROTOCOL_MAJOR,
1265                    protocol_minor: 5,
1266                    capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1267                    max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1268                    daemon_epoch: format!("active-{index}"),
1269                    daemon_pid: u32::try_from(index).unwrap_or(u32::MAX),
1270                },
1271                stream,
1272            }));
1273            cache_negotiation(
1274                &mut cache,
1275                &format!("active-{index}"),
1276                Arc::clone(&session),
1277                Instant::now(),
1278            )
1279            .expect("active slot");
1280            active.push(session);
1281        }
1282        let (stream, peer) = UnixStream::pair().expect("overflow pair");
1283        drop(peer);
1284        let overflow = Arc::new(Mutex::new(NegotiatedSession {
1285            control_epoch: "control".to_string(),
1286            guardian: HelloResult {
1287                protocol_major: PROTOCOL_MAJOR,
1288                protocol_minor: 5,
1289                capabilities: vec![],
1290                max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1291                daemon_epoch: "overflow".to_string(),
1292                daemon_pid: 999,
1293            },
1294            stream,
1295        }));
1296        let error =
1297            cache_negotiation(&mut cache, "overflow", Arc::clone(&overflow), Instant::now())
1298                .expect_err("all active sessions must apply backpressure");
1299        assert!(matches!(error, WinxError::ResourceAllocationError { .. }));
1300        assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1301
1302        drop(active.pop());
1303        cache_negotiation(&mut cache, "overflow", overflow, Instant::now())
1304            .expect("an inactive session can be evicted");
1305        assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1306    }
1307
1308    #[tokio::test]
1309    async fn stale_daemon_action_snapshot_cannot_overwrite_local_reconfiguration() {
1310        let runtime = DaemonShellRuntime::new("unused.sock");
1311        let mut initial = BashState::new();
1312        initial.current_thread_id = "local-revision".to_string();
1313        initial.cwd = PathBuf::from("/old-action-cwd");
1314        let stale_snapshot = initial.snapshot();
1315        let state = Arc::new(Mutex::new(Some(initial)));
1316
1317        let revision = runtime.local_revision("local-revision").await.expect("revision");
1318        let captured = revision.load(Ordering::SeqCst);
1319        let mut configure_guard = state.lock().await;
1320        let apply_runtime = runtime.clone();
1321        let apply_state = Arc::clone(&state);
1322        let apply_revision = Arc::clone(&revision);
1323        let stale_apply = tokio::spawn(async move {
1324            apply_runtime
1325                .apply_snapshot_if_current(
1326                    &apply_state,
1327                    "local-revision",
1328                    &apply_revision,
1329                    captured,
1330                    &stale_snapshot,
1331                )
1332                .await
1333        });
1334        tokio::task::yield_now().await;
1335
1336        // configure_remote performs this bump while its caller holds the same
1337        // BashState lock. The stale action writeback is already concurrent and
1338        // waiting for that lock, so it must revalidate after configuration.
1339        revision.fetch_add(1, Ordering::SeqCst);
1340        configure_guard.as_mut().expect("state").cwd = PathBuf::from("/new-config-cwd");
1341        drop(configure_guard);
1342
1343        assert!(!stale_apply.await.expect("stale writeback task"));
1344        assert_eq!(
1345            state.lock().await.as_ref().expect("state").cwd,
1346            PathBuf::from("/new-config-cwd")
1347        );
1348    }
1349}