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