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