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