1use std::collections::{HashMap, HashSet, VecDeque};
2use std::io::ErrorKind;
3use std::os::unix::fs::PermissionsExt;
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
6use std::sync::Arc;
7use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
8
9use tokio::net::{UnixListener, UnixStream};
10use tokio::sync::{Mutex, Notify};
11
12use super::protocol::{
13 read_json_frame, write_json_frame, ConfigureSessionParams, ConfigureSessionResult,
14 ConfigureSessionTransition, HelloResult, JournalRead, JournalReadParams, RpcError, RpcRequest,
15 RpcResponse, RunActionParams, RunActionResult, SessionInfo, SessionParams, WireShellError,
16 MAX_FRAME_BYTES, PROTOCOL_MAJOR, PROTOCOL_MINOR,
17};
18use crate::errors::{Result, WinxError};
19use crate::runtime::{lock_session_store, ShellTarget};
20use crate::state::bash_state::BashState;
21use crate::state::pty::SharedPtyShell;
22use crate::tools::bash_command::ShellDeliveryCursor;
23use crate::types::normalize_thread_id;
24
25type SharedState = Arc<Mutex<Option<BashState>>>;
26
27struct TimedEntry<T> {
28 value: T,
29 last_seen: Instant,
30}
31
32struct DaemonSession {
33 state: SharedState,
34 completed: Mutex<HashMap<String, TimedEntry<RunActionResult>>>,
35 journal: Mutex<OutputJournal>,
36 observed: Mutex<HashMap<String, (u64, String)>>,
37 background_ids: Mutex<HashSet<String>>,
38 command_id: Mutex<Option<String>>,
39 delivery_cursors: Mutex<HashMap<String, TimedEntry<Arc<Mutex<ShellDeliveryCursor>>>>>,
40 created_at_unix_ms: u64,
41 last_activity_unix_ms: AtomicU64,
42 last_command_at_unix_ms: AtomicU64,
43 ever_ran_command: AtomicBool,
44 drainer_started: AtomicBool,
45 activity: Notify,
46}
47
48impl DaemonSession {
49 fn new() -> Self {
50 let now = unix_ms();
51 Self {
52 state: Arc::new(Mutex::new(None)),
53 completed: Mutex::new(HashMap::new()),
54 journal: Mutex::new(OutputJournal::default()),
55 observed: Mutex::new(HashMap::new()),
56 background_ids: Mutex::new(HashSet::new()),
57 command_id: Mutex::new(None),
58 delivery_cursors: Mutex::new(HashMap::new()),
59 created_at_unix_ms: now,
60 last_activity_unix_ms: AtomicU64::new(now),
61 last_command_at_unix_ms: AtomicU64::new(0),
62 ever_ran_command: AtomicBool::new(false),
63 drainer_started: AtomicBool::new(false),
64 activity: Notify::new(),
65 }
66 }
67
68 fn note_activity(&self, command: bool) {
69 let now = unix_ms();
70 self.last_activity_unix_ms.store(now, Ordering::Release);
71 if command {
72 self.last_command_at_unix_ms.store(now, Ordering::Release);
73 self.ever_ran_command.store(true, Ordering::Release);
74 }
75 self.activity.notify_one();
76 }
77}
78
79const MAX_JOURNAL_BYTES: usize = 4 * 1024 * 1024;
80const MAX_SESSION_CACHE_ENTRIES: usize = 256;
81const MAX_IDENTIFIER_BYTES: usize = 256;
82const SESSION_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
83const DRAIN_CHANGED_INTERVAL: Duration = Duration::from_millis(20);
84const DRAIN_ACTIVE_INTERVAL: Duration = Duration::from_millis(100);
85const DRAIN_IDLE_INTERVAL: Duration = Duration::from_secs(1);
86
87#[derive(Clone, Copy, Default)]
88struct CaptureState {
89 changed: bool,
90 active: bool,
91}
92
93impl CaptureState {
94 fn merge(&mut self, other: Self) {
95 self.changed |= other.changed;
96 self.active |= other.active;
97 }
98
99 fn next_delay(self) -> Duration {
100 if self.changed {
101 DRAIN_CHANGED_INTERVAL
102 } else if self.active {
103 DRAIN_ACTIVE_INTERVAL
104 } else {
105 DRAIN_IDLE_INTERVAL
106 }
107 }
108}
109
110#[derive(Default)]
111struct OutputJournal {
112 chunks: VecDeque<(u64, String)>,
113 cursors: HashMap<String, TimedEntry<u64>>,
114 next_seq: u64,
115 bytes: usize,
116}
117
118fn prune_timed_entries<T>(entries: &mut HashMap<String, TimedEntry<T>>, now: Instant) {
119 entries.retain(|_, entry| now.duration_since(entry.last_seen) <= SESSION_CACHE_TTL);
120}
121
122fn evict_oldest_if_full<T>(entries: &mut HashMap<String, TimedEntry<T>>, incoming: &str) {
123 if entries.contains_key(incoming) || entries.len() < MAX_SESSION_CACHE_ENTRIES {
124 return;
125 }
126 if let Some(oldest) =
127 entries.iter().min_by_key(|(_, entry)| entry.last_seen).map(|(key, _)| key.clone())
128 {
129 entries.remove(&oldest);
130 }
131}
132
133fn validate_identifier(label: &str, value: &str) -> Result<()> {
134 if value.len() > MAX_IDENTIFIER_BYTES {
135 return Err(WinxError::InvalidInput(format!(
136 "{label} exceeds the {MAX_IDENTIFIER_BYTES}-byte limit"
137 )));
138 }
139 Ok(())
140}
141
142impl OutputJournal {
143 fn append(&mut self, output: String) {
144 if output.is_empty() {
145 return;
146 }
147 self.next_seq = self.next_seq.saturating_add(1);
148 self.bytes = self.bytes.saturating_add(output.len());
149 self.chunks.push_back((self.next_seq, output));
150 while self.bytes > MAX_JOURNAL_BYTES && self.chunks.len() > 1 {
151 if let Some((_, removed)) = self.chunks.pop_front() {
152 self.bytes = self.bytes.saturating_sub(removed.len());
153 }
154 }
155 }
156
157 fn read(&mut self, consumer_id: &str) -> JournalRead {
158 let now = Instant::now();
159 prune_timed_entries(&mut self.cursors, now);
160 evict_oldest_if_full(&mut self.cursors, consumer_id);
161
162 let oldest = self.chunks.front().map_or(self.next_seq.saturating_add(1), |(seq, _)| *seq);
163 let cursor = self.cursors.get(consumer_id).map_or(0, |entry| entry.value);
164 let gap = cursor.saturating_add(1) < oldest;
165 let mut output = String::new();
166 let mut next_seq = cursor;
167 for (seq, chunk) in self.chunks.iter().filter(|(seq, _)| *seq > cursor) {
168 output.push_str(chunk);
169 next_seq = *seq;
170 }
171 self.cursors
172 .insert(consumer_id.to_string(), TimedEntry { value: next_seq, last_seen: now });
173 JournalRead { output, next_seq, gap }
174 }
175}
176
177pub struct DaemonServer {
179 listener: UnixListener,
180 socket_path: PathBuf,
181 sessions: Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
182 epoch: String,
183}
184
185impl DaemonServer {
186 pub async fn bind(socket_path: impl AsRef<Path>) -> Result<Self> {
187 let socket_path = socket_path.as_ref().to_path_buf();
188 let parent = socket_path.parent().ok_or_else(|| {
189 WinxError::ConfigurationError("daemon socket must have a parent directory".to_string())
190 })?;
191 tokio::fs::create_dir_all(parent).await?;
192 tokio::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).await?;
193
194 if tokio::fs::try_exists(&socket_path).await? {
195 if UnixStream::connect(&socket_path).await.is_ok() {
196 return Err(WinxError::ConfigurationError(format!(
197 "a winxd instance is already listening at {}",
198 socket_path.display()
199 )));
200 }
201 tokio::fs::remove_file(&socket_path).await?;
202 }
203
204 let listener = UnixListener::bind(&socket_path)?;
205 tokio::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o600)).await?;
206 let epoch = format!("{:016x}", rand::random::<u64>());
207 Ok(Self { listener, socket_path, sessions: Arc::new(Mutex::new(HashMap::new())), epoch })
208 }
209
210 pub async fn serve(self) -> Result<()> {
211 loop {
212 let (stream, _) = self.listener.accept().await?;
213 if !same_uid(&stream)? {
214 tracing::warn!("Rejected winxd connection from a different uid");
215 continue;
216 }
217 let sessions = self.sessions.clone();
218 let epoch = self.epoch.clone();
219 tokio::spawn(async move {
220 if let Err(error) = serve_connection(stream, sessions, epoch).await {
221 tracing::debug!("winxd client disconnected: {error}");
222 }
223 });
224 }
225 }
226}
227
228impl Drop for DaemonServer {
229 fn drop(&mut self) {
230 let _ = std::fs::remove_file(&self.socket_path);
231 }
232}
233
234async fn serve_connection(
235 mut stream: UnixStream,
236 sessions: Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
237 epoch: String,
238) -> Result<()> {
239 loop {
240 let request: RpcRequest = match read_json_frame(&mut stream).await {
241 Ok(request) => request,
242 Err(error) if error.kind() == ErrorKind::UnexpectedEof => return Ok(()),
243 Err(error) => return Err(error.into()),
244 };
245 let response = dispatch(request, &sessions, &epoch).await;
246 write_json_frame(&mut stream, &response).await?;
247 }
248}
249
250#[allow(clippy::too_many_lines)]
251async fn dispatch(
252 request: RpcRequest,
253 sessions: &Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
254 epoch: &str,
255) -> RpcResponse {
256 if request.jsonrpc != "2.0" {
257 return rpc_error(request.id, -32600, "JSON-RPC version must be 2.0");
258 }
259
260 match request.method.as_str() {
261 "winx.hello" => rpc_result(
262 request.id,
263 &HelloResult {
264 protocol_major: PROTOCOL_MAJOR,
265 protocol_minor: PROTOCOL_MINOR,
266 capabilities: vec![
267 "session.configure".to_string(),
268 "shell.run_action".to_string(),
269 "session.list".to_string(),
270 "session.read_output".to_string(),
271 "session.kill".to_string(),
272 "multi_consumer_cursors".to_string(),
273 "idempotency".to_string(),
274 "session_activity_timestamps".to_string(),
275 "attach_or_create".to_string(),
276 ],
277 max_frame_bytes: MAX_FRAME_BYTES,
278 daemon_epoch: epoch.to_string(),
279 daemon_pid: std::process::id(),
280 },
281 ),
282 "session.configure" => {
283 let params: ConfigureSessionParams = match serde_json::from_value(request.params) {
284 Ok(params) => params,
285 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
286 };
287 match configure_session(sessions, params).await {
288 Ok(result) => rpc_result(request.id, &result),
289 Err(error) => rpc_error(request.id, -32603, &error.to_string()),
290 }
291 }
292 "shell.run_action" => {
293 let params: RunActionParams = match serde_json::from_value(request.params) {
294 Ok(params) => params,
295 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
296 };
297 match run_action(sessions, params).await {
298 Ok(result) => rpc_result(request.id, &result),
299 Err(error) => rpc_error(request.id, -32603, &error.to_string()),
300 }
301 }
302 "session.list" => {
303 let entries = sessions.lock().await.values().cloned().collect::<Vec<_>>();
304 let mut infos = Vec::with_capacity(entries.len());
305 for session in entries {
306 if let Some(info) = session_info(&session).await {
307 infos.push(info);
308 }
309 }
310 infos.sort_by(|left, right| left.thread_id.cmp(&right.thread_id));
311 rpc_result(request.id, &infos)
312 }
313 "session.info" => {
314 let params: SessionParams = match serde_json::from_value(request.params) {
315 Ok(params) => params,
316 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
317 };
318 let session =
319 sessions.lock().await.get(&normalize_thread_id(¶ms.thread_id)).cloned();
320 match session {
321 Some(session) => match session_info(&session).await {
322 Some(info) => rpc_result(request.id, &info),
323 None => rpc_error(request.id, -32004, "session not initialized"),
324 },
325 None => rpc_error(request.id, -32004, "session not found"),
326 }
327 }
328 "session.read_output" => {
329 let params: JournalReadParams = match serde_json::from_value(request.params) {
330 Ok(params) => params,
331 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
332 };
333 if let Err(error) = validate_identifier("consumer_id", ¶ms.consumer_id) {
334 return rpc_error(request.id, -32602, &error.to_string());
335 }
336 let thread_id = normalize_thread_id(¶ms.thread_id);
337 let session = sessions.lock().await.get(&thread_id).cloned();
338 match session {
339 Some(session) => {
340 session.note_activity(false);
341 capture_session_outputs(&session).await;
342 let read = session.journal.lock().await.read(¶ms.consumer_id);
343 rpc_result(request.id, &read)
344 }
345 None => rpc_error(request.id, -32004, "session not found"),
346 }
347 }
348 "session.kill" => {
349 let params: SessionParams = match serde_json::from_value(request.params) {
350 Ok(params) => params,
351 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
352 };
353 let session = sessions.lock().await.remove(&normalize_thread_id(¶ms.thread_id));
354 match session {
355 Some(session) => {
356 let background_ids = session.background_ids.lock().await.clone();
357 {
358 let mut store = lock_session_store();
359 for id in background_ids {
360 store.remove_shell(&id);
361 }
362 }
363 *session.state.lock().await = None;
364 rpc_result(request.id, &true)
365 }
366 None => rpc_result(request.id, &false),
367 }
368 }
369 "session.interrupt" => {
370 let params: SessionParams = match serde_json::from_value(request.params) {
371 Ok(params) => params,
372 Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
373 };
374 let session =
375 sessions.lock().await.get(&normalize_thread_id(¶ms.thread_id)).cloned();
376 match session {
377 Some(session) => match interrupt_session(&session).await {
378 Ok(()) => rpc_result(request.id, &true),
379 Err(error) => rpc_error(request.id, -32005, &error.to_string()),
380 },
381 None => rpc_error(request.id, -32004, "session not found"),
382 }
383 }
384 _ => rpc_error(request.id, -32601, "method not found"),
385 }
386}
387
388async fn configure_session(
389 sessions: &Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
390 params: ConfigureSessionParams,
391) -> Result<ConfigureSessionResult> {
392 let thread_id = normalize_thread_id(¶ms.snapshot.chat_id);
393 if thread_id.is_empty() {
394 return Err(WinxError::ThreadIdMismatch(
395 "daemon session configuration requires an explicit thread_id".to_string(),
396 ));
397 }
398
399 let (session, entry_created) = {
400 let mut sessions = sessions.lock().await;
401 match params.transition {
402 ConfigureSessionTransition::FirstCall => match sessions.entry(thread_id) {
403 std::collections::hash_map::Entry::Occupied(entry) => (entry.get().clone(), false),
404 std::collections::hash_map::Entry::Vacant(entry) => {
405 let session = Arc::new(DaemonSession::new());
406 entry.insert(session.clone());
407 (session, true)
408 }
409 },
410 _ => (
411 sessions.get(&thread_id).cloned().ok_or(WinxError::BashStateNotInitialized)?,
412 false,
413 ),
414 }
415 };
416 ensure_drainer(&session);
417 session.note_activity(false);
418
419 let (attach_hint, snapshot, attached_existing) = {
420 let mut guard = session.state.lock().await;
421 let mut attached_existing = false;
422 match params.transition {
423 ConfigureSessionTransition::FirstCall => {
424 if entry_created || guard.is_none() {
425 let mut state = BashState::new();
426 state.apply_snapshot(¶ms.snapshot);
427 if state.cwd.exists() {
428 state.init_pty_shell().await?;
429 }
430 *guard = Some(state);
431 } else {
432 attached_existing = true;
435 }
436 }
437 ConfigureSessionTransition::ModeChange => {
438 let state = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
439 let daemon_cwd = state.cwd.clone();
440 state.apply_snapshot(¶ms.snapshot);
441 state.cwd = daemon_cwd;
442 }
443 ConfigureSessionTransition::Reset => {
444 let state = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
445 let daemon_cwd = state.cwd.clone();
446 state.apply_snapshot(¶ms.snapshot);
447 state.cwd = daemon_cwd;
448 state.init_pty_shell().await?;
449 }
450 ConfigureSessionTransition::WorkspaceChange => {
451 let state = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
452 state.apply_snapshot(¶ms.snapshot);
453 }
454 }
455 let state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
456 let attach_hint = {
457 let shell = state.pty_shell.lock().await;
458 shell.as_ref().and_then(|shell| shell.attach_hint.clone())
459 };
460 (attach_hint, state.snapshot(), attached_existing)
461 };
462
463 if matches!(params.transition, ConfigureSessionTransition::Reset)
464 || (matches!(params.transition, ConfigureSessionTransition::FirstCall)
465 && !attached_existing)
466 {
467 *session.command_id.lock().await = None;
468 session.completed.lock().await.clear();
469 session.delivery_cursors.lock().await.clear();
470 session.observed.lock().await.remove("main");
471 }
472 capture_session_outputs(&session).await;
473 session.activity.notify_one();
474 Ok(ConfigureSessionResult { attach_hint, snapshot: Some(snapshot), attached_existing })
475}
476
477async fn run_action(
478 sessions: &Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
479 params: RunActionParams,
480) -> Result<RunActionResult> {
481 validate_identifier("request_key", ¶ms.request_key)?;
482 validate_identifier("consumer_id", ¶ms.consumer_id)?;
483 let thread_id = normalize_thread_id(¶ms.command.thread_id);
484 if thread_id.is_empty() {
485 return Err(WinxError::ThreadIdMismatch(
486 "daemon requests require an explicit thread_id".to_string(),
487 ));
488 }
489
490 let is_command =
491 matches!(¶ms.command.action_json, crate::types::BashCommandAction::Command { .. });
492 let session = {
493 let mut sessions = sessions.lock().await;
494 sessions.entry(thread_id.clone()).or_insert_with(|| Arc::new(DaemonSession::new())).clone()
495 };
496 ensure_drainer(&session);
497 session.note_activity(is_command);
498
499 let now = Instant::now();
500 let cached = {
501 let mut completed = session.completed.lock().await;
502 prune_timed_entries(&mut completed, now);
503 completed.get_mut(¶ms.request_key).map(|entry| {
504 entry.last_seen = now;
505 entry.value.clone()
506 })
507 };
508 if let Some(cached) = cached {
509 return Ok(cached);
510 }
511
512 {
513 let mut guard = session.state.lock().await;
514 let state = if let Some(state) = guard.as_mut() {
515 let daemon_cwd = state.cwd.clone();
516 state.apply_snapshot(¶ms.snapshot);
517 state.cwd = daemon_cwd;
518 state
519 } else {
520 let mut state = BashState::new();
521 state.apply_snapshot(¶ms.snapshot);
522 guard.insert(state)
523 };
524 let needs_shell = state.pty_shell.lock().await.is_none();
525 if needs_shell && state.cwd.exists() {
526 state.init_pty_shell().await?;
527 }
528 }
529
530 if is_command {
531 *session.command_id.lock().await = Some(params.request_key.clone());
532 }
533 let consumer_id = if params.consumer_id.is_empty() {
534 "legacy-adapter".to_string()
535 } else {
536 params.consumer_id
537 };
538 let cursor_key = delivery_cursor_key(&consumer_id, ¶ms.command.action_json);
539 let cursor = {
540 let now = Instant::now();
541 let mut cursors = session.delivery_cursors.lock().await;
542 prune_timed_entries(&mut cursors, now);
543 evict_oldest_if_full(&mut cursors, &cursor_key);
544 let entry = cursors.entry(cursor_key).or_insert_with(|| TimedEntry {
545 value: Arc::new(Mutex::new(ShellDeliveryCursor::default())),
546 last_seen: now,
547 });
548 entry.last_seen = now;
549 entry.value.clone()
550 };
551 let outcome = crate::tools::bash_command::handle_embedded_tool_call_with_cursor(
552 &session.state,
553 params.command,
554 &cursor,
555 )
556 .await;
557 if let Ok(output) = &outcome {
558 if let Some(id) = output.lines().find_map(|line| line.strip_prefix("bg_command_id = ")) {
559 session.background_ids.lock().await.insert(id.to_string());
560 }
561 }
562 capture_session_outputs(&session).await;
563 let snapshot =
564 session.state.lock().await.as_ref().ok_or(WinxError::BashStateNotInitialized)?.snapshot();
565 let result = match outcome {
566 Ok(output) => RunActionResult { output: Some(output), snapshot, error: None },
567 Err(error) => RunActionResult { output: None, snapshot, error: Some(to_wire_error(error)) },
568 };
569
570 let mut completed = session.completed.lock().await;
571 let now = Instant::now();
572 prune_timed_entries(&mut completed, now);
573 evict_oldest_if_full(&mut completed, ¶ms.request_key);
574 completed.insert(params.request_key, TimedEntry { value: result.clone(), last_seen: now });
575 drop(completed);
576 session.activity.notify_one();
577 Ok(result)
578}
579
580fn delivery_cursor_key(consumer_id: &str, action: &crate::types::BashCommandAction) -> String {
581 let background_id = match action {
582 crate::types::BashCommandAction::StatusCheck { bg_command_id, .. }
583 | crate::types::BashCommandAction::SendText { bg_command_id, .. }
584 | crate::types::BashCommandAction::SendSpecials { bg_command_id, .. }
585 | crate::types::BashCommandAction::SendAscii { bg_command_id, .. }
586 | crate::types::BashCommandAction::Screen { bg_command_id, .. }
587 | crate::types::BashCommandAction::WaitForTurn { bg_command_id, .. } => {
588 bg_command_id.as_deref()
589 }
590 crate::types::BashCommandAction::Command { .. } => None,
591 };
592 match background_id {
593 Some(id) => format!("{consumer_id}:background:{id}"),
594 None => format!("{consumer_id}:main"),
595 }
596}
597
598fn ensure_drainer(session: &Arc<DaemonSession>) {
599 if session.drainer_started.swap(true, Ordering::AcqRel) {
600 session.activity.notify_one();
601 return;
602 }
603 let session = session.clone();
604 tokio::spawn(async move {
605 loop {
606 if Arc::strong_count(&session) == 1 {
607 return;
608 }
609 let capture = capture_session_outputs(&session).await;
610 tokio::select! {
611 () = tokio::time::sleep(capture.next_delay()) => {}
612 () = session.activity.notified() => {}
613 }
614 }
615 });
616}
617
618async fn capture_session_outputs(session: &Arc<DaemonSession>) -> CaptureState {
619 let (thread_id, main) = {
620 let state = session.state.lock().await;
621 let Some(state) = state.as_ref() else { return CaptureState::default() };
622 (state.current_thread_id.clone(), state.pty_shell.clone())
623 };
624 let mut capture = capture_shell_output(session, "main", &main).await;
625
626 let background_ids = session.background_ids.lock().await.clone();
627 for id in background_ids {
628 let shell = {
629 let store = lock_session_store();
630 store.resolve(&thread_id, &ShellTarget::Background(id.clone()))
631 };
632 if let Some(shell) = shell {
633 capture.merge(capture_shell_output(session, &id, &shell).await);
634 }
635 }
636 capture
637}
638
639async fn capture_shell_output(
640 session: &Arc<DaemonSession>,
641 terminal_id: &str,
642 shell: &SharedPtyShell,
643) -> CaptureState {
644 let (generation, snapshot, active) = {
645 let mut guard = shell.lock().await;
646 let Some(shell) = guard.as_mut() else { return CaptureState::default() };
647 shell.poll_output_nonblocking();
648 (shell.command_generation(), shell.output_snapshot(), shell.command_running)
649 };
650 let delta = {
651 let mut observed = session.observed.lock().await;
652 let (previous_generation, previous) =
653 observed.entry(terminal_id.to_string()).or_insert_with(|| (generation, String::new()));
654 if *previous_generation != generation {
655 *previous_generation = generation;
656 previous.clear();
657 }
658 let delta = snapshot
659 .strip_prefix(previous.as_str())
660 .map_or_else(|| snapshot.clone(), str::to_string);
661 previous.clone_from(&snapshot);
662 delta
663 };
664 let changed = !delta.is_empty();
665 if changed {
666 session.last_activity_unix_ms.store(unix_ms(), Ordering::Release);
667 session.journal.lock().await.append(delta);
668 }
669 CaptureState { changed, active }
670}
671
672async fn session_info(session: &Arc<DaemonSession>) -> Option<SessionInfo> {
673 let (thread_id, cwd, main) = {
674 let state = session.state.lock().await;
675 let state = state.as_ref()?;
676 (
677 state.current_thread_id.clone(),
678 state.cwd.to_string_lossy().into_owned(),
679 state.pty_shell.clone(),
680 )
681 };
682 let (shell_pid, running) = {
683 let guard = main.lock().await;
684 guard.as_ref().map_or((None, false), |shell| (shell.process_id(), shell.command_running))
685 };
686 let background_command_ids = active_background_command_ids(session, &thread_id).await;
687 let last_command_at_unix_ms = session.last_command_at_unix_ms.load(Ordering::Acquire);
688 Some(SessionInfo {
689 thread_id,
690 cwd,
691 shell_pid,
692 command_id: session.command_id.lock().await.clone(),
693 running,
694 background_command_ids,
695 created_at_unix_ms: Some(session.created_at_unix_ms),
696 last_activity_unix_ms: Some(session.last_activity_unix_ms.load(Ordering::Acquire)),
697 last_command_at_unix_ms: (last_command_at_unix_ms > 0).then_some(last_command_at_unix_ms),
698 ever_ran_command: session.ever_ran_command.load(Ordering::Acquire),
699 })
700}
701
702async fn active_background_command_ids(
703 session: &Arc<DaemonSession>,
704 thread_id: &str,
705) -> Vec<String> {
706 let candidates = session.background_ids.lock().await.clone();
707 let mut active = Vec::new();
708 for id in candidates {
709 let shell = {
710 let store = lock_session_store();
711 store.resolve(thread_id, &ShellTarget::Background(id.clone()))
712 };
713 let Some(shell) = shell else { continue };
714 capture_shell_output(session, &id, &shell).await;
718 let running = {
719 let guard = shell.lock().await;
720 guard.as_ref().is_some_and(|shell| shell.command_running)
721 };
722 if running {
723 active.push(id);
724 }
725 }
726
727 let active_set = active.iter().cloned().collect::<HashSet<_>>();
728 session.background_ids.lock().await.retain(|id| active_set.contains(id));
729 active.sort();
730 active
731}
732
733async fn interrupt_session(session: &Arc<DaemonSession>) -> Result<()> {
734 session.note_activity(false);
735 let shell = {
736 let state = session.state.lock().await;
737 state.as_ref().map(|state| state.pty_shell.clone())
738 };
739 let Some(shell) = shell else { return Ok(()) };
740 {
741 let mut guard = shell.lock().await;
742 if let Some(shell) = guard.as_mut() {
743 shell.send_interrupt().map_err(|error| {
744 WinxError::CommandExecutionError(format!("failed to interrupt shell: {error}"))
745 })?;
746 }
747 }
748 let deadline = std::time::Instant::now() + Duration::from_secs(3);
749 while std::time::Instant::now() < deadline {
750 let recovered = {
751 let mut guard = shell.lock().await;
752 match guard.as_mut() {
753 Some(shell) => shell.poll_output_nonblocking() || !shell.command_running,
754 None => true,
755 }
756 };
757 if recovered {
758 capture_session_outputs(session).await;
759 return Ok(());
760 }
761 tokio::time::sleep(Duration::from_millis(50)).await;
762 }
763 Err(WinxError::CommandExecutionError(
764 "interrupted shell did not return to a prompt within 3 seconds".to_string(),
765 ))
766}
767
768fn unix_ms() -> u64 {
769 SystemTime::now()
770 .duration_since(UNIX_EPOCH)
771 .ok()
772 .and_then(|duration| u64::try_from(duration.as_millis()).ok())
773 .unwrap_or(0)
774}
775
776fn to_wire_error(error: WinxError) -> WireShellError {
777 match error {
778 WinxError::BashStateNotInitialized => WireShellError::BashStateNotInitialized,
779 WinxError::ShellInitializationError(message) => {
780 WireShellError::ShellInitialization(message)
781 }
782 WinxError::CommandExecutionError(message) => WireShellError::CommandExecution(message),
783 WinxError::NoActiveCommand(message) => WireShellError::NoActiveCommand(message),
784 WinxError::BackgroundSessionNotFound(message) => {
785 WireShellError::BackgroundSessionNotFound(message)
786 }
787 WinxError::EmptyInteractiveInput { action } => {
788 WireShellError::EmptyInteractiveInput(action)
789 }
790 WinxError::InteractiveTargetNotRunning(message) => {
791 WireShellError::InteractiveTargetNotRunning(message)
792 }
793 WinxError::CommandAlreadyRunning { current_command, duration_seconds } => {
794 WireShellError::CommandAlreadyRunning { current_command, duration_seconds }
795 }
796 WinxError::CommandNotAllowed(message) => WireShellError::CommandNotAllowed(message),
797 WinxError::ThreadIdMismatch(message) => WireShellError::ThreadIdMismatch(message),
798 WinxError::InvalidInput(message) | WinxError::ArgumentParseError(message) => {
799 WireShellError::InvalidInput(message)
800 }
801 other => WireShellError::Other(other.to_string()),
802 }
803}
804
805fn rpc_result<T: serde::Serialize>(id: u64, result: &T) -> RpcResponse {
806 match serde_json::to_value(result) {
807 Ok(result) => {
808 RpcResponse { jsonrpc: "2.0".to_string(), id, result: Some(result), error: None }
809 }
810 Err(error) => rpc_error(id, -32603, &error.to_string()),
811 }
812}
813
814fn rpc_error(id: u64, code: i32, message: &str) -> RpcResponse {
815 RpcResponse {
816 jsonrpc: "2.0".to_string(),
817 id,
818 result: None,
819 error: Some(RpcError { code, message: message.to_string() }),
820 }
821}
822
823fn same_uid(stream: &UnixStream) -> Result<bool> {
824 let credentials = stream.peer_cred()?;
825 Ok(credentials.uid() == crate::os::unix::effective_uid())
826}
827
828#[cfg(test)]
829mod tests {
830 use super::{validate_identifier, CaptureState, OutputJournal, MAX_SESSION_CACHE_ENTRIES};
831
832 #[test]
833 fn late_consumer_is_told_when_journal_head_was_dropped() {
834 let mut journal = OutputJournal::default();
835 journal.append("a".repeat(3 * 1024 * 1024));
836 journal.append("b".repeat(3 * 1024 * 1024));
837
838 let read = journal.read("late");
839 assert!(read.gap);
840 assert!(read.output.starts_with('b'));
841 }
842
843 #[test]
844 fn journal_consumer_cursors_are_lru_bounded() {
845 let mut journal = OutputJournal::default();
846 journal.append("output".to_string());
847 for index in 0..(MAX_SESSION_CACHE_ENTRIES + 100) {
848 let _ = journal.read(&format!("consumer-{index}"));
849 }
850 assert_eq!(journal.cursors.len(), MAX_SESSION_CACHE_ENTRIES);
851 }
852
853 #[test]
854 fn daemon_identifiers_have_a_small_memory_bound() {
855 assert!(validate_identifier("consumer_id", &"x".repeat(256)).is_ok());
856 assert!(validate_identifier("consumer_id", &"x".repeat(257)).is_err());
857 }
858
859 #[test]
860 fn drainer_backoff_tracks_output_and_command_activity() {
861 assert!(
862 CaptureState { changed: true, active: false }.next_delay()
863 < CaptureState { changed: false, active: true }.next_delay()
864 );
865 assert!(
866 CaptureState { changed: false, active: true }.next_delay()
867 < CaptureState { changed: false, active: false }.next_delay()
868 );
869 }
870}