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
20pub 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 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 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
492async 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)] #[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)] #[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}