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