1use std::collections::HashMap;
2use std::sync::atomic::{AtomicUsize, Ordering};
3use std::sync::Arc;
4use std::time::Instant;
5
6use rmcp::service::{NotificationContext, RoleServer};
7use tokio::sync::Mutex;
8use tracing::{info, warn};
9
10use super::{SharedBashState, WinxService};
11use crate::runtime::{EmbeddedShellRuntime, ShellRuntime};
12use crate::state::bash_state::generate_thread_id;
13use crate::state::task_state::TaskRegistry;
14use crate::types::{Initialize, InitializeType, ModeName};
15
16pub(super) const MAX_SESSIONS: usize = 32;
19
20#[derive(Default)]
25pub(super) struct SessionRegistry {
26 pub(super) slots: HashMap<String, SharedBashState>,
27 pub(super) last_used: HashMap<String, Instant>,
29 pub(super) in_flight: HashMap<String, SessionPin>,
32 pub(super) last_active: Option<String>,
35}
36
37#[derive(Clone)]
42pub(super) struct SessionPin {
43 count: Arc<AtomicUsize>,
44}
45
46impl Default for SessionPin {
47 fn default() -> Self {
48 Self { count: Arc::new(AtomicUsize::new(0)) }
49 }
50}
51
52impl SessionPin {
53 fn acquire(&self) -> SessionGuard {
54 self.count.fetch_add(1, Ordering::SeqCst);
55 SessionGuard { count: self.count.clone() }
56 }
57
58 fn is_pinned(&self) -> bool {
59 self.count.load(Ordering::SeqCst) > 0
60 }
61}
62
63pub(super) struct SessionGuard {
66 count: Arc<AtomicUsize>,
67}
68
69impl Drop for SessionGuard {
70 fn drop(&mut self) {
71 self.count.fetch_sub(1, Ordering::SeqCst);
72 }
73}
74
75#[derive(Clone, Copy, PartialEq, Eq, Debug)]
77pub enum SessionIsolation {
78 Lenient,
81 Strict,
89}
90
91impl Default for WinxService {
92 fn default() -> Self {
93 Self::new()
94 }
95}
96
97impl WinxService {
98 pub fn new() -> Self {
100 Self::with_isolation(SessionIsolation::Lenient)
101 }
102
103 pub fn with_isolation(isolation: SessionIsolation) -> Self {
106 Self::with_runtime(isolation, Arc::new(EmbeddedShellRuntime))
107 }
108
109 pub fn with_runtime(isolation: SessionIsolation, shell_runtime: Arc<dyn ShellRuntime>) -> Self {
111 info!(?isolation, "Creating new WinxService instance");
112 Self {
113 sessions: Arc::new(Mutex::new(SessionRegistry::default())),
114 tasks: Arc::new(Mutex::new(TaskRegistry::default())),
115 root_bootstrap: Arc::new(Mutex::new(())),
116 shell_runtime,
117 mutations: super::mutations::MutationCoordinator::default(),
118 version: crate::build_info::display_version().to_string(),
119 isolation,
120 }
121 }
122
123 pub(super) async fn session_for(&self, thread_id: &str) -> (SharedBashState, SessionGuard) {
127 let thread_id = crate::types::normalize_thread_id(thread_id);
128 let mut registry = self.sessions.lock().await;
129 let key = if thread_id.is_empty() {
130 match self.isolation {
131 SessionIsolation::Lenient => {
132 registry.last_active.clone().unwrap_or_else(|| "default".to_string())
133 }
134 SessionIsolation::Strict => "anonymous".to_string(),
135 }
136 } else {
137 thread_id.clone()
138 };
139
140 if !registry.slots.contains_key(&key) && registry.slots.len() >= MAX_SESSIONS {
141 let victim = registry
142 .last_used
143 .iter()
144 .filter(|(candidate, _)| **candidate != key)
145 .filter(|(candidate, _)| {
146 registry.in_flight.get(candidate.as_str()).is_none_or(|pin| !pin.is_pinned())
147 })
148 .min_by_key(|(_, last_used)| **last_used)
149 .map(|(candidate, _)| candidate.clone());
150
151 if let Some(victim) = victim {
152 if let Err(error) = self.shell_runtime.terminate_session(&victim).await {
156 warn!(%error, "failed to terminate LRU shell session '{victim}'");
157 }
158 registry.slots.remove(&victim);
159 registry.last_used.remove(&victim);
160 registry.in_flight.remove(&victim);
161 if registry.last_active.as_deref() == Some(victim.as_str()) {
162 registry.last_active = None;
163 }
164 warn!("Evicted LRU shell session '{victim}' (session cap {MAX_SESSIONS})");
165 } else {
166 warn!(
167 "All {MAX_SESSIONS} sessions busy; exceeding the cap rather than evicting an in-flight session"
168 );
169 }
170 }
171
172 let slot =
173 registry.slots.entry(key.clone()).or_insert_with(|| Arc::new(Mutex::new(None))).clone();
174 let pin = registry.in_flight.entry(key.clone()).or_default().clone();
175 registry.last_used.insert(key.clone(), Instant::now());
176 if !thread_id.is_empty() {
177 registry.last_active = Some(key);
178 }
179 (slot, pin.acquire())
180 }
181
182 pub(super) async fn tool_session_for(
193 &self,
194 thread_id: &str,
195 ) -> (SharedBashState, SessionGuard) {
196 let (slot, guard) = self.session_for(thread_id).await;
197 self.rehydrate_slot_if_persisted(&slot, thread_id).await;
198 (slot, guard)
199 }
200
201 async fn rehydrate_slot_if_persisted(&self, slot: &SharedBashState, thread_id: &str) {
206 use crate::runtime::ShellSessionTransition;
207
208 let thread_id = crate::types::normalize_thread_id(thread_id);
209 if thread_id.is_empty() {
210 return;
211 }
212 let mut state_guard = slot.lock().await;
213 if state_guard.is_some() {
214 return;
215 }
216 let snapshot = match crate::state::persistence::load_bash_state(&thread_id) {
217 Ok(Some(snapshot)) => snapshot,
218 Ok(None) => return,
219 Err(error) => {
220 warn!(%error, thread_id, "failed to read persisted session snapshot");
221 return;
222 }
223 };
224
225 let mut state = crate::state::bash_state::BashState::new();
226 state.apply_snapshot(&snapshot);
227 state.current_thread_id.clone_from(&thread_id);
229 if !state.workspace_root.exists() {
230 warn!(
231 thread_id,
232 workspace_root = %state.workspace_root.display(),
233 "not rehydrating session: persisted workspace no longer exists"
234 );
235 return;
236 }
237
238 match self
243 .shell_runtime
244 .configure_session(&mut state, ShellSessionTransition::FirstCall)
245 .await
246 {
247 Ok(configured) => {
248 info!(
249 thread_id,
250 attached_existing = configured.attached_existing,
251 "rehydrated session from persisted state after adapter restart"
252 );
253 if !configured.attached_existing {
254 state.recovery_note = Some(lost_shell_recovery_note(&thread_id, &state));
255 }
256 *state_guard = Some(state);
257 }
258 Err(error) => {
259 warn!(%error, thread_id, "failed to rehydrate persisted session");
260 }
261 }
262 }
263
264 pub(super) async fn active_slot(
267 &self,
268 session_prefix: Option<&str>,
269 ) -> Option<SharedBashState> {
270 let registry = self.sessions.lock().await;
271 if let Some(prefix) = session_prefix {
272 return registry
273 .last_used
274 .iter()
275 .filter(|(key, _)| key.starts_with(prefix))
276 .max_by_key(|(_, last_used)| **last_used)
277 .and_then(|(key, _)| registry.slots.get(key).cloned());
278 }
279 registry.last_active.as_ref().and_then(|key| registry.slots.get(key).cloned())
280 }
281
282 pub(super) async fn bound_workspace(&self, thread_id: &str) -> Option<std::path::PathBuf> {
286 let thread_id = crate::types::normalize_thread_id(thread_id);
287 if thread_id.is_empty() {
288 return None;
289 }
290 let slot = {
291 let registry = self.sessions.lock().await;
292 registry.slots.get(&thread_id).cloned()
293 }?;
294 let state = slot.lock().await;
295 state.as_ref().filter(|state| state.initialized).map(|state| state.workspace_root.clone())
296 }
297
298 pub(super) async fn has_initialized_session(&self) -> bool {
299 let slots = {
300 let registry = self.sessions.lock().await;
301 registry.slots.values().cloned().collect::<Vec<_>>()
302 };
303 for slot in slots {
304 if slot.lock().await.as_ref().is_some_and(|state| state.initialized) {
305 return true;
306 }
307 }
308 false
309 }
310
311 #[allow(deprecated)]
315 pub(super) async fn initialize_from_client_roots(
316 &self,
317 context: NotificationContext<RoleServer>,
318 ) {
319 if self.isolation != SessionIsolation::Lenient {
320 return;
321 }
322 let _bootstrap_guard = self.root_bootstrap.lock().await;
323 if self.has_initialized_session().await {
324 return;
325 }
326 let supports_roots =
327 context.peer.peer_info().is_some_and(|info| info.capabilities.roots.is_some());
328 if !supports_roots {
329 return;
330 }
331 let roots = match context.peer.list_roots().await {
332 Ok(result) => result.roots,
333 Err(error) => {
334 warn!(%error, "client advertised Roots but roots/list failed");
335 return;
336 }
337 };
338 let cwd = std::env::current_dir().ok();
339 let mut paths =
340 roots.iter().filter_map(|root| root_uri_to_path(&root.uri)).collect::<Vec<_>>();
341 let selected = cwd
342 .as_ref()
343 .and_then(|cwd| paths.iter().position(|path| cwd.starts_with(path)))
344 .map(|index| paths.remove(index))
345 .or_else(|| paths.into_iter().next());
346 let Some(workspace) = selected else {
347 warn!("client returned no usable local file Roots; Initialize remains required");
348 return;
349 };
350
351 let initialize = Initialize {
352 init_type: InitializeType::FirstCall,
353 any_workspace_path: workspace.to_string_lossy().into_owned(),
354 initial_files_to_read: Vec::new(),
355 task_id_to_resume: String::new(),
356 mode_name: ModeName::Wcgw,
357 thread_id: generate_thread_id(),
358 code_writer_config: None,
359 };
360 let arguments = match serde_json::to_value(initialize) {
361 Ok(arguments) => arguments,
362 Err(error) => {
363 warn!(%error, "failed to serialize Roots bootstrap request");
364 return;
365 }
366 };
367 match self.handle_initialize(Some(arguments)).await {
368 Ok(_) => info!(workspace = %workspace.display(), "initialized Winx from MCP Roots"),
369 Err(error) => warn!(%error.message, "failed to initialize Winx from MCP Roots"),
370 }
371 }
372}
373
374fn lost_shell_recovery_note(
378 thread_id: &str,
379 state: &crate::state::bash_state::BashState,
380) -> String {
381 let base = format!(
382 "Recovered session {thread_id} from its persisted snapshot after a restart: mode, \
383 permissions, and edit receipts were restored, and a fresh shell was started at {}.",
384 state.cwd.display()
385 );
386 match crate::state::agent_resume::load_agent_session(thread_id) {
387 Ok(Some(record)) => format!(
388 "{base} The interactive `{}` session running before the restart did not survive — \
389 resume its conversation with `{}` (cwd {}).",
390 record.agent,
391 record.resume_command(),
392 record.cwd
393 ),
394 Ok(None) => base,
395 Err(error) => {
396 warn!(%error, thread_id, "failed to read agent-session record");
397 base
398 }
399 }
400}
401
402pub(super) fn root_uri_to_path(uri: &str) -> Option<std::path::PathBuf> {
403 let encoded = uri.strip_prefix("file://")?;
404 let encoded = if let Some(path) = encoded.strip_prefix("localhost/") {
405 format!("/{path}")
406 } else if encoded.starts_with('/') {
407 encoded.to_string()
408 } else {
409 return None;
411 };
412 let decoded = percent_encoding::percent_decode_str(&encoded).decode_utf8().ok()?;
413 let decoded: &str = if cfg!(windows) {
415 decoded.strip_prefix('/').filter(|rest| is_drive_prefixed(rest)).unwrap_or(&decoded)
416 } else {
417 &decoded
418 };
419 let path = std::path::PathBuf::from(decoded);
420 path.is_absolute().then_some(path)
421}
422
423fn is_drive_prefixed(path: &str) -> bool {
424 let mut chars = path.chars();
425 matches!((chars.next(), chars.next()), (Some(drive), Some(':')) if drive.is_ascii_alphabetic())
426}
427
428#[cfg(test)]
429#[allow(clippy::expect_used)]
430mod rehydration_tests {
431 use std::sync::Arc;
432
433 use super::SessionIsolation;
434 use crate::errors::WinxError;
435 use crate::runtime::{
436 ShellRuntime, ShellRuntimeConfigureFuture, ShellRuntimeFuture, ShellRuntimeUnitFuture,
437 ShellSessionConfiguration, ShellSessionTransition,
438 };
439 use crate::server::WinxService;
440 use crate::state::agent_resume::{save_agent_session, AgentSessionRecord};
441 use crate::state::bash_state::BashState;
442 use crate::state::persistence::{delete_bash_state, save_bash_state};
443
444 struct StubRuntime {
447 attached_existing: bool,
448 }
449
450 impl ShellRuntime for StubRuntime {
451 fn configure_session<'a>(
452 &'a self,
453 _bash_state: &'a mut BashState,
454 _transition: ShellSessionTransition,
455 ) -> ShellRuntimeConfigureFuture<'a> {
456 let attached_existing = self.attached_existing;
457 Box::pin(async move {
458 Ok(ShellSessionConfiguration { attach_hint: None, attached_existing })
459 })
460 }
461
462 fn run_action<'a>(
463 &'a self,
464 _bash_state: &'a Arc<tokio::sync::Mutex<Option<BashState>>>,
465 _command: crate::types::BashCommand,
466 ) -> ShellRuntimeFuture<'a> {
467 Box::pin(async { Err(WinxError::CommandExecutionError("stub".to_string())) })
468 }
469
470 fn interrupt<'a>(
471 &'a self,
472 _bash_state: &'a Arc<tokio::sync::Mutex<Option<BashState>>>,
473 ) -> ShellRuntimeUnitFuture<'a> {
474 Box::pin(async { Ok(()) })
475 }
476
477 fn terminate_session<'a>(&'a self, _thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
478 Box::pin(async { Ok(()) })
479 }
480 }
481
482 fn service(attached_existing: bool) -> WinxService {
483 WinxService::with_runtime(
484 SessionIsolation::Strict,
485 Arc::new(StubRuntime { attached_existing }),
486 )
487 }
488
489 fn unique_thread_id(tag: &str) -> String {
490 let nanos = std::time::SystemTime::now()
491 .duration_since(std::time::UNIX_EPOCH)
492 .expect("system time after epoch")
493 .as_nanos();
494 format!("winxtest_rehydrate_{tag}_{}_{nanos}", std::process::id())
497 }
498
499 fn persist_snapshot(thread_id: &str, workspace: &std::path::Path) {
500 let mut state = BashState::new();
501 state.current_thread_id = thread_id.to_string();
502 state.workspace_root = workspace.to_path_buf();
503 state.cwd = workspace.to_path_buf();
504 state.initialized = true;
505 save_bash_state(thread_id, &state.snapshot()).expect("persist snapshot");
506 }
507
508 #[tokio::test]
509 async fn tool_call_rehydrates_a_persisted_session_transparently() {
510 let workspace = tempfile::tempdir().expect("workspace");
511 let thread_id = unique_thread_id("attach");
512 persist_snapshot(&thread_id, workspace.path());
513
514 let (slot, _guard) = service(true).tool_session_for(&thread_id).await;
516 {
517 let state = slot.lock().await;
518 let state = state.as_ref().expect("session should rehydrate from disk");
519 assert!(state.initialized);
520 assert_eq!(state.current_thread_id, thread_id);
521 assert_eq!(state.workspace_root, workspace.path());
522 assert!(state.recovery_note.is_none());
524 }
525 delete_bash_state(&thread_id).ok();
526 }
527
528 #[tokio::test]
529 async fn lost_shell_rehydration_surfaces_the_agent_resume_hint() {
530 let workspace = tempfile::tempdir().expect("workspace");
531 let thread_id = unique_thread_id("resume");
532 persist_snapshot(&thread_id, workspace.path());
533 save_agent_session(
534 &thread_id,
535 &AgentSessionRecord {
536 agent: "claude".to_string(),
537 command: "claude".to_string(),
538 cwd: workspace.path().to_string_lossy().into_owned(),
539 launched_at_unix_ms: 1,
540 },
541 )
542 .expect("persist agent record");
543
544 let (slot, _guard) = service(false).tool_session_for(&thread_id).await;
547 {
548 let state = slot.lock().await;
549 let note = state
550 .as_ref()
551 .expect("session should rehydrate")
552 .recovery_note
553 .clone()
554 .expect("lost shell must set a recovery note");
555 assert!(note.contains("claude --continue"), "note missing resume hint: {note}");
556 }
557 delete_bash_state(&thread_id).ok();
558 crate::state::agent_resume::clear_agent_session(&thread_id).ok();
559 }
560
561 #[tokio::test]
562 async fn without_a_persisted_snapshot_the_slot_stays_empty() {
563 let thread_id = unique_thread_id("missing");
564 let (slot, _guard) = service(true).tool_session_for(&thread_id).await;
565 assert!(slot.lock().await.is_none(), "no snapshot on disk must not fabricate state");
566 }
567
568 #[tokio::test]
569 async fn empty_thread_id_never_rehydrates() {
570 let (slot, _guard) = service(true).tool_session_for("").await;
571 assert!(slot.lock().await.is_none());
572 }
573}