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