1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use crate::daemon::transport::DaemonStream;
8use tokio::sync::Mutex;
9
10use super::protocol::CONDITIONAL_SESSION_KILL_CAPABILITY;
11use super::protocol::{
12 read_json_frame, write_json_frame, CancelActionParams, ConfigureSessionParams,
13 ConfigureSessionResult, ConfigureSessionTransition, HelloResult, JournalRead,
14 JournalReadParams, PruneParams, PruneResult, RpcRequest, RpcResponse, RunActionParams,
15 RunActionResult, SessionInfo, SessionParams, WireShellError, ATTACH_OR_CREATE_CAPABILITY,
16 CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY, COMPACT_ACTION_OUTPUT_CAPABILITY,
17 GENERATION_BOUND_ACTIONS_CAPABILITY, PROTOCOL_MAJOR, TYPED_ACTION_RESULT_CAPABILITY,
18};
19use crate::errors::{Result, WinxError};
20use crate::runtime::{
21 BashCommandRuntimeResult, ShellActionOptions, ShellExecutionToken, ShellRuntime,
22 ShellRuntimeBoolFuture, ShellRuntimeConfigureFuture, ShellRuntimeDetailedFuture,
23 ShellRuntimeFuture, ShellRuntimeStatusFuture, ShellRuntimeUnitFuture,
24 ShellSessionConfiguration, ShellSessionStatus, ShellSessionTransition,
25};
26use crate::state::bash_state::BashState;
27use crate::tools::bash_command::BashCommandResult;
28use crate::types::BashCommand;
29
30#[derive(Clone, Debug)]
32pub struct DaemonShellRuntime {
33 socket_path: PathBuf,
34 consumer_id: String,
35 negotiations: Arc<Mutex<NegotiationCache>>,
36}
37
38#[derive(Debug, Default)]
39struct NegotiationCache {
40 sessions: HashMap<String, CachedNegotiation>,
41 gates: HashMap<String, NegotiationGate>,
42 local_revisions: HashMap<String, LocalRevision>,
43}
44
45#[derive(Debug)]
46struct CachedNegotiation {
47 session: Arc<Mutex<NegotiatedSession>>,
48 last_seen: Instant,
49}
50
51#[derive(Debug)]
52struct NegotiationGate {
53 gate: Arc<Mutex<()>>,
54 last_seen: Instant,
55}
56
57#[derive(Debug)]
58struct LocalRevision {
59 revision: Arc<AtomicU64>,
60 last_seen: Instant,
61}
62
63const MAX_NEGOTIATED_SESSIONS: usize = 64;
64const NEGOTIATION_TTL: Duration = Duration::from_secs(5 * 60);
65const DAEMON_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
66const SESSION_CONTROL_RPC_TIMEOUT: Duration = Duration::from_secs(10);
67
68#[derive(Debug)]
69struct NegotiatedSession {
70 control_epoch: String,
71 guardian: HelloResult,
72 health_stream: DaemonStream,
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78enum RemoteSessionTransition {
79 FirstCall,
80 Attach,
81 ModeChange,
82 Reset,
83 WorkspaceChange,
84}
85
86impl From<ShellSessionTransition> for RemoteSessionTransition {
87 fn from(transition: ShellSessionTransition) -> Self {
88 match transition {
89 ShellSessionTransition::FirstCall => Self::FirstCall,
90 ShellSessionTransition::ModeChange => Self::ModeChange,
91 ShellSessionTransition::Reset => Self::Reset,
92 ShellSessionTransition::WorkspaceChange => Self::WorkspaceChange,
93 }
94 }
95}
96
97#[derive(Clone, Debug)]
99pub struct DaemonClient {
100 socket_path: PathBuf,
101}
102
103impl DaemonClient {
104 pub fn new(socket_path: impl AsRef<Path>) -> Self {
105 Self { socket_path: socket_path.as_ref().to_path_buf() }
106 }
107
108 async fn connected_with_hello(&self) -> Result<(DaemonStream, HelloResult)> {
109 let mut stream = self.connect_raw().await?;
110 let hello: HelloResult = tokio::time::timeout(
111 SESSION_CONTROL_RPC_TIMEOUT,
112 DaemonShellRuntime::request(
113 &mut stream,
114 rand::random::<u64>(),
115 "winx.hello",
116 serde_json::json!({ "protocol_major": PROTOCOL_MAJOR }),
117 ),
118 )
119 .await
120 .map_err(|_| WinxError::CommandTimeout {
121 command: "winxd handshake".to_string(),
122 timeout_seconds: SESSION_CONTROL_RPC_TIMEOUT.as_secs(),
123 })??;
124 if hello.protocol_major != PROTOCOL_MAJOR {
125 return Err(WinxError::ConfigurationError(format!(
126 "winxd protocol major {} is incompatible with client {}",
127 hello.protocol_major, PROTOCOL_MAJOR
128 )));
129 }
130 Ok((stream, hello))
131 }
132
133 async fn connect_raw(&self) -> Result<DaemonStream> {
134 tokio::time::timeout(
135 DAEMON_CONNECT_TIMEOUT,
136 crate::daemon::transport::connect(&self.socket_path),
137 )
138 .await
139 .map_err(|_| WinxError::CommandTimeout {
140 command: format!("connect to winxd at {}", self.socket_path.display()),
141 timeout_seconds: DAEMON_CONNECT_TIMEOUT.as_secs(),
142 })?
143 .map_err(|error| {
144 WinxError::ShellInitializationError(format!(
145 "cannot connect to winxd at {}: {error}",
146 self.socket_path.display()
147 ))
148 })
149 }
150
151 async fn connected(&self) -> Result<DaemonStream> {
152 self.connected_with_hello().await.map(|(stream, _)| stream)
153 }
154
155 pub async fn hello(&self) -> Result<HelloResult> {
156 self.connected_with_hello().await.map(|(_, hello)| hello)
157 }
158
159 async fn bounded<T>(
164 &self,
165 what: &str,
166 future: impl std::future::Future<Output = Result<T>>,
167 ) -> Result<T> {
168 match tokio::time::timeout(SESSION_CONTROL_RPC_TIMEOUT, future).await {
169 Ok(result) => result,
170 Err(_) => Err(WinxError::CommandTimeout {
171 command: format!("winxd {what} RPC at {}", self.socket_path.display()),
172 timeout_seconds: SESSION_CONTROL_RPC_TIMEOUT.as_secs(),
173 }),
174 }
175 }
176
177 pub async fn list_sessions(&self) -> Result<Vec<SessionInfo>> {
178 self.bounded("session.list", self.list_sessions_unbounded()).await
179 }
180
181 async fn list_sessions_unbounded(&self) -> Result<Vec<SessionInfo>> {
182 let mut stream = self.connected().await?;
183 DaemonShellRuntime::request(
184 &mut stream,
185 rand::random::<u64>(),
186 "session.list",
187 serde_json::json!({}),
188 )
189 .await
190 }
191
192 pub async fn session_info(&self, thread_id: &str) -> Result<SessionInfo> {
193 self.bounded("session.info", self.session_info_unbounded(thread_id)).await
194 }
195
196 async fn session_info_unbounded(&self, thread_id: &str) -> Result<SessionInfo> {
197 let mut stream = self.connected().await?;
198 DaemonShellRuntime::request(
199 &mut stream,
200 rand::random::<u64>(),
201 "session.info",
202 serde_json::to_value(SessionParams {
203 only_if_idle: false,
204 thread_id: thread_id.to_string(),
205 expected_generation: None,
206 expected_execution: None,
207 expected_guardian_epoch: None,
208 })
209 .map_err(|error| WinxError::SerializationError(error.to_string()))?,
210 )
211 .await
212 }
213
214 pub async fn read_output(&self, thread_id: &str, consumer_id: &str) -> Result<JournalRead> {
215 self.bounded("session.read_output", self.read_output_unbounded(thread_id, consumer_id))
216 .await
217 }
218
219 async fn read_output_unbounded(
220 &self,
221 thread_id: &str,
222 consumer_id: &str,
223 ) -> Result<JournalRead> {
224 let mut stream = self.connected().await?;
225 DaemonShellRuntime::request(
226 &mut stream,
227 rand::random::<u64>(),
228 "session.read_output",
229 serde_json::to_value(JournalReadParams {
230 thread_id: thread_id.to_string(),
231 consumer_id: consumer_id.to_string(),
232 })
233 .map_err(|error| WinxError::SerializationError(error.to_string()))?,
234 )
235 .await
236 }
237
238 pub async fn shutdown(&self) -> Result<()> {
241 self.bounded("winx.shutdown", self.shutdown_unbounded()).await
242 }
243
244 async fn shutdown_unbounded(&self) -> Result<()> {
245 let mut stream = self.connected().await?;
246 let _: serde_json::Value = DaemonShellRuntime::request(
247 &mut stream,
248 rand::random::<u64>(),
249 "winx.shutdown",
250 serde_json::json!({}),
251 )
252 .await?;
253 Ok(())
254 }
255
256 pub async fn kill_session_if_idle(&self, thread_id: &str) -> Result<bool> {
264 let (mut stream, hello) = self.connected_with_hello().await?;
265 let conditional = hello
266 .capabilities
267 .iter()
268 .any(|capability| capability == CONDITIONAL_SESSION_KILL_CAPABILITY);
269 if !conditional {
270 let info = self.session_info(thread_id).await?;
271 if info.running || !info.background_command_ids.is_empty() {
272 return Ok(false);
273 }
274 }
275 self.bounded(
276 "session.kill",
277 DaemonShellRuntime::request(
278 &mut stream,
279 rand::random::<u64>(),
280 "session.kill",
281 serde_json::json!({ "thread_id": thread_id, "only_if_idle": conditional }),
282 ),
283 )
284 .await
285 }
286
287 pub async fn kill_session(&self, thread_id: &str) -> Result<bool> {
288 self.bounded("session.kill", self.kill_session_unbounded(thread_id)).await
289 }
290
291 async fn kill_session_unbounded(&self, thread_id: &str) -> Result<bool> {
292 let mut stream = self.connected().await?;
293 DaemonShellRuntime::request(
294 &mut stream,
295 rand::random::<u64>(),
296 "session.kill",
297 serde_json::to_value(SessionParams {
298 only_if_idle: false,
299 thread_id: thread_id.to_string(),
300 expected_generation: None,
301 expected_execution: None,
302 expected_guardian_epoch: None,
303 })
304 .map_err(|error| WinxError::SerializationError(error.to_string()))?,
305 )
306 .await
307 }
308
309 pub async fn prune_sessions(&self, idle_seconds: Option<u64>) -> Result<PruneResult> {
310 let mut stream = self.connected().await?;
311 DaemonShellRuntime::request(
312 &mut stream,
313 rand::random::<u64>(),
314 "session.prune",
315 serde_json::to_value(PruneParams { idle_seconds })
316 .map_err(|error| WinxError::SerializationError(error.to_string()))?,
317 )
318 .await
319 }
320
321 pub async fn interrupt_session(&self, thread_id: &str) -> Result<()> {
322 let _ = self.interrupt_session_generation(thread_id, None).await?;
323 Ok(())
324 }
325
326 pub async fn interrupt_session_generation(
327 &self,
328 thread_id: &str,
329 expected_generation: Option<u64>,
330 ) -> Result<bool> {
331 self.interrupt_session_execution(
332 thread_id,
333 expected_generation.map(|generation| ShellExecutionToken {
334 guardian_epoch: String::new(),
335 session_epoch: String::new(),
336 generation,
337 }),
338 )
339 .await
340 }
341
342 async fn interrupt_session_execution(
343 &self,
344 thread_id: &str,
345 expected_execution: Option<ShellExecutionToken>,
346 ) -> Result<bool> {
347 let (mut stream, _) = self.connected_with_hello().await?;
350 let hello: HelloResult = DaemonShellRuntime::request(
351 &mut stream,
352 rand::random::<u64>(),
353 "session.negotiate",
354 serde_json::to_value(SessionParams {
355 only_if_idle: false,
356 thread_id: thread_id.to_string(),
357 expected_generation: None,
358 expected_execution: None,
359 expected_guardian_epoch: None,
360 })
361 .map_err(|error| WinxError::SerializationError(error.to_string()))?,
362 )
363 .await?;
364 if expected_execution.is_some()
365 && !hello
366 .capabilities
367 .iter()
368 .any(|capability| capability == GENERATION_BOUND_ACTIONS_CAPABILITY)
369 {
370 return Ok(false);
371 }
372 if let Some(expected) = expected_execution.as_ref() {
373 if !expected.guardian_epoch.is_empty() && expected.guardian_epoch != hello.daemon_epoch
374 {
375 return Ok(false);
376 }
377 }
378 let expected_generation = expected_execution.as_ref().map(|token| token.generation);
379 DaemonShellRuntime::request(
380 &mut stream,
381 rand::random::<u64>(),
382 "session.interrupt",
383 serde_json::to_value(SessionParams {
384 only_if_idle: false,
385 thread_id: thread_id.to_string(),
386 expected_generation,
387 expected_execution: expected_execution.filter(|token| {
388 !token.guardian_epoch.is_empty() && !token.session_epoch.is_empty()
389 }),
390 expected_guardian_epoch: Some(hello.daemon_epoch),
391 })
392 .map_err(|error| WinxError::SerializationError(error.to_string()))?,
393 )
394 .await
395 }
396
397 async fn cancel_pending_action(&self, thread_id: &str, cancellation_key: &str) -> Result<bool> {
398 let (mut stream, _) = self.connected_with_hello().await?;
401 let hello: HelloResult = DaemonShellRuntime::request(
402 &mut stream,
403 rand::random::<u64>(),
404 "session.negotiate",
405 serde_json::to_value(SessionParams {
406 only_if_idle: false,
407 thread_id: thread_id.to_string(),
408 expected_generation: None,
409 expected_execution: None,
410 expected_guardian_epoch: None,
411 })
412 .map_err(|error| WinxError::SerializationError(error.to_string()))?,
413 )
414 .await?;
415 if !hello
416 .capabilities
417 .iter()
418 .any(|capability| capability == CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY)
419 {
420 return Ok(false);
421 }
422 DaemonShellRuntime::request(
423 &mut stream,
424 rand::random::<u64>(),
425 "shell.cancel_action",
426 serde_json::to_value(CancelActionParams {
427 thread_id: thread_id.to_string(),
428 cancellation_key: cancellation_key.to_string(),
429 expected_guardian_epoch: Some(hello.daemon_epoch),
430 })
431 .map_err(|error| WinxError::SerializationError(error.to_string()))?,
432 )
433 .await
434 }
435}
436
437impl DaemonShellRuntime {
438 pub fn new(socket_path: impl AsRef<Path>) -> Self {
439 Self::with_consumer_id(socket_path, format!("adapter-{:016x}", rand::random::<u64>()))
440 }
441
442 pub fn with_consumer_id(socket_path: impl AsRef<Path>, consumer_id: impl Into<String>) -> Self {
443 Self {
444 socket_path: socket_path.as_ref().to_path_buf(),
445 consumer_id: consumer_id.into(),
446 negotiations: Arc::new(Mutex::new(NegotiationCache::default())),
447 }
448 }
449
450 pub fn socket_path(&self) -> &Path {
451 &self.socket_path
452 }
453
454 async fn negotiated_session(&self, thread_id: &str) -> Result<Arc<Mutex<NegotiatedSession>>> {
455 {
456 let now = Instant::now();
457 let mut cache = self.negotiations.lock().await;
458 prune_negotiations(&mut cache, now);
459 if let Some(entry) = cache.sessions.get_mut(thread_id) {
460 entry.last_seen = now;
461 return Ok(Arc::clone(&entry.session));
462 }
463 }
464
465 let negotiation_gate = self.negotiation_gate(thread_id).await?;
466 let _single_flight = negotiation_gate.lock().await;
467 {
468 let now = Instant::now();
469 let mut cache = self.negotiations.lock().await;
470 prune_negotiations(&mut cache, now);
471 if let Some(entry) = cache.sessions.get_mut(thread_id) {
472 entry.last_seen = now;
473 return Ok(Arc::clone(&entry.session));
474 }
475 }
476
477 let client = DaemonClient::new(&self.socket_path);
480 let (mut stream, control) = client.connected_with_hello().await?;
481 let hello: HelloResult = tokio::time::timeout(
482 SESSION_CONTROL_RPC_TIMEOUT,
483 Self::request(
484 &mut stream,
485 rand::random::<u64>(),
486 "session.negotiate",
487 serde_json::to_value(SessionParams {
488 only_if_idle: false,
489 thread_id: thread_id.to_string(),
490 expected_generation: None,
491 expected_execution: None,
492 expected_guardian_epoch: None,
493 })
494 .map_err(|error| WinxError::SerializationError(error.to_string()))?,
495 ),
496 )
497 .await
498 .map_err(|_| WinxError::CommandTimeout {
499 command: "negotiate a guardian session".to_string(),
500 timeout_seconds: SESSION_CONTROL_RPC_TIMEOUT.as_secs(),
501 })??;
502 if hello.protocol_major != PROTOCOL_MAJOR {
503 return Err(WinxError::ConfigurationError(format!(
504 "guardian protocol major {} is incompatible with adapter {}",
505 hello.protocol_major, PROTOCOL_MAJOR
506 )));
507 }
508 let session = Arc::new(Mutex::new(NegotiatedSession {
509 control_epoch: control.daemon_epoch,
510 guardian: hello,
511 health_stream: stream,
512 }));
513 let now = Instant::now();
514 let mut cache = self.negotiations.lock().await;
515 prune_negotiations(&mut cache, now);
516 if let Some(existing) = cache.sessions.get_mut(thread_id) {
517 existing.last_seen = now;
518 return Ok(Arc::clone(&existing.session));
519 }
520 cache_negotiation(&mut cache, thread_id, Arc::clone(&session), now)?;
521 Ok(session)
522 }
523
524 async fn local_revision(&self, thread_id: &str) -> Result<Arc<AtomicU64>> {
525 let now = Instant::now();
526 let mut cache = self.negotiations.lock().await;
527 prune_negotiations(&mut cache, now);
528 if let Some(entry) = cache.local_revisions.get_mut(thread_id) {
529 entry.last_seen = now;
530 return Ok(Arc::clone(&entry.revision));
531 }
532 if cache.local_revisions.len() >= MAX_NEGOTIATED_SESSIONS {
533 let oldest = cache
534 .local_revisions
535 .iter()
536 .filter(|(_, entry)| Arc::strong_count(&entry.revision) == 1)
537 .min_by_key(|(_, entry)| entry.last_seen)
538 .map(|(thread_id, _)| thread_id.clone());
539 if let Some(oldest) = oldest {
540 cache.local_revisions.remove(&oldest);
541 }
542 }
543 if cache.local_revisions.len() >= MAX_NEGOTIATED_SESSIONS {
544 return Err(WinxError::ResourceAllocationError {
545 message: format!(
546 "all {MAX_NEGOTIATED_SESSIONS} daemon local session revisions are active"
547 ),
548 });
549 }
550 let revision = Arc::new(AtomicU64::new(0));
551 cache.local_revisions.insert(
552 thread_id.to_string(),
553 LocalRevision { revision: Arc::clone(&revision), last_seen: now },
554 );
555 Ok(revision)
556 }
557
558 async fn local_revision_is_current(
559 &self,
560 thread_id: &str,
561 expected: &Arc<AtomicU64>,
562 value: u64,
563 ) -> bool {
564 let mut cache = self.negotiations.lock().await;
565 let Some(current) = cache.local_revisions.get_mut(thread_id) else { return false };
566 current.last_seen = Instant::now();
567 Arc::ptr_eq(¤t.revision, expected) && current.revision.load(Ordering::SeqCst) == value
568 }
569
570 async fn apply_snapshot_if_current(
571 &self,
572 bash_state: &Arc<Mutex<Option<BashState>>>,
573 thread_id: &str,
574 expected_revision: &Arc<AtomicU64>,
575 revision_value: u64,
576 snapshot: &crate::state::persistence::BashStateSnapshot,
577 ) -> bool {
578 let mut state = bash_state.lock().await;
579 let Some(state) = state.as_mut() else { return false };
580 if !self.local_revision_is_current(thread_id, expected_revision, revision_value).await {
581 return false;
582 }
583 state.apply_snapshot(snapshot);
584 true
585 }
586
587 async fn negotiation_gate(&self, thread_id: &str) -> Result<Arc<Mutex<()>>> {
588 let now = Instant::now();
589 let mut cache = self.negotiations.lock().await;
590 prune_negotiations(&mut cache, now);
591 if let Some(entry) = cache.gates.get_mut(thread_id) {
592 entry.last_seen = now;
593 return Ok(Arc::clone(&entry.gate));
594 }
595 if cache.gates.len() >= MAX_NEGOTIATED_SESSIONS {
596 let oldest = cache
597 .gates
598 .iter()
599 .filter(|(_, entry)| Arc::strong_count(&entry.gate) == 1)
600 .min_by_key(|(_, entry)| entry.last_seen)
601 .map(|(key, _)| key.clone());
602 if let Some(oldest) = oldest {
603 cache.gates.remove(&oldest);
604 }
605 }
606 if cache.gates.len() >= MAX_NEGOTIATED_SESSIONS {
607 return Err(WinxError::ResourceAllocationError {
608 message: format!(
609 "all {MAX_NEGOTIATED_SESSIONS} daemon negotiation slots are active"
610 ),
611 });
612 }
613 let gate = Arc::new(Mutex::new(()));
614 cache.gates.insert(
615 thread_id.to_string(),
616 NegotiationGate { gate: Arc::clone(&gate), last_seen: now },
617 );
618 Ok(gate)
619 }
620
621 async fn live_negotiated_session(
622 &self,
623 thread_id: &str,
624 ) -> Result<Arc<Mutex<NegotiatedSession>>> {
625 loop {
626 let session = self.negotiated_session(thread_id).await?;
627 let (alive, control_epoch) = {
628 let session_guard = session.lock().await;
629 let mut probe = [0_u8; 1];
630 match session_guard.health_stream.try_read(&mut probe) {
631 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
632 (true, session_guard.control_epoch.clone())
633 }
634 Ok(_) | Err(_) => (false, session_guard.control_epoch.clone()),
635 }
636 };
637 if alive {
638 return Ok(session);
639 }
640 tracing::debug!(%control_epoch, %thread_id, "invalidating closed winxd negotiation");
641 self.invalidate_negotiation(thread_id, &session).await;
642 }
643 }
644
645 async fn negotiated_guardian(&self, thread_id: &str) -> Result<HelloResult> {
646 let session = self.live_negotiated_session(thread_id).await?;
647 let hello = session.lock().await.guardian.clone();
648 Ok(hello)
649 }
650
651 async fn invalidate_negotiation(
652 &self,
653 thread_id: &str,
654 expected: &Arc<Mutex<NegotiatedSession>>,
655 ) {
656 let mut cache = self.negotiations.lock().await;
657 if cache
658 .sessions
659 .get(thread_id)
660 .is_some_and(|current| Arc::ptr_eq(¤t.session, expected))
661 {
662 cache.sessions.remove(thread_id);
663 }
664 }
665
666 async fn negotiated_request<T: serde::de::DeserializeOwned>(
667 &self,
668 id: u64,
669 method: &str,
670 params: serde_json::Value,
671 ) -> Result<T> {
672 match tokio::time::timeout(
673 SESSION_CONTROL_RPC_TIMEOUT,
674 self.isolated_request(id, method, params),
675 )
676 .await
677 {
678 Ok(result) => result,
679 Err(_) => Err(WinxError::CommandTimeout {
680 command: format!("winxd {method} RPC"),
681 timeout_seconds: SESSION_CONTROL_RPC_TIMEOUT.as_secs(),
682 }),
683 }
684 }
685
686 async fn negotiated_action_request<T: serde::de::DeserializeOwned>(
687 &self,
688 id: u64,
689 params: serde_json::Value,
690 options: &ShellActionOptions,
691 ) -> Result<T> {
692 if options.is_launch_cancelled() {
693 return Err(WinxError::CommandExecutionError(
694 "task was cancelled before the shell action launch gate".to_string(),
695 ));
696 }
697 self.isolated_request(id, "shell.run_action", params).await
698 }
699
700 async fn isolated_request<T: serde::de::DeserializeOwned>(
704 &self,
705 id: u64,
706 method: &str,
707 params: serde_json::Value,
708 ) -> Result<T> {
709 let mut stream = DaemonClient::new(&self.socket_path).connect_raw().await?;
710 Self::request(&mut stream, id, method, params).await
711 }
712
713 async fn request<T: serde::de::DeserializeOwned>(
714 stream: &mut DaemonStream,
715 id: u64,
716 method: &str,
717 params: serde_json::Value,
718 ) -> Result<T> {
719 write_json_frame(
720 stream,
721 &RpcRequest { jsonrpc: "2.0".to_string(), id, method: method.to_string(), params },
722 )
723 .await?;
724 let response: RpcResponse = read_json_frame(stream).await?;
725 if response.id != id || response.jsonrpc != "2.0" {
726 return Err(WinxError::ParseError("mismatched JSON-RPC response".to_string()));
727 }
728 if let Some(error) = response.error {
729 return Err(WinxError::CommandExecutionError(format!(
730 "winxd JSON-RPC error {}: {}",
731 error.code, error.message
732 )));
733 }
734 serde_json::from_value(
735 response.result.ok_or_else(|| {
736 WinxError::ParseError("JSON-RPC response omitted result".to_string())
737 })?,
738 )
739 .map_err(|error| WinxError::DeserializationError(error.to_string()))
740 }
741
742 async fn run_remote_detailed(
743 &self,
744 bash_state: &std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
745 command: BashCommand,
746 requested_options: ShellActionOptions,
747 ) -> Result<BashCommandRuntimeResult> {
748 let thread_id = command.thread_id.clone();
749 let (snapshot, local_revision, captured_revision) = {
754 let state = bash_state.lock().await;
755 let state = state.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
756 let revision = self.local_revision(&thread_id).await?;
757 let value = revision.load(Ordering::SeqCst);
758 (state.snapshot(), revision, value)
759 };
760 let mut session = self.live_negotiated_session(&thread_id).await?;
761 let hello = session.lock().await.guardian.clone();
762 let (options, mut generation_bound_actions) =
763 action_options_for_guardian(&hello, requested_options.clone())?;
764 let request_key = format!("{:016x}", rand::random::<u64>());
765 let id = rand::random::<u64>();
766 let encode_params = |options| {
767 serde_json::to_value(RunActionParams {
768 snapshot: snapshot.clone(),
769 command: command.clone(),
770 request_key: request_key.clone(),
771 consumer_id: self.consumer_id.clone(),
772 options,
773 })
774 .map_err(|error| WinxError::SerializationError(error.to_string()))
775 };
776 let params = encode_params(options.clone())?;
777 let result: RunActionResult = match self
778 .negotiated_action_request(id, params, &options)
779 .await
780 {
781 Ok(result) => result,
782 Err(first_error) => {
783 self.invalidate_negotiation(&thread_id, &session).await;
788 session = self.live_negotiated_session(&thread_id).await?;
789 let retry_hello = session.lock().await.guardian.clone();
790 if retry_hello.daemon_epoch != hello.daemon_epoch {
791 return Err(WinxError::CommandExecutionError(format!(
792 "{first_error}; guardian epoch changed from {} to {}, so Winx will not retry an ambiguously delivered shell action",
793 hello.daemon_epoch, retry_hello.daemon_epoch
794 )));
795 }
796 let (retry_options, retry_generation_bound) =
797 action_options_for_guardian(&retry_hello, requested_options.clone())?;
798 generation_bound_actions = retry_generation_bound;
799 self.negotiated_action_request(
800 id,
801 encode_params(retry_options)?,
802 &requested_options,
803 )
804 .await
805 .map_err(|retry_error| {
806 WinxError::CommandExecutionError(format!(
807 "{first_error}; retry after guardian negotiation failed: {retry_error}"
808 ))
809 })?
810 }
811 };
812
813 self.apply_snapshot_if_current(
814 bash_state,
815 &thread_id,
816 &local_revision,
817 captured_revision,
818 &result.snapshot,
819 )
820 .await;
821 runtime_result_from_wire(result, generation_bound_actions)
822 }
823
824 async fn configure_remote(
825 &self,
826 bash_state: &mut BashState,
827 transition: RemoteSessionTransition,
828 ) -> Result<ShellSessionConfiguration> {
829 let thread_id = bash_state.current_thread_id.clone();
830 self.local_revision(&thread_id).await?.fetch_add(1, Ordering::SeqCst);
834 let snapshot = bash_state.snapshot();
835 let encode_params = |wire_transition| {
836 serde_json::to_value(ConfigureSessionParams {
837 snapshot: snapshot.clone(),
838 transition: wire_transition,
839 })
840 .map_err(|error| WinxError::SerializationError(error.to_string()))
841 };
842 let mut session = self.live_negotiated_session(&thread_id).await?;
843 let hello = session.lock().await.guardian.clone();
844 let wire_transition = configure_transition_for_guardian(transition, &hello);
845 let result: ConfigureSessionResult = match self
846 .negotiated_request(
847 rand::random::<u64>(),
848 "session.configure",
849 encode_params(wire_transition)?,
850 )
851 .await
852 {
853 Ok(result) => result,
854 Err(first_error)
855 if matches!(
856 transition,
857 RemoteSessionTransition::FirstCall | RemoteSessionTransition::Attach
858 ) =>
859 {
860 self.invalidate_negotiation(&thread_id, &session).await;
863 session = self.live_negotiated_session(&thread_id).await?;
864 let retry_hello = session.lock().await.guardian.clone();
865 let retry_transition = configure_transition_for_guardian(transition, &retry_hello);
866 self.negotiated_request(
867 rand::random::<u64>(),
868 "session.configure",
869 encode_params(retry_transition)?,
870 )
871 .await
872 .map_err(|retry_error| {
873 WinxError::CommandExecutionError(format!(
874 "{first_error}; retry after guardian negotiation failed: {retry_error}"
875 ))
876 })?
877 }
878 Err(error) => {
879 self.invalidate_negotiation(&thread_id, &session).await;
880 return Err(error);
881 }
882 };
883 if let Some(snapshot) = result.snapshot {
884 bash_state.apply_snapshot(&snapshot);
885 }
886 Ok(ShellSessionConfiguration {
887 attach_hint: result.attach_hint,
888 attached_existing: result.attached_existing,
889 })
890 }
891}
892
893fn configure_transition_for_guardian(
894 transition: RemoteSessionTransition,
895 guardian: &HelloResult,
896) -> ConfigureSessionTransition {
897 match transition {
898 RemoteSessionTransition::FirstCall => ConfigureSessionTransition::FirstCall,
899 RemoteSessionTransition::Attach
900 if guardian
901 .capabilities
902 .iter()
903 .any(|capability| capability == ATTACH_OR_CREATE_CAPABILITY) =>
904 {
905 ConfigureSessionTransition::FirstCall
906 }
907 RemoteSessionTransition::Attach | RemoteSessionTransition::ModeChange => {
910 ConfigureSessionTransition::ModeChange
911 }
912 RemoteSessionTransition::Reset => ConfigureSessionTransition::Reset,
913 RemoteSessionTransition::WorkspaceChange => ConfigureSessionTransition::WorkspaceChange,
914 }
915}
916
917fn prune_negotiations(cache: &mut NegotiationCache, now: Instant) {
918 cache.sessions.retain(|_, entry| {
919 now.duration_since(entry.last_seen) <= NEGOTIATION_TTL
920 || Arc::strong_count(&entry.session) > 1
921 });
922 cache.gates.retain(|_, entry| {
923 now.duration_since(entry.last_seen) <= NEGOTIATION_TTL || Arc::strong_count(&entry.gate) > 1
924 });
925 cache.local_revisions.retain(|_, entry| {
926 now.duration_since(entry.last_seen) <= NEGOTIATION_TTL
927 || Arc::strong_count(&entry.revision) > 1
928 });
929}
930
931fn cache_negotiation(
932 cache: &mut NegotiationCache,
933 thread_id: &str,
934 session: Arc<Mutex<NegotiatedSession>>,
935 now: Instant,
936) -> Result<()> {
937 if cache.sessions.len() >= MAX_NEGOTIATED_SESSIONS {
938 if let Some(oldest) = cache
939 .sessions
940 .iter()
941 .filter(|(_, entry)| Arc::strong_count(&entry.session) == 1)
942 .min_by_key(|(_, entry)| entry.last_seen)
943 .map(|(thread_id, _)| thread_id.clone())
944 {
945 cache.sessions.remove(&oldest);
946 }
947 }
948 if cache.sessions.len() >= MAX_NEGOTIATED_SESSIONS {
949 return Err(WinxError::ResourceAllocationError {
950 message: format!("all {MAX_NEGOTIATED_SESSIONS} daemon sessions are active"),
951 });
952 }
953 cache.sessions.insert(thread_id.to_string(), CachedNegotiation { session, last_seen: now });
954 Ok(())
955}
956
957impl ShellRuntime for DaemonShellRuntime {
958 fn configure_session<'a>(
959 &'a self,
960 bash_state: &'a mut BashState,
961 transition: ShellSessionTransition,
962 ) -> ShellRuntimeConfigureFuture<'a> {
963 Box::pin(self.configure_remote(bash_state, transition.into()))
964 }
965
966 fn attach_session<'a>(
967 &'a self,
968 bash_state: &'a mut BashState,
969 ) -> ShellRuntimeConfigureFuture<'a> {
970 Box::pin(self.configure_remote(bash_state, RemoteSessionTransition::Attach))
971 }
972
973 fn run_action<'a>(
974 &'a self,
975 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
976 command: BashCommand,
977 ) -> ShellRuntimeFuture<'a> {
978 Box::pin(async move {
979 Ok(self
980 .run_remote_detailed(bash_state, command, ShellActionOptions::default())
981 .await?
982 .result)
983 })
984 }
985
986 fn run_action_detailed<'a>(
987 &'a self,
988 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
989 command: BashCommand,
990 options: ShellActionOptions,
991 ) -> ShellRuntimeDetailedFuture<'a> {
992 Box::pin(self.run_remote_detailed(bash_state, command, options))
993 }
994
995 fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
996 Box::pin(async { Ok(false) })
999 }
1000
1001 fn describe_session<'a>(
1002 &'a self,
1003 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
1004 ) -> ShellRuntimeStatusFuture<'a> {
1005 Box::pin(async move {
1006 let thread_id = bash_state
1007 .lock()
1008 .await
1009 .as_ref()
1010 .ok_or(WinxError::BashStateNotInitialized)?
1011 .current_thread_id
1012 .clone();
1013 let info = match DaemonClient::new(&self.socket_path).session_info(&thread_id).await {
1017 Ok(info) => info,
1018 Err(WinxError::CommandExecutionError(message))
1019 if message.contains("session not found") =>
1020 {
1021 return Ok(ShellSessionStatus {
1022 runtime: "daemon",
1023 ..ShellSessionStatus::default()
1024 });
1025 }
1026 Err(error) => return Err(error),
1027 };
1028 let guardian = match info.runtime.clone() {
1032 Some(hello) => Some(hello),
1033 None => self.negotiated_guardian(&thread_id).await.ok(),
1034 };
1035 let guardian = guardian.as_ref();
1036 Ok(ShellSessionStatus {
1037 runtime: "daemon",
1038 guardian_pid: guardian.map(|hello| hello.daemon_pid),
1039 guardian_epoch: guardian.map(|hello| hello.daemon_epoch.clone()),
1040 guardian_build: guardian
1041 .and_then(|hello| hello.build.as_ref())
1042 .map(|build| build.display.clone()),
1043 command_running: info.running,
1044 running_command_id: info.command_id,
1045 background_command_ids: info.background_command_ids,
1046 last_activity_unix_ms: info.last_activity_unix_ms,
1047 })
1048 })
1049 }
1050
1051 fn supports_generation_bound_actions_for<'a>(
1052 &'a self,
1053 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
1054 ) -> ShellRuntimeBoolFuture<'a> {
1055 Box::pin(async move {
1056 let thread_id = bash_state
1057 .lock()
1058 .await
1059 .as_ref()
1060 .ok_or(WinxError::BashStateNotInitialized)?
1061 .current_thread_id
1062 .clone();
1063 let hello = self.negotiated_guardian(&thread_id).await?;
1064 Ok([GENERATION_BOUND_ACTIONS_CAPABILITY, CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY]
1065 .iter()
1066 .all(|required| hello.capabilities.iter().any(|capability| capability == required)))
1067 })
1068 }
1069
1070 fn interrupt<'a>(
1071 &'a self,
1072 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
1073 ) -> ShellRuntimeUnitFuture<'a> {
1074 Box::pin(async move {
1075 let thread_id = bash_state
1076 .lock()
1077 .await
1078 .as_ref()
1079 .ok_or(WinxError::BashStateNotInitialized)?
1080 .current_thread_id
1081 .clone();
1082 DaemonClient::new(&self.socket_path).interrupt_session(&thread_id).await
1083 })
1084 }
1085
1086 fn interrupt_generation<'a>(
1087 &'a self,
1088 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
1089 expected_generation: Option<u64>,
1090 ) -> ShellRuntimeBoolFuture<'a> {
1091 Box::pin(async move {
1092 let thread_id = bash_state
1093 .lock()
1094 .await
1095 .as_ref()
1096 .ok_or(WinxError::BashStateNotInitialized)?
1097 .current_thread_id
1098 .clone();
1099 DaemonClient::new(&self.socket_path)
1100 .interrupt_session_generation(&thread_id, expected_generation)
1101 .await
1102 })
1103 }
1104
1105 fn interrupt_execution<'a>(
1106 &'a self,
1107 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
1108 expected: Option<ShellExecutionToken>,
1109 ) -> ShellRuntimeBoolFuture<'a> {
1110 Box::pin(async move {
1111 let thread_id = bash_state
1112 .lock()
1113 .await
1114 .as_ref()
1115 .ok_or(WinxError::BashStateNotInitialized)?
1116 .current_thread_id
1117 .clone();
1118 DaemonClient::new(&self.socket_path)
1119 .interrupt_session_execution(&thread_id, expected)
1120 .await
1121 })
1122 }
1123
1124 fn cancel_pending_action<'a>(
1125 &'a self,
1126 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
1127 cancellation_key: &'a str,
1128 ) -> ShellRuntimeBoolFuture<'a> {
1129 Box::pin(async move {
1130 let thread_id = bash_state
1131 .lock()
1132 .await
1133 .as_ref()
1134 .ok_or(WinxError::BashStateNotInitialized)?
1135 .current_thread_id
1136 .clone();
1137 DaemonClient::new(&self.socket_path)
1138 .cancel_pending_action(&thread_id, cancellation_key)
1139 .await
1140 })
1141 }
1142
1143 fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
1144 Box::pin(async move {
1145 let _ = DaemonClient::new(&self.socket_path).kill_session(thread_id).await?;
1146 self.negotiations.lock().await.sessions.remove(thread_id);
1147 Ok(())
1148 })
1149 }
1150}
1151
1152fn action_options_for_guardian(
1153 hello: &HelloResult,
1154 mut options: ShellActionOptions,
1155) -> Result<(ShellActionOptions, bool)> {
1156 let has = |capability: &str| hello.capabilities.iter().any(|candidate| candidate == capability);
1157 if !has(TYPED_ACTION_RESULT_CAPABILITY) {
1158 return Err(WinxError::ConfigurationError(format!(
1159 "the running guardian does not advertise {TYPED_ACTION_RESULT_CAPABILITY}; terminate this durable session and initialize it again before executing BashCommand"
1160 )));
1161 }
1162 let generation_bound_actions = has(GENERATION_BOUND_ACTIONS_CAPABILITY);
1163 if (options.expected_generation.is_some() || options.require_generation_binding)
1164 && !generation_bound_actions
1165 {
1166 return Err(WinxError::InvalidInput(
1167 "the running guardian does not support generation-bound shell actions".to_string(),
1168 ));
1169 }
1170 if let Some(expected) = options.expected_execution.as_ref() {
1171 if expected.guardian_epoch != hello.daemon_epoch {
1172 return Err(WinxError::InvalidInput(
1173 "execution token belongs to a different guardian epoch".to_string(),
1174 ));
1175 }
1176 options.expected_generation = Some(expected.generation);
1177 }
1178 if options.expected_generation.is_some() || options.require_generation_binding {
1179 options.expected_guardian_epoch = Some(hello.daemon_epoch.clone());
1180 }
1181 if options.cancellation_key.is_some() && !has(CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY) {
1182 return Err(WinxError::InvalidInput(
1183 "the running guardian does not support cancellable action reservations".to_string(),
1184 ));
1185 }
1186 if !has(COMPACT_ACTION_OUTPUT_CAPABILITY) {
1187 options.compact_output = false;
1188 }
1189 Ok((options, generation_bound_actions))
1190}
1191
1192fn runtime_result_from_wire(
1193 result: RunActionResult,
1194 generation_bound_actions: bool,
1195) -> Result<BashCommandRuntimeResult> {
1196 match (
1197 result.output,
1198 result.compact_output,
1199 result.state,
1200 result.error,
1201 result.dropped_output_file,
1202 ) {
1203 (output, compact_output, Some(state), None, dropped_output_file)
1204 if output.is_some() || compact_output.is_some() => {
1205 let (output, compact_output) = match (output, compact_output) {
1206 (Some(output), compact) => (output, compact),
1207 (None, Some(compact)) => (String::new(), Some(compact)),
1208 (None, None) => unreachable!(),
1209 };
1210 Ok(BashCommandRuntimeResult {
1211 result: BashCommandResult { output, state },
1212 compact_output,
1213 command_generation: result.command_generation,
1214 execution_token: result.execution_token,
1215 generation_bound_actions,
1216 dropped_output_file,
1217 output_truncated: result.output_truncated,
1218 })
1219 }
1220 (_, _, _, Some(error), _) => Err(from_wire_error(error)),
1221 (Some(_), _, None, None, _) => Err(WinxError::ConfigurationError(
1222 "the session guardian predates typed BashCommand results; terminate this durable session, then initialize it again so the current guardian binary is created. Winx will not reconstruct orchestration state from terminal text."
1223 .to_string(),
1224 )),
1225 _ => Err(WinxError::ParseError(
1226 "winxd action response had neither a typed result nor an error".to_string(),
1227 )),
1228 }
1229}
1230
1231fn from_wire_error(error: WireShellError) -> WinxError {
1232 match error {
1233 WireShellError::BashStateNotInitialized => WinxError::BashStateNotInitialized,
1234 WireShellError::ShellInitialization(message) => {
1235 WinxError::ShellInitializationError(message)
1236 }
1237 WireShellError::CommandExecution(message) | WireShellError::Other(message) => {
1238 WinxError::CommandExecutionError(message)
1239 }
1240 WireShellError::NoActiveCommand(message) => WinxError::NoActiveCommand(message),
1241 WireShellError::BackgroundSessionNotFound(message) => {
1242 WinxError::BackgroundSessionNotFound(message)
1243 }
1244 WireShellError::EmptyInteractiveInput(action) => {
1245 WinxError::EmptyInteractiveInput { action }
1246 }
1247 WireShellError::InteractiveTargetNotRunning(message) => {
1248 WinxError::InteractiveTargetNotRunning(message)
1249 }
1250 WireShellError::CommandAlreadyRunning { current_command, duration_seconds } => {
1251 WinxError::CommandAlreadyRunning { current_command, duration_seconds }
1252 }
1253 WireShellError::CommandNotAllowed(message) => WinxError::CommandNotAllowed(message),
1254 WireShellError::ThreadIdMismatch(message) => WinxError::ThreadIdMismatch(message),
1255 WireShellError::InvalidInput(message) => WinxError::InvalidInput(message),
1256 }
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261 #![allow(clippy::expect_used)]
1262
1263 #[cfg(unix)]
1264 use std::sync::atomic::AtomicUsize;
1265 use std::sync::atomic::Ordering;
1266
1267 use super::*;
1268 #[cfg(unix)]
1269 use crate::daemon::protocol::{RpcResponse, TYPED_ACTION_RESULT_CAPABILITY};
1270 #[cfg(unix)]
1271 use tokio::net::UnixStream;
1272
1273 #[cfg(unix)] #[tokio::test(start_paused = true)]
1275 async fn control_rpcs_time_out_when_a_guardian_stalls_after_hello() {
1276 let temp = tempfile::tempdir().expect("temporary directory");
1277 let socket = temp.path().join("stalled.sock");
1278 let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock guardian");
1279 let server = tokio::spawn(async move {
1280 let (mut stream, _) = listener.accept().await.expect("accept");
1281 let hello: RpcRequest = read_json_frame(&mut stream).await.expect("read hello");
1282 assert_eq!(hello.method, "winx.hello");
1283 let result = HelloResult::current(
1284 crate::daemon::protocol::DaemonProcessRole::Guardian,
1285 vec![],
1286 "stalled",
1287 );
1288 write_json_frame(
1289 &mut stream,
1290 &RpcResponse {
1291 jsonrpc: "2.0".to_string(),
1292 id: hello.id,
1293 result: Some(serde_json::to_value(result).expect("hello json")),
1294 error: None,
1295 },
1296 )
1297 .await
1298 .expect("write hello");
1299 let _list: RpcRequest = read_json_frame(&mut stream).await.expect("read list");
1301 std::future::pending::<()>().await;
1302 });
1303
1304 let error = DaemonClient::new(&socket)
1305 .list_sessions()
1306 .await
1307 .expect_err("a stalled guardian must not park the caller forever");
1308 assert!(
1309 matches!(error, WinxError::CommandTimeout { .. }),
1310 "expected a bounded timeout, got {error}"
1311 );
1312 server.abort();
1313 }
1314
1315 #[cfg(unix)] #[tokio::test]
1317 async fn cancelled_rpc_cannot_poison_the_next_tool_channel() {
1318 let temp = tempfile::tempdir().expect("temporary directory");
1319 let socket = temp.path().join("cancel-safe.sock");
1320 let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
1321 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel();
1322 let server = tokio::spawn(async move {
1323 let (mut first_stream, _) = listener.accept().await.expect("accept first RPC");
1324 let first_handler = tokio::spawn(async move {
1325 let first: RpcRequest =
1326 read_json_frame(&mut first_stream).await.expect("read first RPC");
1327 assert_eq!(first.method, "session.configure");
1328 let _ = first_seen_tx.send(());
1329 let closed = read_json_frame::<_, RpcRequest>(&mut first_stream).await;
1330 assert!(
1331 closed
1332 .as_ref()
1333 .is_err_and(|error| error.kind() == std::io::ErrorKind::UnexpectedEof),
1334 "cancelled disposable connection must close cleanly: {closed:?}"
1335 );
1336 });
1337
1338 let (mut second_stream, _) = listener.accept().await.expect("accept second RPC");
1339 let second: RpcRequest =
1340 read_json_frame(&mut second_stream).await.expect("read second RPC");
1341 assert_eq!(second.method, "session.configure");
1342 write_json_frame(
1343 &mut second_stream,
1344 &RpcResponse {
1345 jsonrpc: "2.0".to_string(),
1346 id: second.id,
1347 result: Some(serde_json::json!(42)),
1348 error: None,
1349 },
1350 )
1351 .await
1352 .expect("write second response");
1353 first_handler.await.expect("first connection handler");
1354 });
1355
1356 let runtime = DaemonShellRuntime::new(&socket);
1357 let first_runtime = runtime.clone();
1358 let first = tokio::spawn(async move {
1359 first_runtime
1360 .isolated_request::<u64>(1, "session.configure", serde_json::json!({}))
1361 .await
1362 });
1363 first_seen_rx.await.expect("first request reached server");
1364 first.abort();
1365 assert!(first.await.expect_err("first request was cancelled").is_cancelled());
1366
1367 let second = tokio::time::timeout(
1368 Duration::from_secs(1),
1369 runtime.isolated_request::<u64>(2, "session.configure", serde_json::json!({})),
1370 )
1371 .await
1372 .expect("next RPC must not inherit cancelled framing")
1373 .expect("second RPC succeeds");
1374 assert_eq!(second, 42);
1375 server.await.expect("mock server task");
1376 }
1377
1378 #[cfg(unix)] #[tokio::test]
1380 #[allow(clippy::too_many_lines)] async fn generation_capability_comes_from_effective_guardian_and_is_cached() {
1382 let temp = tempfile::tempdir().expect("temporary directory");
1383 let socket = temp.path().join("control.sock");
1384 let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
1385 let connections = Arc::new(AtomicUsize::new(0));
1386 let observed = Arc::clone(&connections);
1387 let server = tokio::spawn(async move {
1388 let (mut stream, _) = listener.accept().await.expect("accept adapter");
1389 observed.fetch_add(1, Ordering::SeqCst);
1390 let hello: RpcRequest = read_json_frame(&mut stream).await.expect("control hello");
1391 assert_eq!(hello.method, "winx.hello");
1392 write_json_frame(
1393 &mut stream,
1394 &RpcResponse {
1395 jsonrpc: "2.0".to_string(),
1396 id: hello.id,
1397 result: Some(
1398 serde_json::to_value(HelloResult {
1399 protocol_major: PROTOCOL_MAJOR,
1400 protocol_minor: 5,
1401 capabilities: vec![GENERATION_BOUND_ACTIONS_CAPABILITY.to_string()],
1402 max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1403 daemon_epoch: "control-15".to_string(),
1404 daemon_pid: 15,
1405 process_role: None,
1406 build: None,
1407 })
1408 .expect("control hello value"),
1409 ),
1410 error: None,
1411 },
1412 )
1413 .await
1414 .expect("write control hello");
1415
1416 let negotiation: RpcRequest =
1417 read_json_frame(&mut stream).await.expect("session negotiation");
1418 assert_eq!(negotiation.method, "session.negotiate");
1419 write_json_frame(
1420 &mut stream,
1421 &RpcResponse {
1422 jsonrpc: "2.0".to_string(),
1423 id: negotiation.id,
1424 result: Some(
1425 serde_json::to_value(HelloResult {
1426 protocol_major: PROTOCOL_MAJOR,
1427 protocol_minor: 4,
1428 capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1429 max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1430 daemon_epoch: "guardian-14".to_string(),
1431 daemon_pid: 14,
1432 process_role: None,
1433 build: None,
1434 })
1435 .expect("guardian hello value"),
1436 ),
1437 error: None,
1438 },
1439 )
1440 .await
1441 .expect("write guardian hello");
1442
1443 let (mut action_stream, _) = listener.accept().await.expect("accept isolated action");
1444 observed.fetch_add(1, Ordering::SeqCst);
1445 let action: RpcRequest =
1446 read_json_frame(&mut action_stream).await.expect("isolated action request");
1447 assert_eq!(action.method, "shell.run_action", "hot path must not send winx.hello");
1448 assert!(
1449 action.params.get("options").is_none(),
1450 "guardian 1.4 must receive default protocol-1.4 options"
1451 );
1452 let params: RunActionParams =
1453 serde_json::from_value(action.params).expect("run action params");
1454 let cwd = params.snapshot.cwd.clone();
1455 write_json_frame(
1456 &mut action_stream,
1457 &RpcResponse {
1458 jsonrpc: "2.0".to_string(),
1459 id: action.id,
1460 result: Some(
1461 serde_json::to_value(RunActionResult {
1462 output: Some("guardian-14-output".to_string()),
1463 compact_output: None,
1464 command_generation: None,
1465 execution_token: None,
1466 dropped_output_file: None,
1467 output_truncated: false,
1468 state: Some(crate::tools::bash_command::BashCommandState {
1469 process_status:
1470 crate::tools::bash_command::BashProcessStatus::Exited,
1471 background_id: None,
1472 running_for_seconds: None,
1473 exit_code: Some(0),
1474 cwd: cwd.into(),
1475 turn_state: None,
1476 }),
1477 snapshot: params.snapshot,
1478 error: None,
1479 })
1480 .expect("action result value"),
1481 ),
1482 error: None,
1483 },
1484 )
1485 .await
1486 .expect("write action result");
1487 });
1488
1489 let runtime = DaemonShellRuntime::new(&socket);
1490 let mut state = BashState::new();
1491 state.current_thread_id = "legacy-guardian".to_string();
1492 let state = Arc::new(Mutex::new(Some(state)));
1493 let (first_probe, concurrent_probe) = tokio::join!(
1494 runtime.supports_generation_bound_actions_for(&state),
1495 runtime.supports_generation_bound_actions_for(&state)
1496 );
1497 assert!(!first_probe.expect("first capability probe"));
1498 assert!(!concurrent_probe.expect("concurrent capability probe"));
1499 assert!(!tokio::time::timeout(
1500 std::time::Duration::from_millis(100),
1501 runtime.supports_generation_bound_actions_for(&state),
1502 )
1503 .await
1504 .expect("cached probe must not reconnect")
1505 .expect("cached capability"));
1506
1507 let outcome = runtime
1508 .run_remote_detailed(
1509 &state,
1510 BashCommand {
1511 action_json: crate::types::BashCommandAction::Command {
1512 command: "printf guardian-14-output".to_string(),
1513 is_background: false,
1514 allow_multi: false,
1515 },
1516 wait_for_seconds: Some(0.0),
1517 thread_id: "legacy-guardian".to_string(),
1518 },
1519 ShellActionOptions { compact_output: true, ..ShellActionOptions::default() },
1520 )
1521 .await
1522 .expect("guardian 1.4 action");
1523 assert_eq!(outcome.result.output, "guardian-14-output");
1524 assert_eq!(outcome.compact_output, None);
1525 server.await.expect("mock server task");
1526 assert_eq!(connections.load(Ordering::SeqCst), 2);
1527 }
1528
1529 #[cfg(unix)] #[tokio::test]
1531 #[allow(clippy::too_many_lines)] async fn ambiguous_action_is_not_retried_after_guardian_epoch_changes() {
1533 let temp = tempfile::tempdir().expect("temporary directory");
1534 let socket = temp.path().join("control-restart.sock");
1535 let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
1536 let action_count = Arc::new(AtomicUsize::new(0));
1537 let observed = Arc::clone(&action_count);
1538 let server = tokio::spawn(async move {
1539 for (control_epoch, guardian_epoch) in
1540 [("control-one", "guardian-one"), ("control-two", "guardian-two")]
1541 {
1542 let (mut stream, _) = listener.accept().await.expect("accept adapter");
1543 let control_request: RpcRequest =
1544 read_json_frame(&mut stream).await.expect("control hello");
1545 assert_eq!(control_request.method, "winx.hello");
1546 write_json_frame(
1547 &mut stream,
1548 &RpcResponse {
1549 jsonrpc: "2.0".to_string(),
1550 id: control_request.id,
1551 result: Some(
1552 serde_json::to_value(HelloResult {
1553 protocol_major: PROTOCOL_MAJOR,
1554 protocol_minor: 5,
1555 capabilities: vec![],
1556 max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1557 daemon_epoch: control_epoch.to_string(),
1558 daemon_pid: 15,
1559 process_role: None,
1560 build: None,
1561 })
1562 .expect("control hello value"),
1563 ),
1564 error: None,
1565 },
1566 )
1567 .await
1568 .expect("control hello response");
1569 let negotiation: RpcRequest =
1570 read_json_frame(&mut stream).await.expect("session negotiation");
1571 write_json_frame(
1572 &mut stream,
1573 &RpcResponse {
1574 jsonrpc: "2.0".to_string(),
1575 id: negotiation.id,
1576 result: Some(
1577 serde_json::to_value(HelloResult {
1578 protocol_major: PROTOCOL_MAJOR,
1579 protocol_minor: 5,
1580 capabilities: vec![
1581 TYPED_ACTION_RESULT_CAPABILITY.to_string(),
1582 GENERATION_BOUND_ACTIONS_CAPABILITY.to_string(),
1583 ],
1584 max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1585 daemon_epoch: guardian_epoch.to_string(),
1586 daemon_pid: 16,
1587 process_role: None,
1588 build: None,
1589 })
1590 .expect("guardian hello value"),
1591 ),
1592 error: None,
1593 },
1594 )
1595 .await
1596 .expect("guardian hello response");
1597
1598 if guardian_epoch == "guardian-one" {
1599 let (mut action_stream, _) =
1600 listener.accept().await.expect("accept isolated first action");
1601 let action: RpcRequest =
1602 read_json_frame(&mut action_stream).await.expect("first action");
1603 assert_eq!(action.method, "shell.run_action");
1604 observed.fetch_add(1, Ordering::SeqCst);
1605 } else {
1607 let repeated = tokio::time::timeout(
1608 std::time::Duration::from_millis(100),
1609 listener.accept(),
1610 )
1611 .await;
1612 assert!(repeated.is_err(), "action was resent to a different guardian epoch");
1613 }
1614 }
1615 });
1616
1617 let runtime = DaemonShellRuntime::new(&socket);
1618 let mut state = BashState::new();
1619 state.current_thread_id = "ambiguous-action".to_string();
1620 let state = Arc::new(Mutex::new(Some(state)));
1621 let result = runtime
1622 .run_remote_detailed(
1623 &state,
1624 BashCommand {
1625 action_json: crate::types::BashCommandAction::Command {
1626 command: "printf must-run-once".to_string(),
1627 is_background: false,
1628 allow_multi: false,
1629 },
1630 wait_for_seconds: Some(0.0),
1631 thread_id: "ambiguous-action".to_string(),
1632 },
1633 ShellActionOptions::default(),
1634 )
1635 .await;
1636 assert!(
1637 matches!(result, Err(WinxError::CommandExecutionError(ref message)) if message.contains("will not retry")),
1638 "{result:?}"
1639 );
1640 server.await.expect("mock server task");
1641 assert_eq!(action_count.load(Ordering::SeqCst), 1);
1642 }
1643
1644 #[cfg(unix)] #[tokio::test]
1646 async fn adapter_negotiation_cache_is_bounded_under_session_churn() {
1647 let mut cache = NegotiationCache::default();
1648 for index in 0..(MAX_NEGOTIATED_SESSIONS + 50) {
1649 let (stream, peer) = UnixStream::pair().expect("unix stream pair");
1650 drop(peer);
1651 let session = Arc::new(Mutex::new(NegotiatedSession {
1652 control_epoch: "control".to_string(),
1653 guardian: HelloResult {
1654 protocol_major: PROTOCOL_MAJOR,
1655 protocol_minor: 5,
1656 capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1657 max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1658 daemon_epoch: format!("guardian-{index}"),
1659 daemon_pid: u32::try_from(index).unwrap_or(u32::MAX),
1660 process_role: None,
1661 build: None,
1662 },
1663 health_stream: stream,
1664 }));
1665 cache_negotiation(&mut cache, &format!("thread-{index}"), session, Instant::now())
1666 .expect("inactive cache entry should be evictable");
1667 }
1668 assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1669 }
1670
1671 #[cfg(unix)] #[tokio::test]
1673 async fn adapter_negotiation_cache_never_evicts_active_sessions() {
1674 let mut cache = NegotiationCache::default();
1675 let mut active = Vec::new();
1676 for index in 0..MAX_NEGOTIATED_SESSIONS {
1677 let (stream, peer) = UnixStream::pair().expect("unix stream pair");
1678 drop(peer);
1679 let session = Arc::new(Mutex::new(NegotiatedSession {
1680 control_epoch: "control".to_string(),
1681 guardian: HelloResult {
1682 protocol_major: PROTOCOL_MAJOR,
1683 protocol_minor: 5,
1684 capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1685 max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1686 daemon_epoch: format!("active-{index}"),
1687 daemon_pid: u32::try_from(index).unwrap_or(u32::MAX),
1688 process_role: None,
1689 build: None,
1690 },
1691 health_stream: stream,
1692 }));
1693 cache_negotiation(
1694 &mut cache,
1695 &format!("active-{index}"),
1696 Arc::clone(&session),
1697 Instant::now(),
1698 )
1699 .expect("active slot");
1700 active.push(session);
1701 }
1702 let (stream, peer) = UnixStream::pair().expect("overflow pair");
1703 drop(peer);
1704 let overflow = Arc::new(Mutex::new(NegotiatedSession {
1705 control_epoch: "control".to_string(),
1706 guardian: HelloResult {
1707 protocol_major: PROTOCOL_MAJOR,
1708 protocol_minor: 5,
1709 capabilities: vec![],
1710 max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1711 daemon_epoch: "overflow".to_string(),
1712 daemon_pid: 999,
1713 process_role: None,
1714 build: None,
1715 },
1716 health_stream: stream,
1717 }));
1718 let error =
1719 cache_negotiation(&mut cache, "overflow", Arc::clone(&overflow), Instant::now())
1720 .expect_err("all active sessions must apply backpressure");
1721 assert!(matches!(error, WinxError::ResourceAllocationError { .. }));
1722 assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1723
1724 drop(active.pop());
1725 cache_negotiation(&mut cache, "overflow", overflow, Instant::now())
1726 .expect("an inactive session can be evicted");
1727 assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1728 }
1729
1730 #[tokio::test]
1731 async fn stale_daemon_action_snapshot_cannot_overwrite_local_reconfiguration() {
1732 let runtime = DaemonShellRuntime::new("unused.sock");
1733 let mut initial = BashState::new();
1734 initial.current_thread_id = "local-revision".to_string();
1735 initial.cwd = PathBuf::from("/old-action-cwd");
1736 let stale_snapshot = initial.snapshot();
1737 let state = Arc::new(Mutex::new(Some(initial)));
1738
1739 let revision = runtime.local_revision("local-revision").await.expect("revision");
1740 let captured = revision.load(Ordering::SeqCst);
1741 let mut configure_guard = state.lock().await;
1742 let apply_runtime = runtime.clone();
1743 let apply_state = Arc::clone(&state);
1744 let apply_revision = Arc::clone(&revision);
1745 let stale_apply = tokio::spawn(async move {
1746 apply_runtime
1747 .apply_snapshot_if_current(
1748 &apply_state,
1749 "local-revision",
1750 &apply_revision,
1751 captured,
1752 &stale_snapshot,
1753 )
1754 .await
1755 });
1756 tokio::task::yield_now().await;
1757
1758 revision.fetch_add(1, Ordering::SeqCst);
1762 configure_guard.as_mut().expect("state").cwd = PathBuf::from("/new-config-cwd");
1763 drop(configure_guard);
1764
1765 assert!(!stale_apply.await.expect("stale writeback task"));
1766 assert_eq!(
1767 state.lock().await.as_ref().expect("state").cwd,
1768 PathBuf::from("/new-config-cwd")
1769 );
1770 }
1771}