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