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 match (result.output, result.compact_output, result.state, result.error) {
659 (output, compact_output, Some(state), None)
660 if output.is_some() || compact_output.is_some() => {
661 let (output, compact_output) = match (output, compact_output) {
662 (Some(output), compact) => (output, compact),
663 (None, Some(compact)) => (String::new(), Some(compact)),
664 (None, None) => unreachable!(),
665 };
666 Ok(BashCommandRuntimeResult {
667 result: BashCommandResult { output, state },
668 compact_output,
669 command_generation: result.command_generation,
670 execution_token: result.execution_token,
671 generation_bound_actions,
672 output_truncated: result.output_truncated,
673 })
674 }
675 (_, _, _, Some(error)) => Err(from_wire_error(error)),
676 (Some(_), _, None, None) => Err(WinxError::ConfigurationError(
677 "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."
678 .to_string(),
679 )),
680 _ => Err(WinxError::ParseError(
681 "winxd action response had neither a typed result nor an error".to_string(),
682 )),
683 }
684 }
685
686 async fn configure_remote(
687 &self,
688 bash_state: &mut BashState,
689 transition: ShellSessionTransition,
690 ) -> Result<ShellSessionConfiguration> {
691 let transition = match transition {
692 ShellSessionTransition::FirstCall => ConfigureSessionTransition::FirstCall,
693 ShellSessionTransition::ModeChange => ConfigureSessionTransition::ModeChange,
694 ShellSessionTransition::Reset => ConfigureSessionTransition::Reset,
695 ShellSessionTransition::WorkspaceChange => ConfigureSessionTransition::WorkspaceChange,
696 };
697 let thread_id = bash_state.current_thread_id.clone();
698 self.local_revision(&thread_id).await?.fetch_add(1, Ordering::SeqCst);
702 let session = self.live_negotiated_session(&thread_id).await?;
703 let result: ConfigureSessionResult = Self::negotiated_request(
704 &session,
705 rand::random::<u64>(),
706 "session.configure",
707 serde_json::to_value(ConfigureSessionParams {
708 snapshot: bash_state.snapshot(),
709 transition,
710 })
711 .map_err(|error| WinxError::SerializationError(error.to_string()))?,
712 )
713 .await?;
714 if let Some(snapshot) = result.snapshot {
715 bash_state.apply_snapshot(&snapshot);
716 }
717 Ok(ShellSessionConfiguration {
718 attach_hint: result.attach_hint,
719 attached_existing: result.attached_existing,
720 })
721 }
722}
723
724fn prune_negotiations(cache: &mut NegotiationCache, now: Instant) {
725 cache.sessions.retain(|_, entry| {
726 now.duration_since(entry.last_seen) <= NEGOTIATION_TTL
727 || Arc::strong_count(&entry.session) > 1
728 });
729 cache.gates.retain(|_, entry| {
730 now.duration_since(entry.last_seen) <= NEGOTIATION_TTL || Arc::strong_count(&entry.gate) > 1
731 });
732 cache.local_revisions.retain(|_, entry| {
733 now.duration_since(entry.last_seen) <= NEGOTIATION_TTL
734 || Arc::strong_count(&entry.revision) > 1
735 });
736}
737
738fn cache_negotiation(
739 cache: &mut NegotiationCache,
740 thread_id: &str,
741 session: Arc<Mutex<NegotiatedSession>>,
742 now: Instant,
743) -> Result<()> {
744 if cache.sessions.len() >= MAX_NEGOTIATED_SESSIONS {
745 if let Some(oldest) = cache
746 .sessions
747 .iter()
748 .filter(|(_, entry)| Arc::strong_count(&entry.session) == 1)
749 .min_by_key(|(_, entry)| entry.last_seen)
750 .map(|(thread_id, _)| thread_id.clone())
751 {
752 cache.sessions.remove(&oldest);
753 }
754 }
755 if cache.sessions.len() >= MAX_NEGOTIATED_SESSIONS {
756 return Err(WinxError::ResourceAllocationError {
757 message: format!("all {MAX_NEGOTIATED_SESSIONS} daemon sessions are active"),
758 });
759 }
760 cache.sessions.insert(thread_id.to_string(), CachedNegotiation { session, last_seen: now });
761 Ok(())
762}
763
764impl ShellRuntime for DaemonShellRuntime {
765 fn configure_session<'a>(
766 &'a self,
767 bash_state: &'a mut BashState,
768 transition: ShellSessionTransition,
769 ) -> ShellRuntimeConfigureFuture<'a> {
770 Box::pin(self.configure_remote(bash_state, transition))
771 }
772
773 fn run_action<'a>(
774 &'a self,
775 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
776 command: BashCommand,
777 ) -> ShellRuntimeFuture<'a> {
778 Box::pin(async move {
779 Ok(self
780 .run_remote_detailed(bash_state, command, ShellActionOptions::default())
781 .await?
782 .result)
783 })
784 }
785
786 fn run_action_detailed<'a>(
787 &'a self,
788 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
789 command: BashCommand,
790 options: ShellActionOptions,
791 ) -> ShellRuntimeDetailedFuture<'a> {
792 Box::pin(self.run_remote_detailed(bash_state, command, options))
793 }
794
795 fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
796 Box::pin(async { Ok(false) })
799 }
800
801 fn supports_generation_bound_actions_for<'a>(
802 &'a self,
803 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
804 ) -> ShellRuntimeBoolFuture<'a> {
805 Box::pin(async move {
806 let thread_id = bash_state
807 .lock()
808 .await
809 .as_ref()
810 .ok_or(WinxError::BashStateNotInitialized)?
811 .current_thread_id
812 .clone();
813 let hello = self.negotiated_guardian(&thread_id).await?;
814 Ok([GENERATION_BOUND_ACTIONS_CAPABILITY, CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY]
815 .iter()
816 .all(|required| hello.capabilities.iter().any(|capability| capability == required)))
817 })
818 }
819
820 fn interrupt<'a>(
821 &'a self,
822 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
823 ) -> ShellRuntimeUnitFuture<'a> {
824 Box::pin(async move {
825 let thread_id = bash_state
826 .lock()
827 .await
828 .as_ref()
829 .ok_or(WinxError::BashStateNotInitialized)?
830 .current_thread_id
831 .clone();
832 DaemonClient::new(&self.socket_path).interrupt_session(&thread_id).await
833 })
834 }
835
836 fn interrupt_generation<'a>(
837 &'a self,
838 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
839 expected_generation: Option<u64>,
840 ) -> ShellRuntimeBoolFuture<'a> {
841 Box::pin(async move {
842 let thread_id = bash_state
843 .lock()
844 .await
845 .as_ref()
846 .ok_or(WinxError::BashStateNotInitialized)?
847 .current_thread_id
848 .clone();
849 DaemonClient::new(&self.socket_path)
850 .interrupt_session_generation(&thread_id, expected_generation)
851 .await
852 })
853 }
854
855 fn interrupt_execution<'a>(
856 &'a self,
857 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
858 expected: Option<ShellExecutionToken>,
859 ) -> ShellRuntimeBoolFuture<'a> {
860 Box::pin(async move {
861 let thread_id = bash_state
862 .lock()
863 .await
864 .as_ref()
865 .ok_or(WinxError::BashStateNotInitialized)?
866 .current_thread_id
867 .clone();
868 DaemonClient::new(&self.socket_path)
869 .interrupt_session_execution(&thread_id, expected)
870 .await
871 })
872 }
873
874 fn cancel_pending_action<'a>(
875 &'a self,
876 bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
877 cancellation_key: &'a str,
878 ) -> ShellRuntimeBoolFuture<'a> {
879 Box::pin(async move {
880 let thread_id = bash_state
881 .lock()
882 .await
883 .as_ref()
884 .ok_or(WinxError::BashStateNotInitialized)?
885 .current_thread_id
886 .clone();
887 DaemonClient::new(&self.socket_path)
888 .cancel_pending_action(&thread_id, cancellation_key)
889 .await
890 })
891 }
892
893 fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
894 Box::pin(async move {
895 let _ = DaemonClient::new(&self.socket_path).kill_session(thread_id).await?;
896 self.negotiations.lock().await.sessions.remove(thread_id);
897 Ok(())
898 })
899 }
900}
901
902fn action_options_for_guardian(
903 hello: &HelloResult,
904 mut options: ShellActionOptions,
905) -> Result<(ShellActionOptions, bool)> {
906 let has = |capability: &str| hello.capabilities.iter().any(|candidate| candidate == capability);
907 if !has(TYPED_ACTION_RESULT_CAPABILITY) {
908 return Err(WinxError::ConfigurationError(format!(
909 "the running guardian does not advertise {TYPED_ACTION_RESULT_CAPABILITY}; terminate this durable session and initialize it again before executing BashCommand"
910 )));
911 }
912 let generation_bound_actions = has(GENERATION_BOUND_ACTIONS_CAPABILITY);
913 if (options.expected_generation.is_some() || options.require_generation_binding)
914 && !generation_bound_actions
915 {
916 return Err(WinxError::InvalidInput(
917 "the running guardian does not support generation-bound shell actions".to_string(),
918 ));
919 }
920 if let Some(expected) = options.expected_execution.as_ref() {
921 if expected.guardian_epoch != hello.daemon_epoch {
922 return Err(WinxError::InvalidInput(
923 "execution token belongs to a different guardian epoch".to_string(),
924 ));
925 }
926 options.expected_generation = Some(expected.generation);
927 }
928 if options.expected_generation.is_some() || options.require_generation_binding {
929 options.expected_guardian_epoch = Some(hello.daemon_epoch.clone());
930 }
931 if options.cancellation_key.is_some() && !has(CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY) {
932 return Err(WinxError::InvalidInput(
933 "the running guardian does not support cancellable action reservations".to_string(),
934 ));
935 }
936 if !has(COMPACT_ACTION_OUTPUT_CAPABILITY) {
937 options.compact_output = false;
938 }
939 Ok((options, generation_bound_actions))
940}
941
942fn from_wire_error(error: WireShellError) -> WinxError {
943 match error {
944 WireShellError::BashStateNotInitialized => WinxError::BashStateNotInitialized,
945 WireShellError::ShellInitialization(message) => {
946 WinxError::ShellInitializationError(message)
947 }
948 WireShellError::CommandExecution(message) | WireShellError::Other(message) => {
949 WinxError::CommandExecutionError(message)
950 }
951 WireShellError::NoActiveCommand(message) => WinxError::NoActiveCommand(message),
952 WireShellError::BackgroundSessionNotFound(message) => {
953 WinxError::BackgroundSessionNotFound(message)
954 }
955 WireShellError::EmptyInteractiveInput(action) => {
956 WinxError::EmptyInteractiveInput { action }
957 }
958 WireShellError::InteractiveTargetNotRunning(message) => {
959 WinxError::InteractiveTargetNotRunning(message)
960 }
961 WireShellError::CommandAlreadyRunning { current_command, duration_seconds } => {
962 WinxError::CommandAlreadyRunning { current_command, duration_seconds }
963 }
964 WireShellError::CommandNotAllowed(message) => WinxError::CommandNotAllowed(message),
965 WireShellError::ThreadIdMismatch(message) => WinxError::ThreadIdMismatch(message),
966 WireShellError::InvalidInput(message) => WinxError::InvalidInput(message),
967 }
968}
969
970#[cfg(test)]
971mod tests {
972 #![allow(clippy::expect_used)]
973
974 use std::sync::atomic::{AtomicUsize, Ordering};
975
976 use super::*;
977 use crate::daemon::protocol::{RpcResponse, TYPED_ACTION_RESULT_CAPABILITY};
978
979 #[tokio::test]
980 #[allow(clippy::too_many_lines)] async fn generation_capability_comes_from_effective_guardian_and_is_cached() {
982 let temp = tempfile::tempdir().expect("temporary directory");
983 let socket = temp.path().join("control.sock");
984 let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
985 let connections = Arc::new(AtomicUsize::new(0));
986 let observed = Arc::clone(&connections);
987 let server = tokio::spawn(async move {
988 let (mut stream, _) = listener.accept().await.expect("accept adapter");
989 observed.fetch_add(1, Ordering::SeqCst);
990 let hello: RpcRequest = read_json_frame(&mut stream).await.expect("control hello");
991 assert_eq!(hello.method, "winx.hello");
992 write_json_frame(
993 &mut stream,
994 &RpcResponse {
995 jsonrpc: "2.0".to_string(),
996 id: hello.id,
997 result: Some(
998 serde_json::to_value(HelloResult {
999 protocol_major: PROTOCOL_MAJOR,
1000 protocol_minor: 5,
1001 capabilities: vec![GENERATION_BOUND_ACTIONS_CAPABILITY.to_string()],
1002 max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1003 daemon_epoch: "control-15".to_string(),
1004 daemon_pid: 15,
1005 })
1006 .expect("control hello value"),
1007 ),
1008 error: None,
1009 },
1010 )
1011 .await
1012 .expect("write control hello");
1013
1014 let negotiation: RpcRequest =
1015 read_json_frame(&mut stream).await.expect("session negotiation");
1016 assert_eq!(negotiation.method, "session.negotiate");
1017 write_json_frame(
1018 &mut stream,
1019 &RpcResponse {
1020 jsonrpc: "2.0".to_string(),
1021 id: negotiation.id,
1022 result: Some(
1023 serde_json::to_value(HelloResult {
1024 protocol_major: PROTOCOL_MAJOR,
1025 protocol_minor: 4,
1026 capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1027 max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1028 daemon_epoch: "guardian-14".to_string(),
1029 daemon_pid: 14,
1030 })
1031 .expect("guardian hello value"),
1032 ),
1033 error: None,
1034 },
1035 )
1036 .await
1037 .expect("write guardian hello");
1038
1039 let action: RpcRequest =
1040 read_json_frame(&mut stream).await.expect("hot action request");
1041 assert_eq!(action.method, "shell.run_action", "hot path must not send winx.hello");
1042 assert!(
1043 action.params.get("options").is_none(),
1044 "guardian 1.4 must receive default protocol-1.4 options"
1045 );
1046 let params: RunActionParams =
1047 serde_json::from_value(action.params).expect("run action params");
1048 let cwd = params.snapshot.cwd.clone();
1049 write_json_frame(
1050 &mut stream,
1051 &RpcResponse {
1052 jsonrpc: "2.0".to_string(),
1053 id: action.id,
1054 result: Some(
1055 serde_json::to_value(RunActionResult {
1056 output: Some("guardian-14-output".to_string()),
1057 compact_output: None,
1058 command_generation: None,
1059 execution_token: None,
1060 output_truncated: false,
1061 state: Some(crate::tools::bash_command::BashCommandState {
1062 process_status:
1063 crate::tools::bash_command::BashProcessStatus::Exited,
1064 background_id: None,
1065 running_for_seconds: None,
1066 exit_code: Some(0),
1067 cwd: cwd.into(),
1068 turn_state: None,
1069 }),
1070 snapshot: params.snapshot,
1071 error: None,
1072 })
1073 .expect("action result value"),
1074 ),
1075 error: None,
1076 },
1077 )
1078 .await
1079 .expect("write action result");
1080 });
1081
1082 let runtime = DaemonShellRuntime::new(&socket);
1083 let mut state = BashState::new();
1084 state.current_thread_id = "legacy-guardian".to_string();
1085 let state = Arc::new(Mutex::new(Some(state)));
1086 let (first_probe, concurrent_probe) = tokio::join!(
1087 runtime.supports_generation_bound_actions_for(&state),
1088 runtime.supports_generation_bound_actions_for(&state)
1089 );
1090 assert!(!first_probe.expect("first capability probe"));
1091 assert!(!concurrent_probe.expect("concurrent capability probe"));
1092 assert!(!tokio::time::timeout(
1093 std::time::Duration::from_millis(100),
1094 runtime.supports_generation_bound_actions_for(&state),
1095 )
1096 .await
1097 .expect("cached probe must not reconnect")
1098 .expect("cached capability"));
1099
1100 let outcome = runtime
1101 .run_remote_detailed(
1102 &state,
1103 BashCommand {
1104 action_json: crate::types::BashCommandAction::Command {
1105 command: "printf guardian-14-output".to_string(),
1106 is_background: false,
1107 allow_multi: false,
1108 },
1109 wait_for_seconds: Some(0.0),
1110 thread_id: "legacy-guardian".to_string(),
1111 },
1112 ShellActionOptions { compact_output: true, ..ShellActionOptions::default() },
1113 )
1114 .await
1115 .expect("guardian 1.4 action");
1116 assert_eq!(outcome.result.output, "guardian-14-output");
1117 assert_eq!(outcome.compact_output, None);
1118 server.await.expect("mock server task");
1119 assert_eq!(connections.load(Ordering::SeqCst), 1);
1120 }
1121
1122 #[tokio::test]
1123 #[allow(clippy::too_many_lines)] async fn ambiguous_action_is_not_retried_after_guardian_epoch_changes() {
1125 let temp = tempfile::tempdir().expect("temporary directory");
1126 let socket = temp.path().join("control-restart.sock");
1127 let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
1128 let action_count = Arc::new(AtomicUsize::new(0));
1129 let observed = Arc::clone(&action_count);
1130 let server = tokio::spawn(async move {
1131 for (control_epoch, guardian_epoch) in
1132 [("control-one", "guardian-one"), ("control-two", "guardian-two")]
1133 {
1134 let (mut stream, _) = listener.accept().await.expect("accept adapter");
1135 let control_request: RpcRequest =
1136 read_json_frame(&mut stream).await.expect("control hello");
1137 assert_eq!(control_request.method, "winx.hello");
1138 write_json_frame(
1139 &mut stream,
1140 &RpcResponse {
1141 jsonrpc: "2.0".to_string(),
1142 id: control_request.id,
1143 result: Some(
1144 serde_json::to_value(HelloResult {
1145 protocol_major: PROTOCOL_MAJOR,
1146 protocol_minor: 5,
1147 capabilities: vec![],
1148 max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1149 daemon_epoch: control_epoch.to_string(),
1150 daemon_pid: 15,
1151 })
1152 .expect("control hello value"),
1153 ),
1154 error: None,
1155 },
1156 )
1157 .await
1158 .expect("control hello response");
1159 let negotiation: RpcRequest =
1160 read_json_frame(&mut stream).await.expect("session negotiation");
1161 write_json_frame(
1162 &mut stream,
1163 &RpcResponse {
1164 jsonrpc: "2.0".to_string(),
1165 id: negotiation.id,
1166 result: Some(
1167 serde_json::to_value(HelloResult {
1168 protocol_major: PROTOCOL_MAJOR,
1169 protocol_minor: 5,
1170 capabilities: vec![
1171 TYPED_ACTION_RESULT_CAPABILITY.to_string(),
1172 GENERATION_BOUND_ACTIONS_CAPABILITY.to_string(),
1173 ],
1174 max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1175 daemon_epoch: guardian_epoch.to_string(),
1176 daemon_pid: 16,
1177 })
1178 .expect("guardian hello value"),
1179 ),
1180 error: None,
1181 },
1182 )
1183 .await
1184 .expect("guardian hello response");
1185
1186 if guardian_epoch == "guardian-one" {
1187 let action: RpcRequest =
1188 read_json_frame(&mut stream).await.expect("first action");
1189 assert_eq!(action.method, "shell.run_action");
1190 observed.fetch_add(1, Ordering::SeqCst);
1191 } else {
1193 let repeated = tokio::time::timeout(
1194 std::time::Duration::from_millis(100),
1195 read_json_frame::<_, RpcRequest>(&mut stream),
1196 )
1197 .await;
1198 assert!(repeated.is_err(), "action was resent to a different guardian epoch");
1199 }
1200 }
1201 });
1202
1203 let runtime = DaemonShellRuntime::new(&socket);
1204 let mut state = BashState::new();
1205 state.current_thread_id = "ambiguous-action".to_string();
1206 let state = Arc::new(Mutex::new(Some(state)));
1207 let result = runtime
1208 .run_remote_detailed(
1209 &state,
1210 BashCommand {
1211 action_json: crate::types::BashCommandAction::Command {
1212 command: "printf must-run-once".to_string(),
1213 is_background: false,
1214 allow_multi: false,
1215 },
1216 wait_for_seconds: Some(0.0),
1217 thread_id: "ambiguous-action".to_string(),
1218 },
1219 ShellActionOptions::default(),
1220 )
1221 .await;
1222 assert!(
1223 matches!(result, Err(WinxError::CommandExecutionError(ref message)) if message.contains("will not retry")),
1224 "{result:?}"
1225 );
1226 server.await.expect("mock server task");
1227 assert_eq!(action_count.load(Ordering::SeqCst), 1);
1228 }
1229
1230 #[tokio::test]
1231 async fn adapter_negotiation_cache_is_bounded_under_session_churn() {
1232 let mut cache = NegotiationCache::default();
1233 for index in 0..(MAX_NEGOTIATED_SESSIONS + 50) {
1234 let (stream, peer) = UnixStream::pair().expect("unix stream pair");
1235 drop(peer);
1236 let session = Arc::new(Mutex::new(NegotiatedSession {
1237 control_epoch: "control".to_string(),
1238 guardian: HelloResult {
1239 protocol_major: PROTOCOL_MAJOR,
1240 protocol_minor: 5,
1241 capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1242 max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1243 daemon_epoch: format!("guardian-{index}"),
1244 daemon_pid: u32::try_from(index).unwrap_or(u32::MAX),
1245 },
1246 stream,
1247 }));
1248 cache_negotiation(&mut cache, &format!("thread-{index}"), session, Instant::now())
1249 .expect("inactive cache entry should be evictable");
1250 }
1251 assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1252 }
1253
1254 #[tokio::test]
1255 async fn adapter_negotiation_cache_never_evicts_active_sessions() {
1256 let mut cache = NegotiationCache::default();
1257 let mut active = Vec::new();
1258 for index in 0..MAX_NEGOTIATED_SESSIONS {
1259 let (stream, peer) = UnixStream::pair().expect("unix stream pair");
1260 drop(peer);
1261 let session = Arc::new(Mutex::new(NegotiatedSession {
1262 control_epoch: "control".to_string(),
1263 guardian: HelloResult {
1264 protocol_major: PROTOCOL_MAJOR,
1265 protocol_minor: 5,
1266 capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1267 max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1268 daemon_epoch: format!("active-{index}"),
1269 daemon_pid: u32::try_from(index).unwrap_or(u32::MAX),
1270 },
1271 stream,
1272 }));
1273 cache_negotiation(
1274 &mut cache,
1275 &format!("active-{index}"),
1276 Arc::clone(&session),
1277 Instant::now(),
1278 )
1279 .expect("active slot");
1280 active.push(session);
1281 }
1282 let (stream, peer) = UnixStream::pair().expect("overflow pair");
1283 drop(peer);
1284 let overflow = Arc::new(Mutex::new(NegotiatedSession {
1285 control_epoch: "control".to_string(),
1286 guardian: HelloResult {
1287 protocol_major: PROTOCOL_MAJOR,
1288 protocol_minor: 5,
1289 capabilities: vec![],
1290 max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1291 daemon_epoch: "overflow".to_string(),
1292 daemon_pid: 999,
1293 },
1294 stream,
1295 }));
1296 let error =
1297 cache_negotiation(&mut cache, "overflow", Arc::clone(&overflow), Instant::now())
1298 .expect_err("all active sessions must apply backpressure");
1299 assert!(matches!(error, WinxError::ResourceAllocationError { .. }));
1300 assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1301
1302 drop(active.pop());
1303 cache_negotiation(&mut cache, "overflow", overflow, Instant::now())
1304 .expect("an inactive session can be evicted");
1305 assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1306 }
1307
1308 #[tokio::test]
1309 async fn stale_daemon_action_snapshot_cannot_overwrite_local_reconfiguration() {
1310 let runtime = DaemonShellRuntime::new("unused.sock");
1311 let mut initial = BashState::new();
1312 initial.current_thread_id = "local-revision".to_string();
1313 initial.cwd = PathBuf::from("/old-action-cwd");
1314 let stale_snapshot = initial.snapshot();
1315 let state = Arc::new(Mutex::new(Some(initial)));
1316
1317 let revision = runtime.local_revision("local-revision").await.expect("revision");
1318 let captured = revision.load(Ordering::SeqCst);
1319 let mut configure_guard = state.lock().await;
1320 let apply_runtime = runtime.clone();
1321 let apply_state = Arc::clone(&state);
1322 let apply_revision = Arc::clone(&revision);
1323 let stale_apply = tokio::spawn(async move {
1324 apply_runtime
1325 .apply_snapshot_if_current(
1326 &apply_state,
1327 "local-revision",
1328 &apply_revision,
1329 captured,
1330 &stale_snapshot,
1331 )
1332 .await
1333 });
1334 tokio::task::yield_now().await;
1335
1336 revision.fetch_add(1, Ordering::SeqCst);
1340 configure_guard.as_mut().expect("state").cwd = PathBuf::from("/new-config-cwd");
1341 drop(configure_guard);
1342
1343 assert!(!stale_apply.await.expect("stale writeback task"));
1344 assert_eq!(
1345 state.lock().await.as_ref().expect("state").cwd,
1346 PathBuf::from("/new-config-cwd")
1347 );
1348 }
1349}