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