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        runtime_result_from_wire(result, generation_bound_actions)
659    }
660
661    async fn configure_remote(
662        &self,
663        bash_state: &mut BashState,
664        transition: ShellSessionTransition,
665    ) -> Result<ShellSessionConfiguration> {
666        let transition = match transition {
667            ShellSessionTransition::FirstCall => ConfigureSessionTransition::FirstCall,
668            ShellSessionTransition::ModeChange => ConfigureSessionTransition::ModeChange,
669            ShellSessionTransition::Reset => ConfigureSessionTransition::Reset,
670            ShellSessionTransition::WorkspaceChange => ConfigureSessionTransition::WorkspaceChange,
671        };
672        let thread_id = bash_state.current_thread_id.clone();
673        // The caller owns BashState for this entire future. Bump before any
674        // remote I/O so every older action response is stale even when this
675        // transition ultimately returns an error.
676        self.local_revision(&thread_id).await?.fetch_add(1, Ordering::SeqCst);
677        let session = self.live_negotiated_session(&thread_id).await?;
678        let result: ConfigureSessionResult = Self::negotiated_request(
679            &session,
680            rand::random::<u64>(),
681            "session.configure",
682            serde_json::to_value(ConfigureSessionParams {
683                snapshot: bash_state.snapshot(),
684                transition,
685            })
686            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
687        )
688        .await?;
689        if let Some(snapshot) = result.snapshot {
690            bash_state.apply_snapshot(&snapshot);
691        }
692        Ok(ShellSessionConfiguration {
693            attach_hint: result.attach_hint,
694            attached_existing: result.attached_existing,
695        })
696    }
697}
698
699fn prune_negotiations(cache: &mut NegotiationCache, now: Instant) {
700    cache.sessions.retain(|_, entry| {
701        now.duration_since(entry.last_seen) <= NEGOTIATION_TTL
702            || Arc::strong_count(&entry.session) > 1
703    });
704    cache.gates.retain(|_, entry| {
705        now.duration_since(entry.last_seen) <= NEGOTIATION_TTL || Arc::strong_count(&entry.gate) > 1
706    });
707    cache.local_revisions.retain(|_, entry| {
708        now.duration_since(entry.last_seen) <= NEGOTIATION_TTL
709            || Arc::strong_count(&entry.revision) > 1
710    });
711}
712
713fn cache_negotiation(
714    cache: &mut NegotiationCache,
715    thread_id: &str,
716    session: Arc<Mutex<NegotiatedSession>>,
717    now: Instant,
718) -> Result<()> {
719    if cache.sessions.len() >= MAX_NEGOTIATED_SESSIONS {
720        if let Some(oldest) = cache
721            .sessions
722            .iter()
723            .filter(|(_, entry)| Arc::strong_count(&entry.session) == 1)
724            .min_by_key(|(_, entry)| entry.last_seen)
725            .map(|(thread_id, _)| thread_id.clone())
726        {
727            cache.sessions.remove(&oldest);
728        }
729    }
730    if cache.sessions.len() >= MAX_NEGOTIATED_SESSIONS {
731        return Err(WinxError::ResourceAllocationError {
732            message: format!("all {MAX_NEGOTIATED_SESSIONS} daemon sessions are active"),
733        });
734    }
735    cache.sessions.insert(thread_id.to_string(), CachedNegotiation { session, last_seen: now });
736    Ok(())
737}
738
739impl ShellRuntime for DaemonShellRuntime {
740    fn configure_session<'a>(
741        &'a self,
742        bash_state: &'a mut BashState,
743        transition: ShellSessionTransition,
744    ) -> ShellRuntimeConfigureFuture<'a> {
745        Box::pin(self.configure_remote(bash_state, transition))
746    }
747
748    fn run_action<'a>(
749        &'a self,
750        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
751        command: BashCommand,
752    ) -> ShellRuntimeFuture<'a> {
753        Box::pin(async move {
754            Ok(self
755                .run_remote_detailed(bash_state, command, ShellActionOptions::default())
756                .await?
757                .result)
758        })
759    }
760
761    fn run_action_detailed<'a>(
762        &'a self,
763        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
764        command: BashCommand,
765        options: ShellActionOptions,
766    ) -> ShellRuntimeDetailedFuture<'a> {
767        Box::pin(self.run_remote_detailed(bash_state, command, options))
768    }
769
770    fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
771        // The stable control daemon cannot answer this without a session. Keep
772        // the legacy context-free probe conservative.
773        Box::pin(async { Ok(false) })
774    }
775
776    fn supports_generation_bound_actions_for<'a>(
777        &'a self,
778        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
779    ) -> ShellRuntimeBoolFuture<'a> {
780        Box::pin(async move {
781            let thread_id = bash_state
782                .lock()
783                .await
784                .as_ref()
785                .ok_or(WinxError::BashStateNotInitialized)?
786                .current_thread_id
787                .clone();
788            let hello = self.negotiated_guardian(&thread_id).await?;
789            Ok([GENERATION_BOUND_ACTIONS_CAPABILITY, CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY]
790                .iter()
791                .all(|required| hello.capabilities.iter().any(|capability| capability == required)))
792        })
793    }
794
795    fn interrupt<'a>(
796        &'a self,
797        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
798    ) -> ShellRuntimeUnitFuture<'a> {
799        Box::pin(async move {
800            let thread_id = bash_state
801                .lock()
802                .await
803                .as_ref()
804                .ok_or(WinxError::BashStateNotInitialized)?
805                .current_thread_id
806                .clone();
807            DaemonClient::new(&self.socket_path).interrupt_session(&thread_id).await
808        })
809    }
810
811    fn interrupt_generation<'a>(
812        &'a self,
813        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
814        expected_generation: Option<u64>,
815    ) -> ShellRuntimeBoolFuture<'a> {
816        Box::pin(async move {
817            let thread_id = bash_state
818                .lock()
819                .await
820                .as_ref()
821                .ok_or(WinxError::BashStateNotInitialized)?
822                .current_thread_id
823                .clone();
824            DaemonClient::new(&self.socket_path)
825                .interrupt_session_generation(&thread_id, expected_generation)
826                .await
827        })
828    }
829
830    fn interrupt_execution<'a>(
831        &'a self,
832        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
833        expected: Option<ShellExecutionToken>,
834    ) -> ShellRuntimeBoolFuture<'a> {
835        Box::pin(async move {
836            let thread_id = bash_state
837                .lock()
838                .await
839                .as_ref()
840                .ok_or(WinxError::BashStateNotInitialized)?
841                .current_thread_id
842                .clone();
843            DaemonClient::new(&self.socket_path)
844                .interrupt_session_execution(&thread_id, expected)
845                .await
846        })
847    }
848
849    fn cancel_pending_action<'a>(
850        &'a self,
851        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
852        cancellation_key: &'a str,
853    ) -> ShellRuntimeBoolFuture<'a> {
854        Box::pin(async move {
855            let thread_id = bash_state
856                .lock()
857                .await
858                .as_ref()
859                .ok_or(WinxError::BashStateNotInitialized)?
860                .current_thread_id
861                .clone();
862            DaemonClient::new(&self.socket_path)
863                .cancel_pending_action(&thread_id, cancellation_key)
864                .await
865        })
866    }
867
868    fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
869        Box::pin(async move {
870            let _ = DaemonClient::new(&self.socket_path).kill_session(thread_id).await?;
871            self.negotiations.lock().await.sessions.remove(thread_id);
872            Ok(())
873        })
874    }
875}
876
877fn action_options_for_guardian(
878    hello: &HelloResult,
879    mut options: ShellActionOptions,
880) -> Result<(ShellActionOptions, bool)> {
881    let has = |capability: &str| hello.capabilities.iter().any(|candidate| candidate == capability);
882    if !has(TYPED_ACTION_RESULT_CAPABILITY) {
883        return Err(WinxError::ConfigurationError(format!(
884            "the running guardian does not advertise {TYPED_ACTION_RESULT_CAPABILITY}; terminate this durable session and initialize it again before executing BashCommand"
885        )));
886    }
887    let generation_bound_actions = has(GENERATION_BOUND_ACTIONS_CAPABILITY);
888    if (options.expected_generation.is_some() || options.require_generation_binding)
889        && !generation_bound_actions
890    {
891        return Err(WinxError::InvalidInput(
892            "the running guardian does not support generation-bound shell actions".to_string(),
893        ));
894    }
895    if let Some(expected) = options.expected_execution.as_ref() {
896        if expected.guardian_epoch != hello.daemon_epoch {
897            return Err(WinxError::InvalidInput(
898                "execution token belongs to a different guardian epoch".to_string(),
899            ));
900        }
901        options.expected_generation = Some(expected.generation);
902    }
903    if options.expected_generation.is_some() || options.require_generation_binding {
904        options.expected_guardian_epoch = Some(hello.daemon_epoch.clone());
905    }
906    if options.cancellation_key.is_some() && !has(CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY) {
907        return Err(WinxError::InvalidInput(
908            "the running guardian does not support cancellable action reservations".to_string(),
909        ));
910    }
911    if !has(COMPACT_ACTION_OUTPUT_CAPABILITY) {
912        options.compact_output = false;
913    }
914    Ok((options, generation_bound_actions))
915}
916
917fn runtime_result_from_wire(
918    result: RunActionResult,
919    generation_bound_actions: bool,
920) -> Result<BashCommandRuntimeResult> {
921    match (
922        result.output,
923        result.compact_output,
924        result.state,
925        result.error,
926        result.dropped_output_file,
927    ) {
928        (output, compact_output, Some(state), None, dropped_output_file)
929            if output.is_some() || compact_output.is_some() => {
930            let (output, compact_output) = match (output, compact_output) {
931                (Some(output), compact) => (output, compact),
932                (None, Some(compact)) => (String::new(), Some(compact)),
933                (None, None) => unreachable!(),
934            };
935            Ok(BashCommandRuntimeResult {
936                result: BashCommandResult { output, state },
937                compact_output,
938                command_generation: result.command_generation,
939                execution_token: result.execution_token,
940                generation_bound_actions,
941                dropped_output_file,
942                output_truncated: result.output_truncated,
943            })
944        }
945        (_, _, _, Some(error), _) => Err(from_wire_error(error)),
946        (Some(_), _, None, None, _) => Err(WinxError::ConfigurationError(
947            "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."
948                .to_string(),
949        )),
950        _ => Err(WinxError::ParseError(
951            "winxd action response had neither a typed result nor an error".to_string(),
952        )),
953    }
954}
955
956fn from_wire_error(error: WireShellError) -> WinxError {
957    match error {
958        WireShellError::BashStateNotInitialized => WinxError::BashStateNotInitialized,
959        WireShellError::ShellInitialization(message) => {
960            WinxError::ShellInitializationError(message)
961        }
962        WireShellError::CommandExecution(message) | WireShellError::Other(message) => {
963            WinxError::CommandExecutionError(message)
964        }
965        WireShellError::NoActiveCommand(message) => WinxError::NoActiveCommand(message),
966        WireShellError::BackgroundSessionNotFound(message) => {
967            WinxError::BackgroundSessionNotFound(message)
968        }
969        WireShellError::EmptyInteractiveInput(action) => {
970            WinxError::EmptyInteractiveInput { action }
971        }
972        WireShellError::InteractiveTargetNotRunning(message) => {
973            WinxError::InteractiveTargetNotRunning(message)
974        }
975        WireShellError::CommandAlreadyRunning { current_command, duration_seconds } => {
976            WinxError::CommandAlreadyRunning { current_command, duration_seconds }
977        }
978        WireShellError::CommandNotAllowed(message) => WinxError::CommandNotAllowed(message),
979        WireShellError::ThreadIdMismatch(message) => WinxError::ThreadIdMismatch(message),
980        WireShellError::InvalidInput(message) => WinxError::InvalidInput(message),
981    }
982}
983
984#[cfg(test)]
985mod tests {
986    #![allow(clippy::expect_used)]
987
988    use std::sync::atomic::{AtomicUsize, Ordering};
989
990    use super::*;
991    use crate::daemon::protocol::{RpcResponse, TYPED_ACTION_RESULT_CAPABILITY};
992
993    #[tokio::test]
994    #[allow(clippy::too_many_lines)] // full control/guardian wire exchange is intentional
995    async fn generation_capability_comes_from_effective_guardian_and_is_cached() {
996        let temp = tempfile::tempdir().expect("temporary directory");
997        let socket = temp.path().join("control.sock");
998        let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
999        let connections = Arc::new(AtomicUsize::new(0));
1000        let observed = Arc::clone(&connections);
1001        let server = tokio::spawn(async move {
1002            let (mut stream, _) = listener.accept().await.expect("accept adapter");
1003            observed.fetch_add(1, Ordering::SeqCst);
1004            let hello: RpcRequest = read_json_frame(&mut stream).await.expect("control hello");
1005            assert_eq!(hello.method, "winx.hello");
1006            write_json_frame(
1007                &mut stream,
1008                &RpcResponse {
1009                    jsonrpc: "2.0".to_string(),
1010                    id: hello.id,
1011                    result: Some(
1012                        serde_json::to_value(HelloResult {
1013                            protocol_major: PROTOCOL_MAJOR,
1014                            protocol_minor: 5,
1015                            capabilities: vec![GENERATION_BOUND_ACTIONS_CAPABILITY.to_string()],
1016                            max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1017                            daemon_epoch: "control-15".to_string(),
1018                            daemon_pid: 15,
1019                            process_role: None,
1020                            build: None,
1021                        })
1022                        .expect("control hello value"),
1023                    ),
1024                    error: None,
1025                },
1026            )
1027            .await
1028            .expect("write control hello");
1029
1030            let negotiation: RpcRequest =
1031                read_json_frame(&mut stream).await.expect("session negotiation");
1032            assert_eq!(negotiation.method, "session.negotiate");
1033            write_json_frame(
1034                &mut stream,
1035                &RpcResponse {
1036                    jsonrpc: "2.0".to_string(),
1037                    id: negotiation.id,
1038                    result: Some(
1039                        serde_json::to_value(HelloResult {
1040                            protocol_major: PROTOCOL_MAJOR,
1041                            protocol_minor: 4,
1042                            capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1043                            max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1044                            daemon_epoch: "guardian-14".to_string(),
1045                            daemon_pid: 14,
1046                            process_role: None,
1047                            build: None,
1048                        })
1049                        .expect("guardian hello value"),
1050                    ),
1051                    error: None,
1052                },
1053            )
1054            .await
1055            .expect("write guardian hello");
1056
1057            let action: RpcRequest =
1058                read_json_frame(&mut stream).await.expect("hot action request");
1059            assert_eq!(action.method, "shell.run_action", "hot path must not send winx.hello");
1060            assert!(
1061                action.params.get("options").is_none(),
1062                "guardian 1.4 must receive default protocol-1.4 options"
1063            );
1064            let params: RunActionParams =
1065                serde_json::from_value(action.params).expect("run action params");
1066            let cwd = params.snapshot.cwd.clone();
1067            write_json_frame(
1068                &mut stream,
1069                &RpcResponse {
1070                    jsonrpc: "2.0".to_string(),
1071                    id: action.id,
1072                    result: Some(
1073                        serde_json::to_value(RunActionResult {
1074                            output: Some("guardian-14-output".to_string()),
1075                            compact_output: None,
1076                            command_generation: None,
1077                            execution_token: None,
1078                            dropped_output_file: None,
1079                            output_truncated: false,
1080                            state: Some(crate::tools::bash_command::BashCommandState {
1081                                process_status:
1082                                    crate::tools::bash_command::BashProcessStatus::Exited,
1083                                background_id: None,
1084                                running_for_seconds: None,
1085                                exit_code: Some(0),
1086                                cwd: cwd.into(),
1087                                turn_state: None,
1088                            }),
1089                            snapshot: params.snapshot,
1090                            error: None,
1091                        })
1092                        .expect("action result value"),
1093                    ),
1094                    error: None,
1095                },
1096            )
1097            .await
1098            .expect("write action result");
1099        });
1100
1101        let runtime = DaemonShellRuntime::new(&socket);
1102        let mut state = BashState::new();
1103        state.current_thread_id = "legacy-guardian".to_string();
1104        let state = Arc::new(Mutex::new(Some(state)));
1105        let (first_probe, concurrent_probe) = tokio::join!(
1106            runtime.supports_generation_bound_actions_for(&state),
1107            runtime.supports_generation_bound_actions_for(&state)
1108        );
1109        assert!(!first_probe.expect("first capability probe"));
1110        assert!(!concurrent_probe.expect("concurrent capability probe"));
1111        assert!(!tokio::time::timeout(
1112            std::time::Duration::from_millis(100),
1113            runtime.supports_generation_bound_actions_for(&state),
1114        )
1115        .await
1116        .expect("cached probe must not reconnect")
1117        .expect("cached capability"));
1118
1119        let outcome = runtime
1120            .run_remote_detailed(
1121                &state,
1122                BashCommand {
1123                    action_json: crate::types::BashCommandAction::Command {
1124                        command: "printf guardian-14-output".to_string(),
1125                        is_background: false,
1126                        allow_multi: false,
1127                    },
1128                    wait_for_seconds: Some(0.0),
1129                    thread_id: "legacy-guardian".to_string(),
1130                },
1131                ShellActionOptions { compact_output: true, ..ShellActionOptions::default() },
1132            )
1133            .await
1134            .expect("guardian 1.4 action");
1135        assert_eq!(outcome.result.output, "guardian-14-output");
1136        assert_eq!(outcome.compact_output, None);
1137        server.await.expect("mock server task");
1138        assert_eq!(connections.load(Ordering::SeqCst), 1);
1139    }
1140
1141    #[tokio::test]
1142    #[allow(clippy::too_many_lines)] // two complete negotiated epochs are intentional
1143    async fn ambiguous_action_is_not_retried_after_guardian_epoch_changes() {
1144        let temp = tempfile::tempdir().expect("temporary directory");
1145        let socket = temp.path().join("control-restart.sock");
1146        let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
1147        let action_count = Arc::new(AtomicUsize::new(0));
1148        let observed = Arc::clone(&action_count);
1149        let server = tokio::spawn(async move {
1150            for (control_epoch, guardian_epoch) in
1151                [("control-one", "guardian-one"), ("control-two", "guardian-two")]
1152            {
1153                let (mut stream, _) = listener.accept().await.expect("accept adapter");
1154                let control_request: RpcRequest =
1155                    read_json_frame(&mut stream).await.expect("control hello");
1156                assert_eq!(control_request.method, "winx.hello");
1157                write_json_frame(
1158                    &mut stream,
1159                    &RpcResponse {
1160                        jsonrpc: "2.0".to_string(),
1161                        id: control_request.id,
1162                        result: Some(
1163                            serde_json::to_value(HelloResult {
1164                                protocol_major: PROTOCOL_MAJOR,
1165                                protocol_minor: 5,
1166                                capabilities: vec![],
1167                                max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1168                                daemon_epoch: control_epoch.to_string(),
1169                                daemon_pid: 15,
1170                                process_role: None,
1171                                build: None,
1172                            })
1173                            .expect("control hello value"),
1174                        ),
1175                        error: None,
1176                    },
1177                )
1178                .await
1179                .expect("control hello response");
1180                let negotiation: RpcRequest =
1181                    read_json_frame(&mut stream).await.expect("session negotiation");
1182                write_json_frame(
1183                    &mut stream,
1184                    &RpcResponse {
1185                        jsonrpc: "2.0".to_string(),
1186                        id: negotiation.id,
1187                        result: Some(
1188                            serde_json::to_value(HelloResult {
1189                                protocol_major: PROTOCOL_MAJOR,
1190                                protocol_minor: 5,
1191                                capabilities: vec![
1192                                    TYPED_ACTION_RESULT_CAPABILITY.to_string(),
1193                                    GENERATION_BOUND_ACTIONS_CAPABILITY.to_string(),
1194                                ],
1195                                max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1196                                daemon_epoch: guardian_epoch.to_string(),
1197                                daemon_pid: 16,
1198                                process_role: None,
1199                                build: None,
1200                            })
1201                            .expect("guardian hello value"),
1202                        ),
1203                        error: None,
1204                    },
1205                )
1206                .await
1207                .expect("guardian hello response");
1208
1209                if guardian_epoch == "guardian-one" {
1210                    let action: RpcRequest =
1211                        read_json_frame(&mut stream).await.expect("first action");
1212                    assert_eq!(action.method, "shell.run_action");
1213                    observed.fetch_add(1, Ordering::SeqCst);
1214                    // Dropping without a response makes delivery ambiguous.
1215                } else {
1216                    let repeated = tokio::time::timeout(
1217                        std::time::Duration::from_millis(100),
1218                        read_json_frame::<_, RpcRequest>(&mut stream),
1219                    )
1220                    .await;
1221                    assert!(repeated.is_err(), "action was resent to a different guardian epoch");
1222                }
1223            }
1224        });
1225
1226        let runtime = DaemonShellRuntime::new(&socket);
1227        let mut state = BashState::new();
1228        state.current_thread_id = "ambiguous-action".to_string();
1229        let state = Arc::new(Mutex::new(Some(state)));
1230        let result = runtime
1231            .run_remote_detailed(
1232                &state,
1233                BashCommand {
1234                    action_json: crate::types::BashCommandAction::Command {
1235                        command: "printf must-run-once".to_string(),
1236                        is_background: false,
1237                        allow_multi: false,
1238                    },
1239                    wait_for_seconds: Some(0.0),
1240                    thread_id: "ambiguous-action".to_string(),
1241                },
1242                ShellActionOptions::default(),
1243            )
1244            .await;
1245        assert!(
1246            matches!(result, Err(WinxError::CommandExecutionError(ref message)) if message.contains("will not retry")),
1247            "{result:?}"
1248        );
1249        server.await.expect("mock server task");
1250        assert_eq!(action_count.load(Ordering::SeqCst), 1);
1251    }
1252
1253    #[tokio::test]
1254    async fn adapter_negotiation_cache_is_bounded_under_session_churn() {
1255        let mut cache = NegotiationCache::default();
1256        for index in 0..(MAX_NEGOTIATED_SESSIONS + 50) {
1257            let (stream, peer) = UnixStream::pair().expect("unix stream pair");
1258            drop(peer);
1259            let session = Arc::new(Mutex::new(NegotiatedSession {
1260                control_epoch: "control".to_string(),
1261                guardian: HelloResult {
1262                    protocol_major: PROTOCOL_MAJOR,
1263                    protocol_minor: 5,
1264                    capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1265                    max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1266                    daemon_epoch: format!("guardian-{index}"),
1267                    daemon_pid: u32::try_from(index).unwrap_or(u32::MAX),
1268                    process_role: None,
1269                    build: None,
1270                },
1271                stream,
1272            }));
1273            cache_negotiation(&mut cache, &format!("thread-{index}"), session, Instant::now())
1274                .expect("inactive cache entry should be evictable");
1275        }
1276        assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1277    }
1278
1279    #[tokio::test]
1280    async fn adapter_negotiation_cache_never_evicts_active_sessions() {
1281        let mut cache = NegotiationCache::default();
1282        let mut active = Vec::new();
1283        for index in 0..MAX_NEGOTIATED_SESSIONS {
1284            let (stream, peer) = UnixStream::pair().expect("unix stream pair");
1285            drop(peer);
1286            let session = Arc::new(Mutex::new(NegotiatedSession {
1287                control_epoch: "control".to_string(),
1288                guardian: HelloResult {
1289                    protocol_major: PROTOCOL_MAJOR,
1290                    protocol_minor: 5,
1291                    capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1292                    max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1293                    daemon_epoch: format!("active-{index}"),
1294                    daemon_pid: u32::try_from(index).unwrap_or(u32::MAX),
1295                    process_role: None,
1296                    build: None,
1297                },
1298                stream,
1299            }));
1300            cache_negotiation(
1301                &mut cache,
1302                &format!("active-{index}"),
1303                Arc::clone(&session),
1304                Instant::now(),
1305            )
1306            .expect("active slot");
1307            active.push(session);
1308        }
1309        let (stream, peer) = UnixStream::pair().expect("overflow pair");
1310        drop(peer);
1311        let overflow = Arc::new(Mutex::new(NegotiatedSession {
1312            control_epoch: "control".to_string(),
1313            guardian: HelloResult {
1314                protocol_major: PROTOCOL_MAJOR,
1315                protocol_minor: 5,
1316                capabilities: vec![],
1317                max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1318                daemon_epoch: "overflow".to_string(),
1319                daemon_pid: 999,
1320                process_role: None,
1321                build: None,
1322            },
1323            stream,
1324        }));
1325        let error =
1326            cache_negotiation(&mut cache, "overflow", Arc::clone(&overflow), Instant::now())
1327                .expect_err("all active sessions must apply backpressure");
1328        assert!(matches!(error, WinxError::ResourceAllocationError { .. }));
1329        assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1330
1331        drop(active.pop());
1332        cache_negotiation(&mut cache, "overflow", overflow, Instant::now())
1333            .expect("an inactive session can be evicted");
1334        assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1335    }
1336
1337    #[tokio::test]
1338    async fn stale_daemon_action_snapshot_cannot_overwrite_local_reconfiguration() {
1339        let runtime = DaemonShellRuntime::new("unused.sock");
1340        let mut initial = BashState::new();
1341        initial.current_thread_id = "local-revision".to_string();
1342        initial.cwd = PathBuf::from("/old-action-cwd");
1343        let stale_snapshot = initial.snapshot();
1344        let state = Arc::new(Mutex::new(Some(initial)));
1345
1346        let revision = runtime.local_revision("local-revision").await.expect("revision");
1347        let captured = revision.load(Ordering::SeqCst);
1348        let mut configure_guard = state.lock().await;
1349        let apply_runtime = runtime.clone();
1350        let apply_state = Arc::clone(&state);
1351        let apply_revision = Arc::clone(&revision);
1352        let stale_apply = tokio::spawn(async move {
1353            apply_runtime
1354                .apply_snapshot_if_current(
1355                    &apply_state,
1356                    "local-revision",
1357                    &apply_revision,
1358                    captured,
1359                    &stale_snapshot,
1360                )
1361                .await
1362        });
1363        tokio::task::yield_now().await;
1364
1365        // configure_remote performs this bump while its caller holds the same
1366        // BashState lock. The stale action writeback is already concurrent and
1367        // waiting for that lock, so it must revalidate after configuration.
1368        revision.fetch_add(1, Ordering::SeqCst);
1369        configure_guard.as_mut().expect("state").cwd = PathBuf::from("/new-config-cwd");
1370        drop(configure_guard);
1371
1372        assert!(!stale_apply.await.expect("stale writeback task"));
1373        assert_eq!(
1374            state.lock().await.as_ref().expect("state").cwd,
1375            PathBuf::from("/new-config-cwd")
1376        );
1377    }
1378}