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 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 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 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
501async 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)] #[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)] #[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}