Skip to main content

winx_code_agent/daemon/
control.rs

1use std::collections::HashMap;
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use tokio::sync::Mutex;
8
9use super::lifecycle::{GuardianLifecycle, GuardianLimits};
10use super::protocol::{
11    read_json_frame, write_json_frame, CancelActionParams, ConfigureSessionParams,
12    DaemonProcessRole, HelloResult, JournalReadParams, PruneParams, RpcError, RpcRequest,
13    RpcResponse, RunActionParams, SessionInfo, SessionParams,
14    CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY, COMPACT_ACTION_OUTPUT_CAPABILITY,
15    GENERATION_BOUND_ACTIONS_CAPABILITY, PROTOCOL_MAJOR, TYPED_ACTION_RESULT_CAPABILITY,
16};
17use crate::errors::{Result, WinxError};
18use crate::types::normalize_thread_id;
19
20/// Stable control plane. Each logical session is owned by a separate guardian
21/// process, so restarting this process does not close any PTY master.
22pub struct ControlServer {
23    listener: crate::daemon::transport::DaemonListener,
24    socket_path: PathBuf,
25    lifecycle: Arc<GuardianLifecycle>,
26    guardian_capabilities: Arc<Mutex<HashMap<PathBuf, CachedGuardian>>>,
27    guardian_negotiation_gates: Arc<Mutex<HashMap<PathBuf, GuardianNegotiationGate>>>,
28    epoch: String,
29}
30
31#[derive(Clone)]
32struct CachedGuardian {
33    hello: HelloResult,
34    last_seen: Instant,
35    socket_identity: Option<SocketIdentity>,
36}
37
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39struct SocketIdentity {
40    device: u64,
41    inode: u64,
42    ctime_seconds: i64,
43    ctime_nanoseconds: i64,
44    mtime_seconds: i64,
45    mtime_nanoseconds: i64,
46}
47
48struct GuardianNegotiationGate {
49    gate: Arc<Mutex<()>>,
50    last_seen: Instant,
51}
52
53const MAX_GUARDIAN_NEGOTIATIONS: usize = 256;
54const GUARDIAN_NEGOTIATION_TTL: Duration = Duration::from_secs(5 * 60);
55
56impl ControlServer {
57    pub async fn bind(socket_path: impl AsRef<Path>) -> Result<Self> {
58        let socket_path = socket_path.as_ref().to_path_buf();
59        let parent = socket_path.parent().ok_or_else(|| {
60            WinxError::ConfigurationError("daemon socket must have a parent directory".to_string())
61        })?;
62        tokio::fs::create_dir_all(parent).await?;
63        crate::daemon::transport::restrict_to_owner(parent, 0o700).await?;
64        let guardian_dir = parent.join("guardians");
65        tokio::fs::create_dir_all(&guardian_dir).await?;
66        crate::daemon::transport::restrict_to_owner(&guardian_dir, 0o700).await?;
67
68        // The listener claims the path atomically; a concurrent daemon start
69        // fails here instead of stealing a live socket.
70        let listener = crate::daemon::transport::DaemonListener::bind(&socket_path).await.map_err(
71            |error| {
72                WinxError::ConfigurationError(format!(
73                    "cannot claim daemon socket {}: {error}",
74                    socket_path.display()
75                ))
76            },
77        )?;
78        crate::daemon::transport::restrict_to_owner(&socket_path, 0o600).await?;
79        let lifecycle = Arc::new(GuardianLifecycle::new(
80            guardian_dir,
81            guardian_binary()?,
82            GuardianLimits::from_env()?,
83        ));
84
85        Ok(Self {
86            listener,
87            socket_path,
88            lifecycle,
89            guardian_capabilities: Arc::new(Mutex::new(HashMap::new())),
90            guardian_negotiation_gates: Arc::new(Mutex::new(HashMap::new())),
91            epoch: format!("{:016x}", rand::random::<u64>()),
92        })
93    }
94
95    pub async fn serve(mut self) -> Result<()> {
96        crate::daemon::exit::install("winxd", &self.socket_path, &self.epoch);
97        self.lifecycle.clone().spawn_sweeper();
98        loop {
99            let stream = match self.listener.accept().await {
100                Ok(stream) => stream,
101                Err(error) => {
102                    crate::daemon::exit::record_current(
103                        &crate::daemon::exit::ExitReason::ListenerFailure(error.to_string()),
104                    );
105                    return Err(error.into());
106                }
107            };
108            if !crate::daemon::transport::peer_is_same_user(&stream)? {
109                tracing::warn!("Rejected winxd control connection from a different uid");
110                continue;
111            }
112            let lifecycle = self.lifecycle.clone();
113            let guardian_capabilities = self.guardian_capabilities.clone();
114            let guardian_negotiation_gates = self.guardian_negotiation_gates.clone();
115            let epoch = self.epoch.clone();
116            tokio::spawn(async move {
117                if let Err(error) = serve_connection(
118                    stream,
119                    lifecycle,
120                    guardian_capabilities,
121                    guardian_negotiation_gates,
122                    epoch,
123                )
124                .await
125                {
126                    tracing::debug!("winxd control client disconnected: {error}");
127                }
128            });
129        }
130    }
131}
132
133impl Drop for ControlServer {
134    fn drop(&mut self) {
135        let _ = std::fs::remove_file(&self.socket_path);
136    }
137}
138
139async fn serve_connection(
140    mut stream: crate::daemon::transport::DaemonStream,
141    lifecycle: Arc<GuardianLifecycle>,
142    guardian_capabilities: Arc<Mutex<HashMap<PathBuf, CachedGuardian>>>,
143    guardian_negotiation_gates: Arc<Mutex<HashMap<PathBuf, GuardianNegotiationGate>>>,
144    epoch: String,
145) -> Result<()> {
146    loop {
147        let request: RpcRequest = match read_json_frame(&mut stream).await {
148            Ok(request) => request,
149            Err(error) if error.kind() == ErrorKind::UnexpectedEof => return Ok(()),
150            Err(error) => return Err(error.into()),
151        };
152        let response = dispatch(
153            request,
154            &lifecycle,
155            &guardian_capabilities,
156            &guardian_negotiation_gates,
157            &epoch,
158        )
159        .await;
160        write_json_frame(&mut stream, &response).await?;
161    }
162}
163
164#[allow(clippy::too_many_lines)]
165async fn dispatch(
166    mut request: RpcRequest,
167    lifecycle: &GuardianLifecycle,
168    guardian_capabilities: &Mutex<HashMap<PathBuf, CachedGuardian>>,
169    guardian_negotiation_gates: &Mutex<HashMap<PathBuf, GuardianNegotiationGate>>,
170    epoch: &str,
171) -> RpcResponse {
172    if request.jsonrpc != "2.0" {
173        return rpc_error(request.id, -32600, "JSON-RPC version must be 2.0");
174    }
175    if request.method == "winx.hello" {
176        return rpc_result(
177            request.id,
178            &HelloResult::current(
179                DaemonProcessRole::Control,
180                vec![
181                    "per_session_guardians".to_string(),
182                    "planned_control_restart".to_string(),
183                    "guardian_quota".to_string(),
184                    "guardian_idle_ttl".to_string(),
185                    "guardian_activity_clock".to_string(),
186                    "unused_session_ttl".to_string(),
187                    "quota_reclaims_unused".to_string(),
188                    "session.configure".to_string(),
189                    "session.negotiate".to_string(),
190                    "shell.run_action".to_string(),
191                    TYPED_ACTION_RESULT_CAPABILITY.to_string(),
192                    COMPACT_ACTION_OUTPUT_CAPABILITY.to_string(),
193                    GENERATION_BOUND_ACTIONS_CAPABILITY.to_string(),
194                    CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY.to_string(),
195                    "session.list".to_string(),
196                    "session.read_output".to_string(),
197                    "session.kill".to_string(),
198                    "session.prune".to_string(),
199                    "multi_consumer_cursors".to_string(),
200                    "idempotency".to_string(),
201                    crate::daemon::protocol::PROCESS_SHUTDOWN_CAPABILITY.to_string(),
202                ],
203                epoch,
204            ),
205        );
206    }
207    if request.method == "winx.shutdown" {
208        // Planned control restart: guardians are detached processes and keep
209        // their PTYs; only the control plane exits.
210        crate::daemon::server::schedule_process_exit("winxd");
211        return rpc_result(request.id, &serde_json::json!({ "shutting_down": true }));
212    }
213
214    if request.method == "session.list" {
215        return match list_sessions(lifecycle.guardian_dir()).await {
216            Ok(sessions) => rpc_result(request.id, &sessions),
217            Err(error) => rpc_error(request.id, -32000, &error.to_string()),
218        };
219    }
220
221    if request.method == "session.prune" {
222        let params = match decode::<PruneParams>(request.params) {
223            Ok(params) => params,
224            Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
225        };
226        return match lifecycle.prune(params.idle_seconds).await {
227            Ok(result) => {
228                let mut cache = guardian_capabilities.lock().await;
229                for thread_id in &result.removed_thread_ids {
230                    cache.remove(&lifecycle.socket_for(thread_id));
231                }
232                rpc_result(request.id, &result)
233            }
234            Err(error) => rpc_error(request.id, -32003, &error.to_string()),
235        };
236    }
237
238    let thread_id = match request_thread_id(&request) {
239        Ok(thread_id) if !thread_id.is_empty() => thread_id,
240        Ok(_) => return rpc_error(request.id, -32602, "explicit thread_id is required"),
241        Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
242    };
243    let guardian_socket = lifecycle.socket_for(&thread_id);
244    if request.method == "session.negotiate" {
245        return match negotiated_guardian(
246            lifecycle,
247            guardian_capabilities,
248            guardian_negotiation_gates,
249            &thread_id,
250            &guardian_socket,
251            true,
252        )
253        .await
254        {
255            Ok(hello) => rpc_result(request.id, &hello),
256            Err(error) => rpc_error(request.id, -32001, &error.to_string()),
257        };
258    }
259    if request.method == "session.kill" {
260        match tokio::fs::try_exists(&guardian_socket).await {
261            Ok(true) => {}
262            Ok(false) => return rpc_result(request.id, &false),
263            Err(error) => return rpc_error(request.id, -32002, &error.to_string()),
264        }
265    }
266
267    let create_if_missing =
268        matches!(request.method.as_str(), "session.configure" | "shell.run_action");
269    let mut hello = match negotiated_guardian(
270        lifecycle,
271        guardian_capabilities,
272        guardian_negotiation_gates,
273        &thread_id,
274        &guardian_socket,
275        create_if_missing,
276    )
277    .await
278    {
279        Ok(hello) => hello,
280        Err(error) => return rpc_error(request.id, -32001, &error.to_string()),
281    };
282    let live_precondition = match request_requires_live_guardian(&request) {
283        Ok(required) => required,
284        Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
285    };
286    if !live_precondition {
287        if let Err(error) = normalize_guardian_request(&mut request, &hello) {
288            return rpc_error(request.id, -32602, &error.to_string());
289        }
290    }
291    let kill_after = request.method == "session.kill";
292    let request_id = request.id;
293    let response = if live_precondition {
294        match relay(&guardian_socket, request.clone()).await {
295            Ok((response, effective)) => {
296                hello = effective.clone();
297                cache_guardian(guardian_capabilities, guardian_socket.clone(), effective).await;
298                response
299            }
300            Err(error) => {
301                guardian_capabilities.lock().await.remove(&guardian_socket);
302                return rpc_error(request_id, -32602, &error.to_string());
303            }
304        }
305    } else {
306        match relay_negotiated(&guardian_socket, request.clone(), &hello).await {
307            Ok(result) => result,
308            Err(first_error) => {
309                guardian_capabilities.lock().await.remove(&guardian_socket);
310                let refreshed = match negotiated_guardian(
311                    lifecycle,
312                    guardian_capabilities,
313                    guardian_negotiation_gates,
314                    &thread_id,
315                    &guardian_socket,
316                    create_if_missing,
317                )
318                .await
319                {
320                    Ok(hello) => hello,
321                    Err(error) => {
322                        return rpc_error(
323                            request_id,
324                            -32002,
325                            &format!("{first_error}; guardian renegotiation failed: {error}"),
326                        )
327                    }
328                };
329                if request.method == "shell.run_action"
330                    && refreshed.daemon_epoch != hello.daemon_epoch
331                {
332                    return rpc_error(
333                    request_id,
334                    -32002,
335                    &format!(
336                        "{first_error}; guardian epoch changed, so winxd will not retry an ambiguously delivered shell action"
337                    ),
338                );
339                }
340                if let Err(error) = normalize_guardian_request(&mut request, &refreshed) {
341                    return rpc_error(request_id, -32602, &error.to_string());
342                }
343                match relay_negotiated(&guardian_socket, request, &refreshed).await {
344                    Ok(result) => {
345                        hello = refreshed;
346                        result
347                    }
348                    Err(error) => return rpc_error(request_id, -32002, &error.to_string()),
349                }
350            }
351        }
352    };
353
354    if kill_after && response.error.is_none() {
355        guardian_capabilities.lock().await.remove(&guardian_socket);
356        if let Err(error) = lifecycle.finish_kill(&guardian_socket, hello.daemon_pid).await {
357            return rpc_error(
358                request_id,
359                -32003,
360                &format!("session state was removed, but guardian cleanup failed: {error}"),
361            );
362        }
363    } else {
364        lifecycle.note_activity(&guardian_socket, &thread_id, hello.daemon_pid).await;
365    }
366    response
367}
368
369fn request_thread_id(request: &RpcRequest) -> Result<String> {
370    let thread_id = match request.method.as_str() {
371        "session.configure" => {
372            decode::<ConfigureSessionParams>(request.params.clone())?.snapshot.chat_id
373        }
374        "shell.run_action" => decode::<RunActionParams>(request.params.clone())?.command.thread_id,
375        "shell.cancel_action" => decode::<CancelActionParams>(request.params.clone())?.thread_id,
376        "session.read_output" => decode::<JournalReadParams>(request.params.clone())?.thread_id,
377        "session.negotiate" | "session.info" | "session.kill" | "session.interrupt" => {
378            decode::<SessionParams>(request.params.clone())?.thread_id
379        }
380        _ => {
381            return Err(WinxError::InvalidInput(format!(
382                "unsupported control method {}",
383                request.method
384            )))
385        }
386    };
387    Ok(normalize_thread_id(&thread_id))
388}
389
390async fn negotiated_guardian(
391    lifecycle: &GuardianLifecycle,
392    cache: &Mutex<HashMap<PathBuf, CachedGuardian>>,
393    gates: &Mutex<HashMap<PathBuf, GuardianNegotiationGate>>,
394    thread_id: &str,
395    socket: &Path,
396    create_if_missing: bool,
397) -> Result<HelloResult> {
398    if let Some(hello) = cached_guardian(cache, socket).await {
399        return Ok(hello);
400    }
401    let negotiation_gate = guardian_negotiation_gate(gates, socket).await?;
402    let _single_flight = negotiation_gate.lock().await;
403    if let Some(hello) = cached_guardian(cache, socket).await {
404        return Ok(hello);
405    }
406    if !create_if_missing && !tokio::fs::try_exists(socket).await? {
407        return Err(WinxError::ShellInitializationError(format!(
408            "guardian at {} is unavailable",
409            socket.display()
410        )));
411    }
412    let hello = lifecycle.ensure_guardian(thread_id, socket).await?;
413    cache_guardian(cache, socket.to_path_buf(), hello.clone()).await;
414    Ok(hello)
415}
416
417async fn cached_guardian(
418    cache: &Mutex<HashMap<PathBuf, CachedGuardian>>,
419    socket: &Path,
420) -> Option<HelloResult> {
421    let socket_identity = socket_identity(socket).await;
422    let now = Instant::now();
423    let mut cache = cache.lock().await;
424    prune_guardian_cache(&mut cache, now);
425    if let Some(entry) = cache
426        .get_mut(socket)
427        .filter(|entry| socket_identity.is_some() && entry.socket_identity == socket_identity)
428    {
429        entry.last_seen = now;
430        return Some(entry.hello.clone());
431    }
432    cache.remove(socket);
433    None
434}
435
436fn prune_guardian_cache(cache: &mut HashMap<PathBuf, CachedGuardian>, now: Instant) {
437    cache.retain(|_, entry| now.duration_since(entry.last_seen) <= GUARDIAN_NEGOTIATION_TTL);
438}
439
440async fn guardian_negotiation_gate(
441    gates: &Mutex<HashMap<PathBuf, GuardianNegotiationGate>>,
442    socket: &Path,
443) -> Result<Arc<Mutex<()>>> {
444    let now = Instant::now();
445    let mut gates = gates.lock().await;
446    gates.retain(|_, entry| {
447        now.duration_since(entry.last_seen) <= GUARDIAN_NEGOTIATION_TTL
448            || Arc::strong_count(&entry.gate) > 1
449    });
450    if let Some(entry) = gates.get_mut(socket) {
451        entry.last_seen = now;
452        return Ok(Arc::clone(&entry.gate));
453    }
454    if gates.len() >= MAX_GUARDIAN_NEGOTIATIONS {
455        let oldest = gates
456            .iter()
457            .filter(|(_, entry)| Arc::strong_count(&entry.gate) == 1)
458            .min_by_key(|(_, entry)| entry.last_seen)
459            .map(|(path, _)| path.clone());
460        if let Some(oldest) = oldest {
461            gates.remove(&oldest);
462        }
463    }
464    if gates.len() >= MAX_GUARDIAN_NEGOTIATIONS {
465        return Err(WinxError::ResourceAllocationError {
466            message: format!(
467                "all {MAX_GUARDIAN_NEGOTIATIONS} guardian negotiation slots are active"
468            ),
469        });
470    }
471    let gate = Arc::new(Mutex::new(()));
472    gates.insert(
473        socket.to_path_buf(),
474        GuardianNegotiationGate { gate: Arc::clone(&gate), last_seen: now },
475    );
476    Ok(gate)
477}
478
479async fn cache_guardian(
480    cache: &Mutex<HashMap<PathBuf, CachedGuardian>>,
481    socket: PathBuf,
482    hello: HelloResult,
483) {
484    // Read filesystem identity before taking the global cache mutex. Besides
485    // avoiding I/O under the lock, this makes an unlinked/recreated guardian
486    // socket invalidate the old epoch immediately instead of waiting for TTL.
487    let socket_identity = socket_identity(&socket).await;
488    let now = Instant::now();
489    let mut cache = cache.lock().await;
490    prune_guardian_cache(&mut cache, now);
491    if !cache.contains_key(&socket) && cache.len() >= MAX_GUARDIAN_NEGOTIATIONS {
492        if let Some(oldest) =
493            cache.iter().min_by_key(|(_, entry)| entry.last_seen).map(|(path, _)| path.clone())
494        {
495            cache.remove(&oldest);
496        }
497    }
498    cache.insert(socket, CachedGuardian { hello, last_seen: now, socket_identity });
499}
500
501/// Identity of the socket artifact, used to notice a guardian that was
502/// recreated behind the same path. Unix reads the inode; Windows uses the
503/// marker file's creation time and size, which change on every re-bind.
504async fn socket_identity(socket: &Path) -> Option<SocketIdentity> {
505    let metadata = tokio::fs::metadata(socket).await.ok()?;
506    #[cfg(unix)]
507    {
508        use std::os::unix::fs::MetadataExt as _;
509        Some(SocketIdentity {
510            device: metadata.dev(),
511            inode: metadata.ino(),
512            ctime_seconds: metadata.ctime(),
513            ctime_nanoseconds: metadata.ctime_nsec(),
514            mtime_seconds: metadata.mtime(),
515            mtime_nanoseconds: metadata.mtime_nsec(),
516        })
517    }
518    #[cfg(not(unix))]
519    {
520        use std::os::windows::fs::MetadataExt as _;
521        let modified = metadata
522            .modified()
523            .ok()
524            .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok());
525        Some(SocketIdentity {
526            device: 0,
527            inode: metadata.file_size(),
528            ctime_seconds: i64::try_from(metadata.creation_time()).unwrap_or(i64::MAX),
529            ctime_nanoseconds: 0,
530            mtime_seconds: modified
531                .map_or(0, |duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)),
532            mtime_nanoseconds: modified.map_or(0, |duration| i64::from(duration.subsec_nanos())),
533        })
534    }
535}
536
537fn normalize_guardian_request(request: &mut RpcRequest, hello: &HelloResult) -> Result<()> {
538    let has = |capability: &str| hello.capabilities.iter().any(|candidate| candidate == capability);
539    if request.method == "shell.run_action" {
540        let mut params = decode::<RunActionParams>(request.params.clone())?;
541        if !has(TYPED_ACTION_RESULT_CAPABILITY) {
542            return Err(WinxError::ConfigurationError(format!(
543                "guardian pid {} does not advertise {TYPED_ACTION_RESULT_CAPABILITY}; terminate this durable session and initialize it again before running another BashCommand",
544                hello.daemon_pid
545            )));
546        }
547        if params
548            .options
549            .expected_guardian_epoch
550            .as_deref()
551            .is_some_and(|expected| expected != hello.daemon_epoch)
552            || params
553                .options
554                .expected_execution
555                .as_ref()
556                .is_some_and(|expected| expected.guardian_epoch != hello.daemon_epoch)
557        {
558            return Err(WinxError::InvalidInput(
559                "shell action precondition refers to a different effective guardian epoch"
560                    .to_string(),
561            ));
562        }
563        if (params.options.expected_generation.is_some()
564            || params.options.expected_execution.is_some()
565            || params.options.require_generation_binding)
566            && !has(GENERATION_BOUND_ACTIONS_CAPABILITY)
567        {
568            return Err(WinxError::InvalidInput(
569                "the running guardian does not support generation-bound shell actions".to_string(),
570            ));
571        }
572        if params.options.cancellation_key.is_some()
573            && !has(CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY)
574        {
575            return Err(WinxError::InvalidInput(
576                "the running guardian does not support cancellable action reservations".to_string(),
577            ));
578        }
579        if !has(COMPACT_ACTION_OUTPUT_CAPABILITY) {
580            params.options.compact_output = false;
581        }
582        request.params = serde_json::to_value(params)
583            .map_err(|error| WinxError::SerializationError(error.to_string()))?;
584    } else if request.method == "session.interrupt" {
585        let params = decode::<SessionParams>(request.params.clone())?;
586        if params
587            .expected_guardian_epoch
588            .as_deref()
589            .is_some_and(|expected| expected != hello.daemon_epoch)
590            || params
591                .expected_execution
592                .as_ref()
593                .is_some_and(|expected| expected.guardian_epoch != hello.daemon_epoch)
594        {
595            return Err(WinxError::InvalidInput(
596                "interrupt precondition refers to a different effective guardian epoch".to_string(),
597            ));
598        }
599        if (params.expected_generation.is_some() || params.expected_execution.is_some())
600            && !has(GENERATION_BOUND_ACTIONS_CAPABILITY)
601        {
602            return Err(WinxError::InvalidInput(
603                "the running guardian does not support generation-bound interruption".to_string(),
604            ));
605        }
606    } else if request.method == "shell.cancel_action" {
607        let params = decode::<CancelActionParams>(request.params.clone())?;
608        if !has(CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY) {
609            return Err(WinxError::InvalidInput(
610                "the running guardian does not support cancellable action reservations".to_string(),
611            ));
612        }
613        if params
614            .expected_guardian_epoch
615            .as_deref()
616            .is_some_and(|expected| expected != hello.daemon_epoch)
617        {
618            return Err(WinxError::InvalidInput(
619                "cancel precondition refers to a different effective guardian epoch".to_string(),
620            ));
621        }
622    }
623    Ok(())
624}
625
626fn request_requires_live_guardian(request: &RpcRequest) -> Result<bool> {
627    match request.method.as_str() {
628        "shell.run_action" => {
629            let params = decode::<RunActionParams>(request.params.clone())?;
630            Ok(params.options.require_generation_binding
631                || params.options.expected_generation.is_some()
632                || params.options.expected_execution.is_some()
633                || params.options.expected_guardian_epoch.is_some()
634                || params.options.cancellation_key.is_some())
635        }
636        "session.interrupt" => {
637            let params = decode::<SessionParams>(request.params.clone())?;
638            Ok(params.expected_generation.is_some()
639                || params.expected_execution.is_some()
640                || params.expected_guardian_epoch.is_some())
641        }
642        "shell.cancel_action" => Ok(true),
643        _ => Ok(false),
644    }
645}
646
647async fn relay(socket: &Path, mut request: RpcRequest) -> Result<(RpcResponse, HelloResult)> {
648    let mut stream = crate::daemon::transport::connect(socket).await.map_err(|error| {
649        WinxError::ShellInitializationError(format!(
650            "guardian at {} is unavailable: {error}",
651            socket.display()
652        ))
653    })?;
654    let hello_id = rand::random::<u64>();
655    write_json_frame(
656        &mut stream,
657        &RpcRequest {
658            jsonrpc: "2.0".to_string(),
659            id: hello_id,
660            method: "winx.hello".to_string(),
661            params: serde_json::json!({ "protocol_major": PROTOCOL_MAJOR }),
662        },
663    )
664    .await?;
665    let hello_response: RpcResponse = read_json_frame(&mut stream).await?;
666    let hello: HelloResult = decode(hello_response.result.ok_or_else(|| {
667        WinxError::ConfigurationError("guardian handshake omitted result".to_string())
668    })?)?;
669    if hello.protocol_major != PROTOCOL_MAJOR {
670        return Err(WinxError::ConfigurationError(format!(
671            "guardian protocol major {} is incompatible with control {}",
672            hello.protocol_major, PROTOCOL_MAJOR
673        )));
674    }
675    if request.method == "shell.run_action"
676        && !hello.capabilities.iter().any(|capability| capability == TYPED_ACTION_RESULT_CAPABILITY)
677    {
678        return Err(WinxError::ConfigurationError(format!(
679            "guardian pid {} does not advertise {TYPED_ACTION_RESULT_CAPABILITY}; terminate this durable session and initialize it again before running another BashCommand",
680            hello.daemon_pid
681        )));
682    }
683    normalize_guardian_request(&mut request, &hello)?;
684    write_json_frame(&mut stream, &request).await?;
685    let response: RpcResponse = read_json_frame(&mut stream).await?;
686    Ok((response, hello))
687}
688
689async fn relay_negotiated(
690    socket: &Path,
691    request: RpcRequest,
692    hello: &HelloResult,
693) -> Result<RpcResponse> {
694    let mut stream = crate::daemon::transport::connect(socket).await.map_err(|error| {
695        WinxError::ShellInitializationError(format!(
696            "guardian at {} is unavailable: {error}",
697            socket.display()
698        ))
699    })?;
700    write_json_frame(&mut stream, &request).await?;
701    let response: RpcResponse = read_json_frame(&mut stream).await?;
702    if response.id != request.id || response.jsonrpc != "2.0" {
703        return Err(WinxError::ParseError(format!(
704            "guardian pid {} returned a mismatched JSON-RPC response",
705            hello.daemon_pid
706        )));
707    }
708    Ok(response)
709}
710
711async fn list_sessions(guardian_dir: &Path) -> Result<Vec<SessionInfo>> {
712    let mut sessions = Vec::new();
713    let mut entries = tokio::fs::read_dir(guardian_dir).await?;
714    while let Some(entry) = entries.next_entry().await? {
715        let path = entry.path();
716        if path.extension().and_then(|extension| extension.to_str()) != Some("sock") {
717            continue;
718        }
719        let request = RpcRequest {
720            jsonrpc: "2.0".to_string(),
721            id: rand::random::<u64>(),
722            method: "session.list".to_string(),
723            params: serde_json::json!({}),
724        };
725        let Ok((response, hello)) = relay(&path, request).await else { continue };
726        let Some(result) = response.result else { continue };
727        if let Ok(mut guardian_sessions) = serde_json::from_value::<Vec<SessionInfo>>(result) {
728            for session in &mut guardian_sessions {
729                session.runtime = Some(hello.clone());
730            }
731            sessions.append(&mut guardian_sessions);
732        }
733    }
734    sessions.sort_by(|left, right| left.thread_id.cmp(&right.thread_id));
735    Ok(sessions)
736}
737
738fn decode<T: serde::de::DeserializeOwned>(value: serde_json::Value) -> Result<T> {
739    serde_json::from_value(value)
740        .map_err(|error| WinxError::DeserializationError(error.to_string()))
741}
742
743fn guardian_binary() -> Result<PathBuf> {
744    if let Some(path) = std::env::var_os("WINX_GUARDIAN_BIN") {
745        return Ok(PathBuf::from(path));
746    }
747    let executable = std::env::current_exe()?;
748    let sibling = executable.with_file_name(if cfg!(windows) {
749        "winx-guardian.exe"
750    } else {
751        "winx-guardian"
752    });
753    if sibling.is_file() {
754        Ok(sibling)
755    } else {
756        Err(WinxError::ConfigurationError(format!(
757            "winx-guardian not found beside {} (set WINX_GUARDIAN_BIN)",
758            executable.display()
759        )))
760    }
761}
762
763fn rpc_result<T: serde::Serialize>(id: u64, result: &T) -> RpcResponse {
764    match serde_json::to_value(result) {
765        Ok(result) => {
766            RpcResponse { jsonrpc: "2.0".to_string(), id, result: Some(result), error: None }
767        }
768        Err(error) => rpc_error(id, -32603, &error.to_string()),
769    }
770}
771
772fn rpc_error(id: u64, code: i32, message: &str) -> RpcResponse {
773    RpcResponse {
774        jsonrpc: "2.0".to_string(),
775        id,
776        result: None,
777        error: Some(RpcError { code, message: message.to_string() }),
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    #![allow(clippy::expect_used)]
784
785    use super::*;
786    use crate::runtime::ShellActionOptions;
787    use crate::state::bash_state::BashState;
788    use crate::types::BashCommand;
789    #[cfg(unix)]
790    use tokio::net::UnixListener;
791
792    fn guardian_1_4() -> HelloResult {
793        HelloResult {
794            protocol_major: PROTOCOL_MAJOR,
795            protocol_minor: 4,
796            capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
797            max_frame_bytes: crate::daemon::protocol::MAX_FRAME_BYTES,
798            daemon_epoch: "guardian-14".to_string(),
799            daemon_pid: 14,
800            process_role: None,
801            build: None,
802        }
803    }
804
805    #[test]
806    fn guardian_1_4_normalization_preserves_adapter_wait_policy_semantics() {
807        let command: BashCommand = serde_json::from_value(serde_json::json!({
808            "command": "printf compatible",
809            "wait_policy": "until_complete",
810            "thread_id": "guardian14"
811        }))
812        .expect("adapter command");
813        let mut request = RpcRequest {
814            jsonrpc: "2.0".to_string(),
815            id: 14,
816            method: "shell.run_action".to_string(),
817            params: serde_json::to_value(RunActionParams {
818                snapshot: BashState::new().snapshot(),
819                command,
820                request_key: "request14".to_string(),
821                consumer_id: "consumer14".to_string(),
822                options: ShellActionOptions {
823                    compact_output: true,
824                    ..ShellActionOptions::default()
825                },
826            })
827            .expect("run params"),
828        };
829
830        normalize_guardian_request(&mut request, &guardian_1_4()).expect("normalization");
831        let wire = request.params.as_object().expect("params object");
832        assert!(wire.get("options").is_none());
833        assert!(wire["command"].get("wait_policy").is_none());
834    }
835
836    #[test]
837    fn guardian_1_4_rejects_generation_bound_action_before_relay() {
838        let command: BashCommand = serde_json::from_value(serde_json::json!({
839            "command": "printf compatible",
840            "thread_id": "guardian14"
841        }))
842        .expect("adapter command");
843        let mut request = RpcRequest {
844            jsonrpc: "2.0".to_string(),
845            id: 15,
846            method: "shell.run_action".to_string(),
847            params: serde_json::to_value(RunActionParams {
848                snapshot: BashState::new().snapshot(),
849                command,
850                request_key: "request15".to_string(),
851                consumer_id: "consumer15".to_string(),
852                options: ShellActionOptions {
853                    expected_generation: Some(7),
854                    ..ShellActionOptions::default()
855                },
856            })
857            .expect("run params"),
858        };
859        assert!(matches!(
860            normalize_guardian_request(&mut request, &guardian_1_4()),
861            Err(WinxError::InvalidInput(_))
862        ));
863    }
864
865    #[cfg(unix)] // mocks a guardian with a raw UnixListener
866    #[tokio::test]
867    async fn stale_1_5_precondition_sends_zero_action_frames_to_1_4_guardian() {
868        let temp = tempfile::tempdir().expect("temporary directory");
869        let socket = temp.path().join("guardian.sock");
870        let listener = UnixListener::bind(&socket).expect("mock guardian");
871        let guardian = tokio::spawn(async move {
872            let (mut stream, _) = listener.accept().await.expect("control connection");
873            let hello: RpcRequest = read_json_frame(&mut stream).await.expect("hello frame");
874            assert_eq!(hello.method, "winx.hello");
875            write_json_frame(&mut stream, &rpc_result(hello.id, &guardian_1_4()))
876                .await
877                .expect("hello response");
878            tokio::time::timeout(
879                Duration::from_millis(100),
880                read_json_frame::<_, RpcRequest>(&mut stream),
881            )
882            .await
883            .is_ok_and(|frame| frame.is_ok())
884        });
885        let command: BashCommand = serde_json::from_value(serde_json::json!({
886            "command": "touch must-not-run",
887            "thread_id": "stale-guardian"
888        }))
889        .expect("command");
890        let request = RpcRequest {
891            jsonrpc: "2.0".to_string(),
892            id: 16,
893            method: "shell.run_action".to_string(),
894            params: serde_json::to_value(RunActionParams {
895                snapshot: BashState::new().snapshot(),
896                command,
897                request_key: "stale-request".to_string(),
898                consumer_id: "stale-consumer".to_string(),
899                options: ShellActionOptions {
900                    require_generation_binding: true,
901                    expected_guardian_epoch: Some("guardian-15".to_string()),
902                    ..ShellActionOptions::default()
903                },
904            })
905            .expect("params"),
906        };
907
908        assert!(relay(&socket, request).await.is_err());
909        assert!(!guardian.await.expect("guardian task"));
910    }
911
912    #[tokio::test]
913    async fn guardian_negotiation_cache_is_bounded_under_churn() {
914        let cache = Mutex::new(HashMap::new());
915        for index in 0..(MAX_GUARDIAN_NEGOTIATIONS + 50) {
916            let mut hello = guardian_1_4();
917            hello.daemon_epoch = format!("epoch-{index}");
918            cache_guardian(&cache, PathBuf::from(format!("guardian-{index}.sock")), hello).await;
919        }
920        assert_eq!(cache.lock().await.len(), MAX_GUARDIAN_NEGOTIATIONS);
921    }
922
923    #[tokio::test]
924    async fn concurrent_cold_guardian_negotiation_uses_one_gate() {
925        let gates = Mutex::new(HashMap::new());
926        let socket = PathBuf::from("same-guardian.sock");
927        let (first, second) = tokio::join!(
928            guardian_negotiation_gate(&gates, &socket),
929            guardian_negotiation_gate(&gates, &socket)
930        );
931        assert!(Arc::ptr_eq(
932            &first.expect("first negotiation gate"),
933            &second.expect("concurrent negotiation gate")
934        ));
935        assert_eq!(gates.lock().await.len(), 1);
936    }
937
938    #[tokio::test]
939    async fn guardian_negotiation_gates_apply_backpressure_without_evicting_active_slots() {
940        let gates = Mutex::new(HashMap::new());
941        let mut active = Vec::with_capacity(MAX_GUARDIAN_NEGOTIATIONS);
942        for index in 0..MAX_GUARDIAN_NEGOTIATIONS {
943            active.push(
944                guardian_negotiation_gate(
945                    &gates,
946                    &PathBuf::from(format!("active-guardian-{index}.sock")),
947                )
948                .await
949                .expect("active gate"),
950            );
951        }
952
953        let overflow =
954            guardian_negotiation_gate(&gates, &PathBuf::from("overflow-guardian.sock")).await;
955        assert!(matches!(overflow, Err(WinxError::ResourceAllocationError { .. })));
956        assert_eq!(gates.lock().await.len(), MAX_GUARDIAN_NEGOTIATIONS);
957
958        drop(active.pop());
959        guardian_negotiation_gate(&gates, &PathBuf::from("replacement-guardian.sock"))
960            .await
961            .expect("inactive slot can be replaced");
962        assert_eq!(gates.lock().await.len(), MAX_GUARDIAN_NEGOTIATIONS);
963    }
964
965    #[cfg(unix)] // mocks a guardian with a raw UnixListener
966    #[tokio::test]
967    async fn recreated_guardian_socket_invalidates_negotiation_cache() {
968        let temp = tempfile::tempdir().expect("temporary directory");
969        let socket = temp.path().join("guardian.sock");
970        let first_listener = UnixListener::bind(&socket).expect("first guardian socket");
971        let cache = Mutex::new(HashMap::new());
972        let hello = guardian_1_4();
973        cache_guardian(&cache, socket.clone(), hello.clone()).await;
974
975        assert_eq!(
976            cached_guardian(&cache, &socket).await.expect("cached first guardian").daemon_epoch,
977            hello.daemon_epoch
978        );
979
980        drop(first_listener);
981        tokio::fs::remove_file(&socket).await.expect("remove first socket");
982        let _second_listener = UnixListener::bind(&socket).expect("recreated guardian socket");
983
984        assert!(cached_guardian(&cache, &socket).await.is_none());
985        assert!(cache.lock().await.is_empty());
986    }
987}