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