1use super::protocol::{
2 binary_identity, encode_line, Request, Response, TerminalClientMessage, TerminalServerMessage,
3 MAX_RESPONSE_BYTES, PROTOCOL_VERSION,
4};
5use std::{
6 fs::{self, File},
7 io::{self, BufRead, BufReader, Read, Seek, SeekFrom, Write},
8 os::unix::{
9 fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt},
10 io::AsRawFd,
11 net::UnixStream,
12 process::CommandExt,
13 },
14 path::{Path, PathBuf},
15 process::{Child, Command, Stdio},
16 sync::{
17 atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
18 mpsc, Arc,
19 },
20 thread,
21 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
22};
23
24const IO_TIMEOUT: Duration = Duration::from_secs(5);
25const START_WINDOW: Duration = Duration::from_secs(60);
26const MAX_START_ATTEMPTS: usize = 3;
27static ACTIVE_TUI_MONITORS: AtomicUsize = AtomicUsize::new(0);
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Availability {
31 Current,
32 RecoveredFromBackup,
33 LegacyCompatible,
34 NewerDaemon {
35 daemon_version: String,
36 },
37 DaemonReplaced {
38 previous_version: String,
39 },
40 DaemonRestarted,
41 ReplacementDeferred {
42 daemon_version: String,
43 target_version: String,
44 live_runtimes: usize,
45 blockers: Vec<super::domain::ReplacementBlocker>,
46 },
47}
48
49#[derive(Debug, Clone)]
50pub struct Client {
51 socket: PathBuf,
52 automatic_start: bool,
53}
54impl Client {
55 pub fn local() -> Self {
56 Self {
57 socket: super::protocol::default_socket_path(),
58 automatic_start: true,
59 }
60 }
61 pub fn new(socket: impl Into<PathBuf>) -> Self {
62 Self {
63 socket: socket.into(),
64 automatic_start: false,
65 }
66 }
67 pub fn socket(&self) -> &Path {
68 &self.socket
69 }
70
71 pub fn shutdown(&self) -> io::Result<()> {
73 match probe_existing_daemon(self)? {
74 ExistingDaemon::Missing => Ok(()),
75 ExistingDaemon::Ready { .. } => match self.call(&Request::Shutdown) {
76 Ok(Response::Ack { .. }) => wait_until_stopped(self),
77 Ok(Response::Error(error)) => Err(io::Error::other(format!(
78 "{}: {}",
79 error.code, error.message
80 ))),
81 Ok(_) => Err(io::Error::new(
82 io::ErrorKind::InvalidData,
83 "unexpected wsxd shutdown response",
84 )),
85 Err(error) if daemon_is_stopped_error(&error) => Ok(()),
86 Err(error) => Err(error),
87 },
88 ExistingDaemon::Incompatible {
89 stream,
90 advertised_protocol,
91 ..
92 } => {
93 shutdown_incompatible_daemon(self, stream, advertised_protocol)?;
94 wait_until_stopped(self)
95 }
96 }
97 }
98
99 pub fn call(&self, request: &Request) -> io::Result<Response> {
100 let mut stream = self.connect()?;
101 if !matches!(request, Request::Hello { .. }) {
102 validate_hello(round_trip(
103 &mut stream,
104 &Request::Hello {
105 protocol: PROTOCOL_VERSION,
106 },
107 )?)?;
108 }
109 round_trip(&mut stream, request)
110 }
111
112 fn connect(&self) -> io::Result<UnixStream> {
113 validate_socket(&self.socket)?;
114 let stream = UnixStream::connect(&self.socket)?;
115 stream.set_read_timeout(Some(IO_TIMEOUT))?;
116 stream.set_write_timeout(Some(IO_TIMEOUT))?;
117 validate_peer_owner(&stream)?;
118 Ok(stream)
119 }
120}
121
122struct Handshake {
123 epoch: u64,
124}
125
126fn validate_hello(response: Response) -> io::Result<Handshake> {
127 match response {
128 Response::Hello {
129 protocol, epoch, ..
130 } if protocol == PROTOCOL_VERSION => Ok(Handshake { epoch }),
131 Response::Hello { protocol, .. } => Err(io::Error::other(format!(
132 "protocol_mismatch: client {PROTOCOL_VERSION}, daemon {protocol}"
133 ))),
134 Response::Error(error) => Err(io::Error::other(format!(
135 "{}: {}",
136 error.code, error.message
137 ))),
138 _ => Err(io::Error::new(
139 io::ErrorKind::InvalidData,
140 "wsxd protocol handshake failed",
141 )),
142 }
143}
144
145fn round_trip(stream: &mut UnixStream, request: &Request) -> io::Result<Response> {
146 stream.write_all(&encode_line(request).map_err(io::Error::other)?)?;
147 stream.flush()?;
148 read_json_line(stream, MAX_RESPONSE_BYTES)
149}
150
151fn round_trip_buffered(
152 writer: &mut UnixStream,
153 reader: &mut impl BufRead,
154 request: &Request,
155) -> io::Result<Response> {
156 writer.write_all(&encode_line(request).map_err(io::Error::other)?)?;
157 writer.flush()?;
158 read_buffered_json_line(reader, MAX_RESPONSE_BYTES)
159}
160
161fn read_buffered_json_line<T: serde::de::DeserializeOwned>(
162 reader: &mut impl BufRead,
163 limit: usize,
164) -> io::Result<T> {
165 let mut response = Vec::with_capacity(4096);
166 let read = reader
167 .take((limit + 1) as u64)
168 .read_until(b'\n', &mut response)?;
169 if read == 0 {
170 return Err(io::Error::new(
171 io::ErrorKind::UnexpectedEof,
172 "daemon closed response",
173 ));
174 }
175 if response.len() > limit || response.last() != Some(&b'\n') {
176 return Err(io::Error::new(
177 io::ErrorKind::InvalidData,
178 "daemon response is incomplete or exceeds limit",
179 ));
180 }
181 response.pop();
182 serde_json::from_slice(&response)
183 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
184}
185
186fn read_json_line<T: serde::de::DeserializeOwned>(
187 reader: &mut impl Read,
188 limit: usize,
189) -> io::Result<T> {
190 read_buffered_json_line(&mut BufReader::new(reader), limit)
191}
192
193const TERMINAL_INPUT_QUEUE: usize = 1024;
194const TERMINAL_UPDATE_QUEUE: usize = 64;
195
196pub struct TerminalStream {
197 epoch: u64,
198 input: mpsc::SyncSender<TerminalClientMessage>,
199 updates: mpsc::Receiver<TerminalServerMessage>,
200 resync: Arc<AtomicBool>,
201 stopping: Arc<AtomicBool>,
202 socket: UnixStream,
203 writer: Option<thread::JoinHandle<()>>,
204 reader: Option<thread::JoinHandle<()>>,
205}
206
207impl TerminalStream {
208 pub fn connect(
209 client: &Client,
210 pane_id: super::domain::PaneId,
211 client_id: u64,
212 takeover: bool,
213 rows: u16,
214 cols: u16,
215 ) -> io::Result<Self> {
216 let mut stream = client.connect()?;
217 let mut reader = BufReader::with_capacity(64 * 1024, stream.try_clone()?);
218 let handshake = validate_hello(round_trip_buffered(
219 &mut stream,
220 &mut reader,
221 &Request::Hello {
222 protocol: PROTOCOL_VERSION,
223 },
224 )?)?;
225 match round_trip_buffered(
226 &mut stream,
227 &mut reader,
228 &Request::TerminalSubscribe {
229 pane_id,
230 client_id,
231 takeover,
232 rows,
233 cols,
234 },
235 )? {
236 Response::Ack { .. } => {}
237 Response::Error(error) => {
238 return Err(io::Error::other(format!(
239 "{}: {}",
240 error.code, error.message
241 )))
242 }
243 _ => {
244 return Err(io::Error::new(
245 io::ErrorKind::InvalidData,
246 "unexpected terminal stream response",
247 ))
248 }
249 }
250 stream.set_read_timeout(None)?;
251 let socket = stream.try_clone()?;
252 let write_stream = stream.try_clone()?;
253 let (input_tx, input_rx) = mpsc::sync_channel(TERMINAL_INPUT_QUEUE);
254 let (update_tx, update_rx) = mpsc::sync_channel(TERMINAL_UPDATE_QUEUE);
255 let resync = Arc::new(AtomicBool::new(false));
256 let stopping = Arc::new(AtomicBool::new(false));
257 let writer_resync = Arc::clone(&resync);
258 let writer_stop = Arc::clone(&stopping);
259 let writer = thread::Builder::new()
260 .name("wsx-terminal-writer".into())
261 .spawn(move || terminal_writer(write_stream, input_rx, &writer_resync, &writer_stop))?;
262 let reader_stop = Arc::clone(&stopping);
263 let reader = thread::Builder::new()
264 .name("wsx-terminal-reader".into())
265 .spawn(move || terminal_reader(reader, update_tx, &reader_stop))?;
266 Ok(Self {
267 epoch: handshake.epoch,
268 input: input_tx,
269 updates: update_rx,
270 resync,
271 stopping,
272 socket,
273 writer: Some(writer),
274 reader: Some(reader),
275 })
276 }
277
278 pub fn epoch(&self) -> u64 {
279 self.epoch
280 }
281
282 pub fn request_resync(&self) {
283 self.resync.store(true, Ordering::Release);
284 }
285
286 pub fn try_send(
287 &self,
288 message: TerminalClientMessage,
289 ) -> Result<(), mpsc::TrySendError<TerminalClientMessage>> {
290 self.input.try_send(message)
291 }
292
293 pub fn try_recv(&self) -> Result<TerminalServerMessage, mpsc::TryRecvError> {
294 self.updates.try_recv()
295 }
296}
297
298impl Drop for TerminalStream {
299 fn drop(&mut self) {
300 let _ = self.input.try_send(TerminalClientMessage::Detach);
301 self.stopping.store(true, Ordering::Release);
302 let _ = self.socket.shutdown(std::net::Shutdown::Both);
303 if let Some(thread) = self.writer.take() {
304 let _ = thread.join();
305 }
306 if let Some(thread) = self.reader.take() {
307 let _ = thread.join();
308 }
309 }
310}
311
312fn terminal_writer(
313 mut stream: UnixStream,
314 input: mpsc::Receiver<TerminalClientMessage>,
315 resync: &AtomicBool,
316 stopping: &AtomicBool,
317) {
318 let mut last_heartbeat = Instant::now();
319 while !stopping.load(Ordering::Acquire) {
320 let message = if resync.swap(false, Ordering::AcqRel) {
321 Some(TerminalClientMessage::Resync)
322 } else {
323 match input.recv_timeout(Duration::from_millis(100)) {
324 Ok(message) => Some(message),
325 Err(mpsc::RecvTimeoutError::Timeout)
326 if last_heartbeat.elapsed() >= Duration::from_secs(1) =>
327 {
328 Some(TerminalClientMessage::Heartbeat)
329 }
330 Err(mpsc::RecvTimeoutError::Timeout) => None,
331 Err(mpsc::RecvTimeoutError::Disconnected) => break,
332 }
333 };
334 let Some(message) = message else { continue };
335 if matches!(&message, TerminalClientMessage::Heartbeat) {
336 last_heartbeat = Instant::now();
337 }
338 let bytes = match encode_line(&message) {
339 Ok(bytes) => bytes,
340 Err(_) => break,
341 };
342 if stream.write_all(&bytes).is_err() || stream.flush().is_err() {
343 break;
344 }
345 if matches!(&message, TerminalClientMessage::Detach) {
346 break;
347 }
348 }
349}
350
351fn terminal_reader(
352 mut reader: BufReader<UnixStream>,
353 updates: mpsc::SyncSender<TerminalServerMessage>,
354 stopping: &AtomicBool,
355) {
356 while !stopping.load(Ordering::Acquire) {
357 let message = match read_buffered_json_line(&mut reader, MAX_RESPONSE_BYTES) {
358 Ok(message) => message,
359 Err(error) => {
360 let _ = updates.try_send(TerminalServerMessage::Error(
361 super::protocol::ApiError::new("stream_disconnected", error.to_string()),
362 ));
363 break;
364 }
365 };
366 let terminal = matches!(
367 &message,
368 TerminalServerMessage::Error(_) | TerminalServerMessage::Exited
369 );
370 if !queue_terminal_update(&updates, message, stopping) || terminal {
371 break;
372 }
373 }
374 stopping.store(true, Ordering::Release);
375}
376
377fn queue_terminal_update(
378 updates: &mpsc::SyncSender<TerminalServerMessage>,
379 mut message: TerminalServerMessage,
380 stopping: &AtomicBool,
381) -> bool {
382 loop {
385 match updates.try_send(message) {
386 Ok(()) => return true,
387 Err(mpsc::TrySendError::Full(pending)) => {
388 if stopping.load(Ordering::Acquire) {
389 return false;
390 }
391 message = pending;
392 thread::sleep(Duration::from_millis(10));
393 }
394 Err(mpsc::TrySendError::Disconnected(_)) => return false,
395 }
396 }
397}
398
399pub fn new_client_id() -> u64 {
400 static NEXT: AtomicU64 = AtomicU64::new(1);
401 let time = SystemTime::now()
402 .duration_since(UNIX_EPOCH)
403 .unwrap_or_default()
404 .as_nanos() as u64;
405 time ^ (u64::from(std::process::id()) << 32) ^ NEXT.fetch_add(1, Ordering::Relaxed)
406}
407
408fn validate_socket(path: &Path) -> io::Result<()> {
409 if !path.is_absolute() {
410 return Err(io::Error::new(
411 io::ErrorKind::InvalidInput,
412 "wsxd socket path must be absolute",
413 ));
414 }
415 let metadata = std::fs::symlink_metadata(path)?;
416 if !metadata.file_type().is_socket()
417 || metadata.uid() != unsafe { libc::geteuid() }
418 || metadata.mode() & 0o077 != 0
419 {
420 return Err(io::Error::new(
421 io::ErrorKind::PermissionDenied,
422 "wsxd socket is not an owner-only same-user socket",
423 ));
424 }
425 Ok(())
426}
427
428#[cfg(target_os = "macos")]
432#[repr(C)]
433#[derive(Clone, Copy)]
434struct AuditToken {
435 value: [u32; 8],
436}
437
438#[cfg(target_os = "macos")]
439#[link(name = "bsm")]
440extern "C" {
441 fn audit_token_to_euid(token: AuditToken) -> libc::uid_t;
442}
443
444#[cfg(target_os = "macos")]
445fn peer_audit_token(stream: &UnixStream) -> io::Result<AuditToken> {
446 let mut token = AuditToken { value: [0; 8] };
447 let mut length = std::mem::size_of::<AuditToken>() as libc::socklen_t;
448 let result = unsafe {
450 libc::getsockopt(
451 stream.as_raw_fd(),
452 libc::SOL_LOCAL,
453 libc::LOCAL_PEERTOKEN,
454 &mut token as *mut AuditToken as *mut libc::c_void,
455 &mut length,
456 )
457 };
458 if result != 0 {
459 return Err(io::Error::last_os_error());
460 }
461 if length as usize != std::mem::size_of::<AuditToken>() {
462 return Err(io::Error::new(
463 io::ErrorKind::InvalidData,
464 "wsxd peer returned an invalid audit token",
465 ));
466 }
467 Ok(token)
468}
469
470#[cfg(target_os = "macos")]
471fn validate_peer_owner(stream: &UnixStream) -> io::Result<()> {
472 let token = peer_audit_token(stream)?;
473 if unsafe { audit_token_to_euid(token) } != unsafe { libc::geteuid() } {
475 return Err(io::Error::new(
476 io::ErrorKind::PermissionDenied,
477 "wsxd socket peer belongs to another user",
478 ));
479 }
480 Ok(())
481}
482
483#[cfg(not(target_os = "macos"))]
484fn validate_peer_owner(_stream: &UnixStream) -> io::Result<()> {
485 Ok(())
486}
487
488pub fn ensure_available() -> io::Result<Availability> {
489 ensure_available_with(&Client::local(), false)
490}
491
492pub fn recover_daemon() -> io::Result<Availability> {
493 ensure_available_with(&Client::local(), true)
494}
495
496pub fn ensure_background_available() -> io::Result<Availability> {
497 ensure_background_available_with(&Client::local())
498}
499
500fn ensure_background_available_with(client: &Client) -> io::Result<Availability> {
501 if !client.automatic_start {
502 return Err(io::Error::new(
503 io::ErrorKind::Unsupported,
504 "automatic recovery is disabled for a custom wsxd client",
505 ));
506 }
507 if !client.socket().exists() && !background_recovery_allowed(client.socket()) {
508 return Err(io::Error::new(
509 io::ErrorKind::ConnectionAborted,
510 "wsxd was stopped intentionally",
511 ));
512 }
513 ensure_available_with(client, false)
514}
515
516fn ensure_available_with(client: &Client, reset_crash_budget: bool) -> io::Result<Availability> {
517 ensure_available_with_binary(client, reset_crash_budget, &daemon_binary())
518}
519
520fn ensure_available_with_binary(
521 client: &Client,
522 reset_crash_budget: bool,
523 binary: &Path,
524) -> io::Result<Availability> {
525 let target_binary_id = binary_identity(binary).ok();
526 let first = probe_existing_daemon(client)?;
527 if let Some(availability) =
528 ready_without_transition(client, &first, target_binary_id.as_deref())?
529 {
530 return Ok(availability);
531 }
532
533 let mut bootstrap = acquire_bootstrap_lock(client.socket())?;
534 let existing = probe_existing_daemon(client)?;
535 if let Some(availability) =
536 ready_without_transition(client, &existing, target_binary_id.as_deref())?
537 {
538 return Ok(availability);
539 }
540
541 match existing {
542 ExistingDaemon::Missing => {
543 let planned = consume_planned_marker(
544 client.socket(),
545 target_binary_id.as_deref(),
546 super::protocol::DAEMON_REVISION,
547 )?;
548 let availability = start_daemon(
549 client,
550 binary,
551 &mut bootstrap,
552 reset_crash_budget || planned != PlannedStart::None,
553 )?;
554 Ok(if planned == PlannedStart::Replacement {
555 Availability::DaemonRestarted
556 } else {
557 availability
558 })
559 }
560 ExistingDaemon::Ready {
561 lifecycle_coordination: true,
562 version_coordination,
563 ..
564 }
565 | ExistingDaemon::Incompatible {
566 lifecycle_coordination: true,
567 version_coordination,
568 ..
569 } => {
570 let target_binary_id = target_binary_id.ok_or_else(|| {
571 io::Error::new(
572 io::ErrorKind::NotFound,
573 format!("could not identify replacement daemon {}", binary.display()),
574 )
575 })?;
576 let status = lifecycle_status(client)?;
577 let daemon_version = lifecycle_version(&status);
578 let target_version = super::protocol::WSX_VERSION.to_string();
579 let compatible = matches!(existing, ExistingDaemon::Ready { .. });
580 if compatible && !version_coordination && legacy_daemon_has_other_clients(&status) {
581 return Ok(Availability::ReplacementDeferred {
582 daemon_version,
583 target_version,
584 live_runtimes: status.live_runtimes,
585 blockers: vec![super::domain::ReplacementBlocker::LegacyDaemon],
586 });
587 }
588 match lifecycle_round_trip(
589 client,
590 &Request::PrepareReplacement {
591 target_binary_id: target_binary_id.clone(),
592 target_daemon_revision: super::protocol::DAEMON_REVISION,
593 },
594 )? {
595 Response::Replacement {
596 disposition: super::domain::ReplacementDisposition::Stopping,
597 ..
598 } => {
599 wait_until_stopped(client)?;
600 consume_planned_marker(
601 client.socket(),
602 Some(&target_binary_id),
603 super::protocol::DAEMON_REVISION,
604 )?;
605 start_daemon(client, binary, &mut bootstrap, true)?;
606 Ok(Availability::DaemonReplaced {
607 previous_version: daemon_version,
608 })
609 }
610 Response::Replacement {
611 disposition: super::domain::ReplacementDisposition::Deferred,
612 live_runtimes,
613 daemon_version: response_daemon_version,
614 target_version: response_target_version,
615 blockers,
616 use_current_daemon,
617 } if compatible => {
618 let daemon_version = nonempty_or(response_daemon_version, daemon_version);
619 if use_current_daemon {
620 Ok(Availability::NewerDaemon { daemon_version })
621 } else {
622 Ok(Availability::ReplacementDeferred {
623 daemon_version,
624 target_version: nonempty_or(response_target_version, target_version),
625 live_runtimes,
626 blockers: if blockers.is_empty() && !version_coordination {
627 vec![super::domain::ReplacementBlocker::LegacyDaemon]
628 } else {
629 blockers
630 },
631 })
632 }
633 }
634 Response::Replacement { live_runtimes, .. } => Err(io::Error::new(
635 io::ErrorKind::AlreadyExists,
636 format!(
637 "replacement_deferred: incompatible wsxd is protecting {live_runtimes} live runtime(s)"
638 ),
639 )),
640 Response::Error(error) if compatible && error.code == "replacement_conflict" => {
641 Ok(Availability::ReplacementDeferred {
642 daemon_version,
643 target_version,
644 live_runtimes: status.live_runtimes,
645 blockers: vec![super::domain::ReplacementBlocker::PendingTarget],
646 })
647 }
648 response => Err(io::Error::new(
649 io::ErrorKind::InvalidData,
650 format!("unexpected wsxd replacement response: {response:?}"),
651 )),
652 }
653 }
654 incompatible @ ExistingDaemon::Incompatible { .. } => {
655 daemon_needs_start(incompatible)?;
656 unreachable!("incompatible daemon must return an error");
657 }
658 ExistingDaemon::Ready { .. } => Ok(Availability::LegacyCompatible),
659 }
660}
661
662fn lifecycle_status(client: &Client) -> io::Result<super::domain::DaemonLifecycle> {
663 match lifecycle_round_trip(client, &Request::LifecycleStatus)? {
664 Response::Lifecycle(status) => Ok(status),
665 response => Err(io::Error::new(
666 io::ErrorKind::InvalidData,
667 format!("unexpected wsxd lifecycle response: {response:?}"),
668 )),
669 }
670}
671
672fn legacy_daemon_has_other_clients(status: &super::domain::DaemonLifecycle) -> bool {
673 active_client_count_has_other_tui(
674 status.active_clients,
675 ACTIVE_TUI_MONITORS.load(Ordering::Acquire),
676 )
677}
678
679fn active_client_count_has_other_tui(active_clients: usize, own_tui_monitors: usize) -> bool {
680 active_clients > 1_usize.saturating_add(own_tui_monitors)
681}
682
683fn lifecycle_version(status: &super::domain::DaemonLifecycle) -> String {
684 if status.version.is_empty() {
685 super::protocol::binary_identity_version(&status.binary_id)
686 .unwrap_or("unknown")
687 .to_string()
688 } else {
689 status.version.clone()
690 }
691}
692
693fn nonempty_or(value: String, fallback: String) -> String {
694 if value.is_empty() {
695 fallback
696 } else {
697 value
698 }
699}
700
701fn ready_without_transition(
702 client: &Client,
703 existing: &ExistingDaemon,
704 target_binary_id: Option<&str>,
705) -> io::Result<Option<Availability>> {
706 let ExistingDaemon::Ready {
707 lifecycle_coordination,
708 daemon_revision_coordination,
709 ..
710 } = existing
711 else {
712 return Ok(None);
713 };
714 if !lifecycle_coordination {
715 return Ok(Some(Availability::LegacyCompatible));
716 }
717 let status = lifecycle_status(client)?;
718 let daemon_version = lifecycle_version(&status);
719 if super::protocol::compare_wsx_versions(&daemon_version, super::protocol::WSX_VERSION)
720 == Some(std::cmp::Ordering::Greater)
721 {
722 return Ok(Some(Availability::NewerDaemon { daemon_version }));
723 }
724 if *daemon_revision_coordination && status.daemon_revision >= super::protocol::DAEMON_REVISION {
725 return Ok(Some(if status.recovered_from_backup {
726 Availability::RecoveredFromBackup
727 } else {
728 Availability::Current
729 }));
730 }
731 if target_binary_id.is_none_or(|target| target == status.binary_id) {
732 Ok(Some(
733 if status.phase == super::domain::DaemonPhase::ReplacementPending {
734 Availability::ReplacementDeferred {
735 daemon_version,
736 target_version: nonempty_or(
737 status.replacement_target_version,
738 super::protocol::WSX_VERSION.to_string(),
739 ),
740 live_runtimes: status.live_runtimes,
741 blockers: status.replacement_blockers,
742 }
743 } else if status.recovered_from_backup {
744 Availability::RecoveredFromBackup
745 } else {
746 Availability::Current
747 },
748 ))
749 } else {
750 Ok(None)
751 }
752}
753
754struct SignalMaskGuard {
755 previous: libc::sigset_t,
756 restored: bool,
757}
758
759impl SignalMaskGuard {
760 fn block(signal: libc::c_int) -> io::Result<Self> {
761 let mut blocked = unsafe { std::mem::zeroed::<libc::sigset_t>() };
762 if unsafe { libc::sigemptyset(&mut blocked) } == -1
763 || unsafe { libc::sigaddset(&mut blocked, signal) } == -1
764 {
765 return Err(io::Error::last_os_error());
766 }
767 let mut previous = unsafe { std::mem::zeroed::<libc::sigset_t>() };
768 let result = unsafe { libc::pthread_sigmask(libc::SIG_BLOCK, &blocked, &mut previous) };
769 if result != 0 {
770 return Err(io::Error::from_raw_os_error(result));
771 }
772 Ok(Self {
773 previous,
774 restored: false,
775 })
776 }
777
778 fn restore(&mut self) -> io::Result<()> {
779 if self.restored {
780 return Ok(());
781 }
782 let result = unsafe {
783 libc::pthread_sigmask(libc::SIG_SETMASK, &self.previous, std::ptr::null_mut())
784 };
785 if result != 0 {
786 return Err(io::Error::from_raw_os_error(result));
787 }
788 self.restored = true;
789 Ok(())
790 }
791}
792
793impl Drop for SignalMaskGuard {
794 fn drop(&mut self) {
795 let _ = self.restore();
796 }
797}
798
799fn spawn_detached(command: &mut Command) -> io::Result<Child> {
800 let mut signal_mask = SignalMaskGuard::block(libc::SIGHUP)?;
801 let child_signal_mask = signal_mask.previous;
802 unsafe {
805 command.pre_exec(move || {
806 if libc::setsid() == -1 {
807 return Err(io::Error::last_os_error());
808 }
809 if libc::signal(libc::SIGHUP, libc::SIG_IGN) == libc::SIG_ERR {
810 return Err(io::Error::last_os_error());
811 }
812 let result =
813 libc::pthread_sigmask(libc::SIG_SETMASK, &child_signal_mask, std::ptr::null_mut());
814 if result != 0 {
815 return Err(io::Error::from_raw_os_error(result));
816 }
817 Ok(())
818 });
819 }
820
821 let child = command.spawn();
822 if let Err(error) = signal_mask.restore() {
823 if let Ok(mut child) = child {
824 let _ = child.kill();
825 let _ = child.wait();
826 }
827 return Err(io::Error::new(
828 error.kind(),
829 format!("could not restore the daemon launcher signal mask: {error}"),
830 ));
831 }
832 child
833}
834
835fn start_daemon(
836 client: &Client,
837 binary: &Path,
838 bootstrap: &mut BootstrapLock,
839 reset_crash_budget: bool,
840) -> io::Result<Availability> {
841 wait_for_singleton_release(client.socket())?;
842 record_start_attempt(&mut bootstrap.file, reset_crash_budget)?;
843 let expected_binary_id = binary_identity(binary).ok();
844 let mut command = Command::new(binary);
845 command
846 .stdin(Stdio::null())
847 .stdout(Stdio::null())
848 .stderr(Stdio::null());
849 let mut child = spawn_detached(&mut command).map_err(|error| {
850 io::Error::new(
851 error.kind(),
852 format!("could not start {}: {error}", binary.display()),
853 )
854 })?;
855 thread::Builder::new()
856 .name("wsxd-reaper".into())
857 .spawn(move || {
858 let _ = child.wait();
859 })?;
860
861 let deadline = Instant::now() + IO_TIMEOUT;
862 loop {
863 match client.call(&Request::Snapshot) {
864 Ok(Response::Snapshot(snapshot)) => {
865 let status = if snapshot.capabilities.lifecycle_coordination {
866 let Response::Lifecycle(status) = client.call(&Request::LifecycleStatus)?
867 else {
868 return Err(io::Error::new(
869 io::ErrorKind::InvalidData,
870 "started wsxd returned an invalid lifecycle response",
871 ));
872 };
873 Some(status)
874 } else {
875 None
876 };
877 if let Some(expected) = expected_binary_id.as_deref() {
878 let status = status.as_ref().ok_or_else(|| {
879 io::Error::new(
880 io::ErrorKind::InvalidData,
881 "started wsxd does not expose lifecycle identity",
882 )
883 })?;
884 if status.binary_id != expected {
885 return Err(io::Error::new(
886 io::ErrorKind::AlreadyExists,
887 "started wsxd binary identity does not match the elected replacement",
888 ));
889 }
890 }
891 return Ok(
892 if status.is_some_and(|status| status.recovered_from_backup) {
893 Availability::RecoveredFromBackup
894 } else {
895 Availability::Current
896 },
897 );
898 }
899 Ok(Response::Error(error)) => {
900 return Err(io::Error::other(format!(
901 "{}: {}",
902 error.code, error.message
903 )))
904 }
905 Ok(_) => {
906 return Err(io::Error::new(
907 io::ErrorKind::InvalidData,
908 "unexpected wsxd response",
909 ))
910 }
911 Err(_) if Instant::now() < deadline => thread::sleep(Duration::from_millis(50)),
912 Err(error) => {
913 return Err(io::Error::new(
914 error.kind(),
915 format!("wsxd did not become ready: {error}"),
916 ))
917 }
918 }
919 }
920}
921
922struct BootstrapLock {
923 file: File,
924}
925
926fn wait_for_singleton_release(socket: &Path) -> io::Result<()> {
927 let path = socket
928 .parent()
929 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "wsxd socket has no parent"))?
930 .join("state.lock");
931 let file = fs::OpenOptions::new()
932 .create(true)
933 .truncate(false)
934 .read(true)
935 .write(true)
936 .mode(0o600)
937 .custom_flags(libc::O_NOFOLLOW)
938 .open(&path)?;
939 let metadata = file.metadata()?;
940 if !metadata.is_file()
941 || metadata.uid() != unsafe { libc::geteuid() }
942 || metadata.mode() & 0o077 != 0
943 {
944 return Err(io::Error::new(
945 io::ErrorKind::PermissionDenied,
946 "unsafe wsxd singleton lock",
947 ));
948 }
949 let deadline = Instant::now() + IO_TIMEOUT;
950 loop {
951 let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
952 if result == 0 {
953 unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
954 return Ok(());
955 }
956 let error = io::Error::last_os_error();
957 if error.raw_os_error() != Some(libc::EWOULDBLOCK) || Instant::now() >= deadline {
958 return Err(io::Error::new(
959 error.kind(),
960 format!("wsxd singleton lock did not become available: {error}"),
961 ));
962 }
963 thread::sleep(Duration::from_millis(25));
964 }
965}
966
967fn acquire_bootstrap_lock(socket: &Path) -> io::Result<BootstrapLock> {
968 let parent = socket
969 .parent()
970 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "wsxd socket has no parent"))?;
971 match fs::symlink_metadata(parent) {
972 Ok(_) => {}
973 Err(error) if error.kind() == io::ErrorKind::NotFound => {
974 fs::create_dir_all(parent)?;
975 fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
976 }
977 Err(error) => return Err(error),
978 }
979 let metadata = fs::symlink_metadata(parent)?;
980 if metadata.file_type().is_symlink()
981 || !metadata.is_dir()
982 || metadata.uid() != unsafe { libc::geteuid() }
983 || metadata.mode() & 0o077 != 0
984 {
985 return Err(io::Error::new(
986 io::ErrorKind::PermissionDenied,
987 "unsafe wsx state directory",
988 ));
989 }
990 let path = socket.with_extension("bootstrap.lock");
991 let file = fs::OpenOptions::new()
992 .create(true)
993 .truncate(false)
994 .read(true)
995 .write(true)
996 .mode(0o600)
997 .custom_flags(libc::O_NOFOLLOW)
998 .open(&path)?;
999 let metadata = file.metadata()?;
1000 if !metadata.is_file()
1001 || metadata.uid() != unsafe { libc::geteuid() }
1002 || metadata.mode() & 0o077 != 0
1003 || metadata.len() > 4096
1004 {
1005 return Err(io::Error::new(
1006 io::ErrorKind::PermissionDenied,
1007 "unsafe wsxd bootstrap lock",
1008 ));
1009 }
1010 let deadline = Instant::now() + IO_TIMEOUT;
1011 loop {
1012 let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
1013 if result == 0 {
1014 return Ok(BootstrapLock { file });
1015 }
1016 let error = io::Error::last_os_error();
1017 if error.raw_os_error() != Some(libc::EWOULDBLOCK) || Instant::now() >= deadline {
1018 return Err(io::Error::new(
1019 error.kind(),
1020 format!("could not coordinate wsxd startup: {error}"),
1021 ));
1022 }
1023 thread::sleep(Duration::from_millis(25));
1024 }
1025}
1026
1027fn record_start_attempt(file: &mut File, reset: bool) -> io::Result<()> {
1028 let now = SystemTime::now()
1029 .duration_since(UNIX_EPOCH)
1030 .unwrap_or_default()
1031 .as_secs();
1032 file.seek(SeekFrom::Start(0))?;
1033 let mut text = String::new();
1034 file.take(4097).read_to_string(&mut text)?;
1035 let mut attempts = if reset {
1036 Vec::new()
1037 } else {
1038 text.lines()
1039 .filter_map(|line| line.parse::<u64>().ok())
1040 .filter(|attempt| *attempt <= now && now - *attempt < START_WINDOW.as_secs())
1041 .collect::<Vec<_>>()
1042 };
1043 if attempts.len() >= MAX_START_ATTEMPTS {
1044 return Err(io::Error::new(
1045 io::ErrorKind::WouldBlock,
1046 "crash_loop: wsxd exceeded 3 automatic starts in 60 seconds; run `wsx daemon recover` to try explicitly",
1047 ));
1048 }
1049 if !attempts.is_empty() {
1050 thread::sleep(Duration::from_millis(100_u64 << attempts.len().min(3)));
1051 }
1052 attempts.push(now);
1053 file.seek(SeekFrom::Start(0))?;
1054 file.set_len(0)?;
1055 for attempt in attempts {
1056 writeln!(file, "{attempt}")?;
1057 }
1058 file.sync_all()
1059}
1060
1061fn write_lifecycle_marker(socket: &Path, reason: &str) -> io::Result<()> {
1062 let path = socket.with_extension("lifecycle");
1063 let temporary = path.with_extension(format!("lifecycle.tmp.{}", std::process::id()));
1064 let result = (|| {
1065 let mut file = fs::OpenOptions::new()
1066 .create(true)
1067 .truncate(true)
1068 .write(true)
1069 .mode(0o600)
1070 .custom_flags(libc::O_NOFOLLOW)
1071 .open(&temporary)?;
1072 file.set_permissions(fs::Permissions::from_mode(0o600))?;
1073 writeln!(file, "{reason}")?;
1074 file.sync_all()?;
1075 fs::rename(&temporary, path)
1076 })();
1077 if result.is_err() {
1078 let _ = fs::remove_file(&temporary);
1079 }
1080 result
1081}
1082
1083#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1084enum PlannedStart {
1085 None,
1086 Intentional,
1087 Replacement,
1088}
1089
1090fn consume_planned_marker(
1091 socket: &Path,
1092 target_binary_id: Option<&str>,
1093 target_daemon_revision: u32,
1094) -> io::Result<PlannedStart> {
1095 let Some(reason) = lifecycle_marker_reason(socket) else {
1096 return Ok(PlannedStart::None);
1097 };
1098 let planned = match reason.as_str() {
1099 "intentional" | "login_ended" => PlannedStart::Intentional,
1100 reason if reason.starts_with("replacement:") => {
1101 let expected = reason.trim_start_matches("replacement:");
1102 let compatible = expected
1103 .rsplit_once('|')
1104 .and_then(|(_, revision)| revision.parse::<u32>().ok())
1105 .is_some_and(|revision| revision > 0 && revision == target_daemon_revision)
1106 || target_binary_id == Some(expected);
1107 if !compatible {
1108 return Err(io::Error::new(
1109 io::ErrorKind::AlreadyExists,
1110 "replacement_protected: the pending wsxd replacement belongs to another daemon revision",
1111 ));
1112 }
1113 PlannedStart::Replacement
1114 }
1115 _ => PlannedStart::None,
1116 };
1117 if planned != PlannedStart::None {
1118 write_lifecycle_marker(socket, "starting")?;
1119 }
1120 Ok(planned)
1121}
1122
1123fn lifecycle_marker_reason(socket: &Path) -> Option<String> {
1124 let path = socket.with_extension("lifecycle");
1125 let file = fs::OpenOptions::new()
1126 .read(true)
1127 .custom_flags(libc::O_NOFOLLOW)
1128 .open(path)
1129 .ok()?;
1130 let metadata = file.metadata().ok()?;
1131 if !metadata.is_file()
1132 || metadata.uid() != unsafe { libc::geteuid() }
1133 || metadata.mode() & 0o077 != 0
1134 || metadata.len() > 1024
1135 {
1136 return None;
1137 }
1138 let mut reason = String::new();
1139 file.take(1025).read_to_string(&mut reason).ok()?;
1140 Some(reason.trim().to_string())
1141}
1142
1143fn background_recovery_allowed(socket: &Path) -> bool {
1144 match lifecycle_marker_reason(socket).as_deref() {
1145 None => !socket.with_extension("lifecycle").exists(),
1146 Some("ready" | "unexpected" | "starting") => true,
1147 Some(reason) if reason.starts_with("replacement:") => true,
1148 Some(_) => false,
1149 }
1150}
1151
1152fn lifecycle_round_trip(client: &Client, request: &Request) -> io::Result<Response> {
1153 let mut stream = client.connect()?;
1154 match round_trip(
1155 &mut stream,
1156 &Request::Hello {
1157 protocol: PROTOCOL_VERSION,
1158 },
1159 )? {
1160 Response::Hello { .. } => round_trip(&mut stream, request),
1161 Response::Error(error) => Err(io::Error::other(format!(
1162 "{}: {}",
1163 error.code, error.message
1164 ))),
1165 _ => Err(io::Error::new(
1166 io::ErrorKind::InvalidData,
1167 "wsxd lifecycle handshake failed",
1168 )),
1169 }
1170}
1171
1172#[derive(Debug)]
1173enum ExistingDaemon {
1174 Ready {
1175 lifecycle_coordination: bool,
1176 version_coordination: bool,
1177 daemon_revision_coordination: bool,
1178 },
1179 Missing,
1180 Incompatible {
1181 stream: Option<UnixStream>,
1182 advertised_protocol: Option<u32>,
1183 lifecycle_coordination: bool,
1184 version_coordination: bool,
1185 },
1186}
1187
1188fn daemon_needs_start(existing: ExistingDaemon) -> io::Result<bool> {
1189 match existing {
1190 ExistingDaemon::Ready { .. } => Ok(false),
1191 ExistingDaemon::Missing => Ok(true),
1192 ExistingDaemon::Incompatible {
1194 advertised_protocol,
1195 ..
1196 } => Err(incompatible_daemon_error(advertised_protocol)),
1197 }
1198}
1199
1200fn incompatible_daemon_error(advertised_protocol: Option<u32>) -> io::Error {
1201 let daemon_protocol = advertised_protocol
1202 .map(|protocol| protocol.to_string())
1203 .unwrap_or_else(|| "unknown".into());
1204 let reason = if advertised_protocol == Some(PROTOCOL_VERSION) {
1205 "missing required capabilities"
1206 } else {
1207 "protocol mismatch"
1208 };
1209 io::Error::new(
1210 io::ErrorKind::AlreadyExists,
1211 format!(
1212 "incompatible wsxd is already running ({reason}; client protocol {PROTOCOL_VERSION}, daemon protocol {daemon_protocol}); refusing automatic shutdown to protect live sessions; use a matching wsx binary or run `wsx daemon stop` explicitly"
1213 ),
1214 )
1215}
1216
1217fn probe_existing_daemon(client: &Client) -> io::Result<ExistingDaemon> {
1218 let mut stream = match client.connect() {
1219 Ok(stream) => stream,
1220 Err(error)
1221 if matches!(
1222 error.kind(),
1223 io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused
1224 ) =>
1225 {
1226 return Ok(ExistingDaemon::Missing)
1227 }
1228 Err(error) => return Err(error),
1229 };
1230 match round_trip(
1231 &mut stream,
1232 &Request::Hello {
1233 protocol: PROTOCOL_VERSION,
1234 },
1235 )? {
1236 Response::Hello {
1237 protocol,
1238 capabilities,
1239 ..
1240 } if protocol == PROTOCOL_VERSION
1241 && capabilities.resume_shell_fallback
1242 && capabilities.foreground_jobs =>
1243 {
1244 let lifecycle_coordination = capabilities.lifecycle_coordination;
1245 let version_coordination = capabilities.version_coordination;
1246 let daemon_revision_coordination = capabilities.daemon_revision_coordination;
1247 match round_trip(&mut stream, &Request::Snapshot)? {
1248 Response::Snapshot(_) => Ok(ExistingDaemon::Ready {
1249 lifecycle_coordination,
1250 version_coordination,
1251 daemon_revision_coordination,
1252 }),
1253 Response::Error(error) => Err(io::Error::other(format!(
1254 "{}: {}",
1255 error.code, error.message
1256 ))),
1257 _ => Err(io::Error::new(
1258 io::ErrorKind::InvalidData,
1259 "unexpected wsxd response",
1260 )),
1261 }
1262 }
1263 Response::Hello {
1264 protocol,
1265 capabilities,
1266 ..
1267 } => Ok(ExistingDaemon::Incompatible {
1268 stream: Some(stream),
1269 advertised_protocol: Some(protocol),
1270 lifecycle_coordination: capabilities.lifecycle_coordination,
1271 version_coordination: capabilities.version_coordination,
1272 }),
1273 Response::Error(error) if error.code == "protocol_mismatch" => {
1274 Ok(ExistingDaemon::Incompatible {
1275 stream: None,
1276 advertised_protocol: None,
1277 lifecycle_coordination: false,
1278 version_coordination: false,
1279 })
1280 }
1281 Response::Error(error) => Err(io::Error::other(format!(
1282 "{}: {}",
1283 error.code, error.message
1284 ))),
1285 _ => Err(io::Error::new(
1286 io::ErrorKind::InvalidData,
1287 "wsxd protocol handshake failed",
1288 )),
1289 }
1290}
1291
1292fn shutdown_incompatible_daemon(
1293 client: &Client,
1294 stream: Option<UnixStream>,
1295 advertised_protocol: Option<u32>,
1296) -> io::Result<()> {
1297 let mut stream = match (stream, advertised_protocol) {
1298 (Some(stream), Some(_)) => stream,
1299 (None, None) => connect_unadvertised_legacy_daemon(client)?,
1300 _ => {
1301 return Err(io::Error::other(
1302 "incompatible wsxd did not advertise a restartable protocol",
1303 ))
1304 }
1305 };
1306 match round_trip(&mut stream, &Request::Shutdown)? {
1307 Response::Ack { .. } => Ok(()),
1308 Response::Error(error) => Err(io::Error::other(format!(
1309 "{}: {}",
1310 error.code, error.message
1311 ))),
1312 _ => Err(io::Error::new(
1313 io::ErrorKind::InvalidData,
1314 "unexpected wsxd shutdown response",
1315 )),
1316 }
1317}
1318
1319fn connect_unadvertised_legacy_daemon(client: &Client) -> io::Result<UnixStream> {
1320 for protocol in (1..PROTOCOL_VERSION).rev() {
1321 let mut stream = client.connect()?;
1322 match round_trip(&mut stream, &Request::Hello { protocol })? {
1323 Response::Hello {
1324 protocol: accepted, ..
1325 } if accepted == protocol => {
1326 if protocol == 1 {
1327 drop(stream);
1329 return client.connect();
1330 }
1331 return Ok(stream);
1332 }
1333 Response::Error(error) if error.code == "protocol_mismatch" => continue,
1334 Response::Error(error) => {
1335 return Err(io::Error::other(format!(
1336 "{}: {}",
1337 error.code, error.message
1338 )))
1339 }
1340 _ => {
1341 return Err(io::Error::new(
1342 io::ErrorKind::InvalidData,
1343 "legacy wsxd protocol handshake failed",
1344 ))
1345 }
1346 }
1347 }
1348 Err(io::Error::other(
1349 "incompatible wsxd protocol could not be negotiated for shutdown",
1350 ))
1351}
1352
1353fn daemon_is_stopped_error(error: &io::Error) -> bool {
1354 matches!(
1355 error.kind(),
1356 io::ErrorKind::NotFound
1357 | io::ErrorKind::ConnectionRefused
1358 | io::ErrorKind::ConnectionReset
1359 | io::ErrorKind::UnexpectedEof
1360 )
1361}
1362
1363fn wait_until_stopped(client: &Client) -> io::Result<()> {
1364 let deadline = Instant::now() + IO_TIMEOUT;
1365 loop {
1366 match probe_existing_daemon(client) {
1367 Ok(ExistingDaemon::Missing) => return Ok(()),
1368 Ok(ExistingDaemon::Ready { .. } | ExistingDaemon::Incompatible { .. })
1369 if Instant::now() < deadline => {}
1370 Err(error) if daemon_is_stopped_error(&error) => return Ok(()),
1371 Err(_) if Instant::now() < deadline => {}
1372 Ok(ExistingDaemon::Ready { .. } | ExistingDaemon::Incompatible { .. }) => {
1373 return Err(io::Error::new(io::ErrorKind::TimedOut, "wsxd did not stop"))
1374 }
1375 Err(error) => return Err(error),
1376 }
1377 thread::sleep(Duration::from_millis(50));
1378 }
1379}
1380
1381fn daemon_binary() -> PathBuf {
1382 if let Some(path) = std::env::var_os("WSX_DAEMON_BIN").filter(|value| !value.is_empty()) {
1383 return PathBuf::from(path);
1384 }
1385 if let Ok(current) = std::env::current_exe() {
1386 if let Some(parent) = current.parent() {
1387 let adjacent = parent.join(format!("wsxd{}", std::env::consts::EXE_SUFFIX));
1388 if adjacent.is_file() {
1389 return adjacent;
1390 }
1391 }
1392 }
1393 PathBuf::from(format!("wsxd{}", std::env::consts::EXE_SUFFIX))
1394}
1395
1396#[derive(Debug, Clone, PartialEq, Eq)]
1397pub enum EventSignal {
1398 Dirty,
1399 Connected(Option<Availability>),
1400 Disconnected(String),
1401}
1402
1403pub struct EventMonitor {
1404 stopping: Arc<AtomicBool>,
1405 thread: Option<thread::JoinHandle<()>>,
1406}
1407impl EventMonitor {
1408 pub fn start(client: Client) -> io::Result<(Self, mpsc::Receiver<EventSignal>)> {
1409 let (sender, receiver) = mpsc::channel();
1410 let stopping = Arc::new(AtomicBool::new(false));
1411 let stop = Arc::clone(&stopping);
1412 let tui = super::domain::TuiClientPresence {
1413 instance_id: new_client_id(),
1414 version: super::protocol::WSX_VERSION.to_string(),
1415 target_binary_id: binary_identity(&daemon_binary()).unwrap_or_default(),
1416 target_daemon_revision: super::protocol::DAEMON_REVISION,
1417 };
1418 let thread = thread::Builder::new()
1419 .name("wsx-runtime-events".into())
1420 .spawn(move || {
1421 let mut revision = 0;
1422 let mut connected = false;
1423 let mut recovery_availability = None;
1424 while !stop.load(Ordering::Acquire) {
1425 match client.call(&Request::Poll {
1426 after_revision: revision,
1427 timeout_ms: 1_000,
1428 tui: Some(tui.clone()),
1429 }) {
1430 Ok(Response::Events {
1431 revision: next,
1432 events,
1433 }) => {
1434 if !connected {
1435 connected = true;
1436 let _ = sender
1437 .send(EventSignal::Connected(recovery_availability.take()));
1438 }
1439 revision = next;
1440 if !events.is_empty() {
1441 let _ = sender.send(EventSignal::Dirty);
1442 }
1443 }
1444 Ok(Response::Error(error)) => {
1445 connected = false;
1446 revision = 0;
1447 let _ = sender.send(EventSignal::Disconnected(format!(
1448 "{}: {}",
1449 error.code, error.message
1450 )));
1451 thread::sleep(Duration::from_millis(250));
1452 }
1453 Ok(_) => {
1454 connected = false;
1455 revision = 0;
1456 let _ = sender.send(EventSignal::Disconnected(
1457 "unexpected daemon poll response".into(),
1458 ));
1459 thread::sleep(Duration::from_millis(250));
1460 }
1461 Err(error) => {
1462 connected = false;
1463 revision = 0;
1464 let _ = sender.send(EventSignal::Disconnected(error.to_string()));
1465 if daemon_is_stopped_error(&error)
1466 && background_recovery_allowed(client.socket())
1467 {
1468 match ensure_background_available_with(&client) {
1469 Ok(availability) => {
1470 recovery_availability = Some(availability);
1471 continue;
1472 }
1473 Err(recovery_error) => {
1474 let _ = sender.send(EventSignal::Disconnected(
1475 recovery_error.to_string(),
1476 ));
1477 }
1478 }
1479 }
1480 thread::sleep(Duration::from_millis(250));
1481 }
1482 }
1483 }
1484 })?;
1485 ACTIVE_TUI_MONITORS.fetch_add(1, Ordering::AcqRel);
1486 Ok((
1487 Self {
1488 stopping,
1489 thread: Some(thread),
1490 },
1491 receiver,
1492 ))
1493 }
1494}
1495impl Drop for EventMonitor {
1496 fn drop(&mut self) {
1497 self.stopping.store(true, Ordering::Release);
1498 if let Some(thread) = self.thread.take() {
1499 let _ = thread.join();
1500 }
1501 ACTIVE_TUI_MONITORS.fetch_sub(1, Ordering::AcqRel);
1502 }
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507 use super::*;
1508 use crate::runtime::{
1509 Cell, Cursor, PaneId, TerminalFrame, TerminalId, TerminalSelectionRange, TerminalUpdate,
1510 };
1511 use std::{
1512 os::unix::{fs::PermissionsExt, net::UnixListener},
1513 sync::atomic::AtomicUsize,
1514 };
1515
1516 fn test_listener(_name: &str) -> (PathBuf, UnixListener) {
1517 let dir = std::env::current_dir().unwrap().join(".work/s");
1518 std::fs::create_dir_all(&dir).unwrap();
1519 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
1520 let path = dir.join(format!("{:x}-{:x}", std::process::id(), new_client_id()));
1521 let _ = std::fs::remove_file(&path);
1522 let listener = UnixListener::bind(&path).unwrap();
1523 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1524 (path, listener)
1525 }
1526
1527 fn send_response(stream: &mut UnixStream, response: &Response) {
1528 stream.write_all(&encode_line(response).unwrap()).unwrap();
1529 }
1530
1531 fn current_capabilities() -> super::super::domain::Capabilities {
1532 super::super::domain::Capabilities {
1533 resume_shell_fallback: true,
1534 foreground_jobs: true,
1535 lifecycle_coordination: true,
1536 ..Default::default()
1537 }
1538 }
1539
1540 #[test]
1541 fn compatible_daemon_is_reused_without_shutdown() {
1542 let (path, listener) = test_listener("compatible-reuse");
1543 let server_path = path.clone();
1544 let server = thread::spawn(move || {
1545 let (mut stream, _) = listener.accept().unwrap();
1546 assert!(matches!(
1547 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1548 Request::Hello {
1549 protocol: PROTOCOL_VERSION
1550 }
1551 ));
1552 send_response(
1553 &mut stream,
1554 &Response::Hello {
1555 protocol: PROTOCOL_VERSION,
1556 epoch: 1,
1557 capabilities: current_capabilities(),
1558 },
1559 );
1560 assert_eq!(
1561 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1562 Request::Snapshot
1563 );
1564 send_response(
1565 &mut stream,
1566 &Response::Snapshot(super::super::domain::Snapshot {
1567 protocol: PROTOCOL_VERSION,
1568 epoch: 1,
1569 revision: 1,
1570 projects: Vec::new(),
1571 worktrees: Vec::new(),
1572 sessions: Vec::new(),
1573 panes: Vec::new(),
1574 conversations: Vec::new(),
1575 listening_ports: Vec::new(),
1576 pane_activity: Vec::new(),
1577 plugin_sidecars: Vec::new(),
1578 capabilities: current_capabilities(),
1579 }),
1580 );
1581 drop(listener);
1582 std::fs::remove_file(server_path).unwrap();
1583 });
1584
1585 assert!(!daemon_needs_start(probe_existing_daemon(&Client::new(path)).unwrap()).unwrap());
1586 assert!(daemon_needs_start(ExistingDaemon::Missing).unwrap());
1587 server.join().unwrap();
1588 }
1589
1590 #[test]
1591 fn matching_daemon_revision_reuses_a_different_wsx_build() {
1592 let (path, listener) = test_listener("matching-daemon-revision");
1593 let server_path = path.clone();
1594 let server = thread::spawn(move || {
1595 let (mut stream, _) = listener.accept().unwrap();
1596 assert!(matches!(
1597 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1598 Request::Hello { .. }
1599 ));
1600 let mut capabilities = current_capabilities();
1601 capabilities.daemon_revision_coordination = true;
1602 send_response(
1603 &mut stream,
1604 &Response::Hello {
1605 protocol: PROTOCOL_VERSION,
1606 epoch: 7,
1607 capabilities,
1608 },
1609 );
1610 assert_eq!(
1611 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1612 Request::LifecycleStatus
1613 );
1614 send_response(
1615 &mut stream,
1616 &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1617 protocol: PROTOCOL_VERSION,
1618 epoch: 7,
1619 binary_id: "0.22.1:1:2:3:10".into(),
1620 version: "0.22.1".into(),
1621 daemon_revision: super::super::protocol::DAEMON_REVISION,
1622 started_unix_ms: 1,
1623 phase: super::super::domain::DaemonPhase::Ready,
1624 live_runtimes: 1,
1625 active_clients: 1,
1626 active_tuis: 1,
1627 recovered_from_backup: false,
1628 replacement_target: None,
1629 replacement_target_version: String::new(),
1630 replacement_blockers: vec![],
1631 }),
1632 );
1633 drop(listener);
1634 std::fs::remove_file(server_path).unwrap();
1635 });
1636 let ready = ExistingDaemon::Ready {
1637 lifecycle_coordination: true,
1638 version_coordination: true,
1639 daemon_revision_coordination: true,
1640 };
1641
1642 assert_eq!(
1643 ready_without_transition(&Client::new(path), &ready, Some("0.22.2:4:5:6:20")).unwrap(),
1644 Some(Availability::Current)
1645 );
1646 server.join().unwrap();
1647 }
1648
1649 #[test]
1650 fn lifecycle_ready_and_deferred_are_typed_healthy_outcomes() {
1651 let (path, listener) = test_listener("lifecycle-outcomes");
1652 let server_path = path.clone();
1653 let server = thread::spawn(move || {
1654 for (phase, live_runtimes) in [
1655 (super::super::domain::DaemonPhase::Ready, 0),
1656 (super::super::domain::DaemonPhase::ReplacementPending, 2),
1657 ] {
1658 let (mut stream, _) = listener.accept().unwrap();
1659 assert!(matches!(
1660 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1661 Request::Hello { .. }
1662 ));
1663 send_response(
1664 &mut stream,
1665 &Response::Hello {
1666 protocol: PROTOCOL_VERSION,
1667 epoch: 7,
1668 capabilities: current_capabilities(),
1669 },
1670 );
1671 assert_eq!(
1672 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1673 Request::LifecycleStatus
1674 );
1675 send_response(
1676 &mut stream,
1677 &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1678 protocol: PROTOCOL_VERSION,
1679 epoch: 7,
1680 binary_id: "target".into(),
1681 version: "0.21.0".into(),
1682 daemon_revision: 0,
1683 started_unix_ms: 1,
1684 phase,
1685 live_runtimes,
1686 active_clients: 1,
1687 active_tuis: 1,
1688 recovered_from_backup: false,
1689 replacement_target: None,
1690 replacement_target_version: "0.21.0".into(),
1691 replacement_blockers: vec![],
1692 }),
1693 );
1694 }
1695 drop(listener);
1696 std::fs::remove_file(server_path).unwrap();
1697 });
1698 let client = Client::new(path);
1699 let ready = ExistingDaemon::Ready {
1700 lifecycle_coordination: true,
1701 version_coordination: true,
1702 daemon_revision_coordination: false,
1703 };
1704 assert_eq!(
1705 ready_without_transition(&client, &ready, Some("target")).unwrap(),
1706 Some(Availability::Current)
1707 );
1708 assert_eq!(
1709 ready_without_transition(&client, &ready, Some("target")).unwrap(),
1710 Some(Availability::ReplacementDeferred {
1711 daemon_version: "0.21.0".into(),
1712 target_version: "0.21.0".into(),
1713 live_runtimes: 2,
1714 blockers: vec![],
1715 })
1716 );
1717 server.join().unwrap();
1718 }
1719
1720 #[test]
1721 fn legacy_client_count_excludes_the_requester_and_own_tui_monitor() {
1722 assert!(!active_client_count_has_other_tui(1, 0));
1723 assert!(!active_client_count_has_other_tui(2, 1));
1724 assert!(active_client_count_has_other_tui(2, 0));
1725 assert!(active_client_count_has_other_tui(3, 1));
1726 }
1727
1728 #[test]
1729 fn newer_daemon_is_reused_without_a_downgrade_request() {
1730 let (path, listener) = test_listener("newer-daemon");
1731 let server_path = path.clone();
1732 let server = thread::spawn(move || {
1733 let (mut stream, _) = listener.accept().unwrap();
1734 assert!(matches!(
1735 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1736 Request::Hello { .. }
1737 ));
1738 send_response(
1739 &mut stream,
1740 &Response::Hello {
1741 protocol: PROTOCOL_VERSION,
1742 epoch: 7,
1743 capabilities: current_capabilities(),
1744 },
1745 );
1746 assert_eq!(
1747 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1748 Request::LifecycleStatus
1749 );
1750 send_response(
1751 &mut stream,
1752 &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1753 protocol: PROTOCOL_VERSION,
1754 epoch: 7,
1755 binary_id: "99.0.0:1:2:3:20".into(),
1756 version: "99.0.0".into(),
1757 daemon_revision: 0,
1758 started_unix_ms: 1,
1759 phase: super::super::domain::DaemonPhase::Ready,
1760 live_runtimes: 2,
1761 active_clients: 2,
1762 active_tuis: 1,
1763 recovered_from_backup: false,
1764 replacement_target: None,
1765 replacement_target_version: String::new(),
1766 replacement_blockers: vec![],
1767 }),
1768 );
1769 drop(listener);
1770 std::fs::remove_file(server_path).unwrap();
1771 });
1772 let ready = ExistingDaemon::Ready {
1773 lifecycle_coordination: true,
1774 version_coordination: true,
1775 daemon_revision_coordination: false,
1776 };
1777 assert_eq!(
1778 ready_without_transition(&Client::new(path), &ready, Some("0.22.0:1:2:3:10")).unwrap(),
1779 Some(Availability::NewerDaemon {
1780 daemon_version: "99.0.0".into()
1781 })
1782 );
1783 server.join().unwrap();
1784 }
1785
1786 #[test]
1787 fn legacy_pending_target_keeps_the_new_client_healthy() {
1788 let directory = std::env::current_dir().unwrap().join(".work").join(format!(
1789 "{:x}-{:x}",
1790 std::process::id(),
1791 new_client_id()
1792 ));
1793 std::fs::create_dir_all(&directory).unwrap();
1794 std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)).unwrap();
1795 let path = directory.join("wsx.sock");
1796 let listener = UnixListener::bind(&path).unwrap();
1797 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1798 let server_path = path.clone();
1799 let server = thread::spawn(move || {
1800 for step in 0..6 {
1801 let (mut stream, _) = listener.accept().unwrap();
1802 assert!(matches!(
1803 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1804 Request::Hello { .. }
1805 ));
1806 send_response(
1807 &mut stream,
1808 &Response::Hello {
1809 protocol: PROTOCOL_VERSION,
1810 epoch: 7,
1811 capabilities: current_capabilities(),
1812 },
1813 );
1814 let request = read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap();
1815 match step {
1816 0 | 2 => {
1817 assert_eq!(request, Request::Snapshot);
1818 send_response(
1819 &mut stream,
1820 &Response::Snapshot(super::super::domain::Snapshot {
1821 protocol: PROTOCOL_VERSION,
1822 epoch: 7,
1823 revision: 1,
1824 projects: vec![],
1825 worktrees: vec![],
1826 sessions: vec![],
1827 panes: vec![],
1828 conversations: vec![],
1829 listening_ports: vec![],
1830 pane_activity: vec![],
1831 plugin_sidecars: Vec::new(),
1832 capabilities: current_capabilities(),
1833 }),
1834 );
1835 }
1836 1 | 3 | 4 => {
1837 assert_eq!(request, Request::LifecycleStatus);
1838 send_response(
1839 &mut stream,
1840 &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1841 protocol: PROTOCOL_VERSION,
1842 epoch: 7,
1843 binary_id: "0.20.0:1:2:3:10".into(),
1844 version: String::new(),
1845 daemon_revision: 0,
1846 started_unix_ms: 1,
1847 phase: super::super::domain::DaemonPhase::ReplacementPending,
1848 live_runtimes: 4,
1849 active_clients: 1,
1850 active_tuis: 0,
1851 recovered_from_backup: false,
1852 replacement_target: Some("0.20.0:1:2:3:20".into()),
1853 replacement_target_version: String::new(),
1854 replacement_blockers: vec![],
1855 }),
1856 );
1857 }
1858 5 => {
1859 assert!(matches!(request, Request::PrepareReplacement { .. }));
1860 send_response(
1861 &mut stream,
1862 &Response::Error(super::super::protocol::ApiError::new(
1863 "replacement_conflict",
1864 "another wsxd binary is already pending replacement",
1865 )),
1866 );
1867 }
1868 _ => unreachable!(),
1869 }
1870 }
1871 drop(listener);
1872 std::fs::remove_file(server_path).unwrap();
1873 });
1874
1875 let availability = ensure_available_with_binary(
1876 &Client::new(path.clone()),
1877 false,
1878 &std::env::current_exe().unwrap(),
1879 )
1880 .unwrap();
1881 assert_eq!(
1882 availability,
1883 Availability::ReplacementDeferred {
1884 daemon_version: "0.20.0".into(),
1885 target_version: super::super::protocol::WSX_VERSION.into(),
1886 live_runtimes: 4,
1887 blockers: vec![super::super::domain::ReplacementBlocker::PendingTarget],
1888 }
1889 );
1890 server.join().unwrap();
1891 let _ = std::fs::remove_file(path.with_extension("bootstrap.lock"));
1892 std::fs::remove_dir(directory).unwrap();
1893 }
1894
1895 #[test]
1896 fn bootstrap_lock_serializes_startup_owners() {
1897 let (path, listener) = test_listener("bootstrap-lock");
1898 drop(listener);
1899 let _ = std::fs::remove_file(&path);
1900 let first = acquire_bootstrap_lock(&path).unwrap();
1901 let contender_path = path.clone();
1902 let (acquired_tx, acquired_rx) = std::sync::mpsc::channel();
1903 let contender = thread::spawn(move || {
1904 let lock = acquire_bootstrap_lock(&contender_path).unwrap();
1905 acquired_tx.send(()).unwrap();
1906 drop(lock);
1907 });
1908 assert!(acquired_rx
1909 .recv_timeout(Duration::from_millis(100))
1910 .is_err());
1911 drop(first);
1912 acquired_rx.recv_timeout(Duration::from_secs(1)).unwrap();
1913 contender.join().unwrap();
1914 let _ = std::fs::remove_file(path.with_extension("bootstrap.lock"));
1915 }
1916
1917 #[test]
1918 fn successor_waits_for_the_daemon_singleton_lock_not_only_socket_removal() {
1919 let (seed, listener) = test_listener("singleton-release");
1920 drop(listener);
1921 let _ = std::fs::remove_file(&seed);
1922 let directory = seed.with_extension("state");
1923 std::fs::create_dir(&directory).unwrap();
1924 std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)).unwrap();
1925 let path = directory.join("wsx.sock");
1926 let lock_path = directory.join("state.lock");
1927 let owner = std::fs::OpenOptions::new()
1928 .create(true)
1929 .truncate(false)
1930 .read(true)
1931 .write(true)
1932 .mode(0o600)
1933 .open(&lock_path)
1934 .unwrap();
1935 assert_eq!(
1936 unsafe { libc::flock(owner.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
1937 0
1938 );
1939 let contender_path = path.clone();
1940 let (released_tx, released_rx) = std::sync::mpsc::channel();
1941 let contender = thread::spawn(move || {
1942 wait_for_singleton_release(&contender_path).unwrap();
1943 released_tx.send(()).unwrap();
1944 });
1945 assert!(released_rx
1946 .recv_timeout(Duration::from_millis(100))
1947 .is_err());
1948 drop(owner);
1949 released_rx.recv_timeout(Duration::from_secs(1)).unwrap();
1950 contender.join().unwrap();
1951 let _ = std::fs::remove_file(lock_path);
1952 let _ = std::fs::remove_dir(directory);
1953 }
1954
1955 #[test]
1956 fn shared_start_budget_blocks_a_loop_and_explicit_recovery_resets_it() {
1957 let (path, listener) = test_listener("start-budget");
1958 drop(listener);
1959 let _ = std::fs::remove_file(&path);
1960 let mut lock = acquire_bootstrap_lock(&path).unwrap();
1961 for _ in 0..MAX_START_ATTEMPTS {
1962 record_start_attempt(&mut lock.file, false).unwrap();
1963 }
1964 assert!(record_start_attempt(&mut lock.file, false)
1965 .unwrap_err()
1966 .to_string()
1967 .contains("crash_loop"));
1968 record_start_attempt(&mut lock.file, true).unwrap();
1969 drop(lock);
1970 let _ = std::fs::remove_file(path.with_extension("bootstrap.lock"));
1971 }
1972
1973 #[test]
1974 fn custom_client_never_spawns_a_default_daemon() {
1975 let (path, listener) = test_listener("custom-no-recovery");
1976 drop(listener);
1977 std::fs::remove_file(&path).unwrap();
1978 assert_eq!(
1979 ensure_background_available_with(&Client::new(path.clone()))
1980 .unwrap_err()
1981 .kind(),
1982 io::ErrorKind::Unsupported
1983 );
1984 assert!(!path.exists());
1985 }
1986
1987 #[test]
1988 fn intentional_stop_marker_disables_background_recovery() {
1989 let (path, listener) = test_listener("intentional-marker");
1990 drop(listener);
1991 std::fs::remove_file(&path).unwrap();
1992 let marker = path.with_extension("lifecycle");
1993 std::fs::write(&marker, "intentional\n").unwrap();
1994 std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o600)).unwrap();
1995 assert!(!background_recovery_allowed(&path));
1996 assert_eq!(
1997 ensure_background_available_with(&Client {
1998 socket: path.clone(),
1999 automatic_start: true,
2000 })
2001 .unwrap_err()
2002 .kind(),
2003 io::ErrorKind::ConnectionAborted
2004 );
2005 assert_eq!(
2006 consume_planned_marker(&path, Some("target"), 1).unwrap(),
2007 PlannedStart::Intentional
2008 );
2009 assert_eq!(lifecycle_marker_reason(&path).as_deref(), Some("starting"));
2010 assert_eq!(
2011 consume_planned_marker(&path, Some("target"), 1).unwrap(),
2012 PlannedStart::None
2013 );
2014 std::fs::write(&marker, "login_ended\n").unwrap();
2015 assert!(!background_recovery_allowed(&path));
2016 std::fs::write(&marker, "replacement:other\n").unwrap();
2017 assert!(consume_planned_marker(&path, Some("target"), 1).is_err());
2018 std::fs::write(&marker, "replacement:other|1\n").unwrap();
2019 assert_eq!(
2020 consume_planned_marker(&path, Some("target"), 1).unwrap(),
2021 PlannedStart::Replacement
2022 );
2023 std::fs::write(&marker, "unexpected\n").unwrap();
2024 assert!(background_recovery_allowed(&path));
2025 let _ = std::fs::remove_file(marker);
2026 let _ = std::fs::remove_file(path);
2027 }
2028
2029 #[test]
2030 fn detached_spawn_is_session_leader_and_survives_hangup() {
2031 let mut command = Command::new("sh");
2032 command.arg("-c").arg("kill -HUP $$; exec sleep 5");
2033
2034 let mut child = spawn_detached(&mut command).unwrap();
2035 thread::sleep(Duration::from_millis(20));
2036 let pid = child.id() as libc::pid_t;
2037 let session_id = unsafe { libc::getsid(pid) };
2038 let status = child.try_wait().unwrap();
2039 if status.is_none() {
2040 child.kill().unwrap();
2041 child.wait().unwrap();
2042 }
2043
2044 assert_eq!(session_id, pid, "detached daemon must own its session");
2045 assert!(status.is_none(), "detached daemon exited after SIGHUP");
2046 }
2047
2048 #[cfg(target_os = "macos")]
2049 #[test]
2050 fn local_socket_peer_matches_the_current_user() {
2051 let (client, server) = UnixStream::pair().unwrap();
2052 validate_peer_owner(&server).unwrap();
2053 validate_peer_owner(&client).unwrap();
2054 }
2055
2056 #[test]
2057 fn same_protocol_daemon_without_required_capabilities_is_not_stopped() {
2058 let (path, listener) = test_listener("missing-capabilities");
2059 let server_path = path.clone();
2060 let server = thread::spawn(move || {
2061 let (mut stream, _) = listener.accept().unwrap();
2062 assert_eq!(
2063 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2064 Request::Hello {
2065 protocol: PROTOCOL_VERSION
2066 }
2067 );
2068 send_response(
2069 &mut stream,
2070 &Response::Hello {
2071 protocol: PROTOCOL_VERSION,
2072 epoch: 1,
2073 capabilities: super::super::domain::Capabilities::default(),
2074 },
2075 );
2076 assert_eq!(
2077 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES)
2078 .unwrap_err()
2079 .kind(),
2080 io::ErrorKind::UnexpectedEof
2081 );
2082 drop(listener);
2083 std::fs::remove_file(server_path).unwrap();
2084 });
2085
2086 let error =
2087 daemon_needs_start(probe_existing_daemon(&Client::new(path)).unwrap()).unwrap_err();
2088 assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
2089 assert!(error.to_string().contains("missing required capabilities"));
2090 assert!(error.to_string().contains("refusing automatic shutdown"));
2091 assert!(error.to_string().contains("wsx daemon stop"));
2092 server.join().unwrap();
2093 }
2094
2095 #[test]
2096 fn protocol_mismatch_does_not_send_shutdown() {
2097 let (path, listener) = test_listener("protocol-skew");
2098 let server_path = path.clone();
2099 let server = thread::spawn(move || {
2100 let (mut stream, _) = listener.accept().unwrap();
2101 assert_eq!(
2102 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2103 Request::Hello {
2104 protocol: PROTOCOL_VERSION
2105 }
2106 );
2107 send_response(
2108 &mut stream,
2109 &Response::Error(super::super::protocol::ApiError::new(
2110 "protocol_mismatch",
2111 format!("client {PROTOCOL_VERSION}, daemon 8"),
2112 )),
2113 );
2114 assert_eq!(
2115 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES)
2116 .unwrap_err()
2117 .kind(),
2118 io::ErrorKind::UnexpectedEof
2119 );
2120 drop(listener);
2121 std::fs::remove_file(server_path).unwrap();
2122 });
2123
2124 let error =
2125 daemon_needs_start(probe_existing_daemon(&Client::new(path)).unwrap()).unwrap_err();
2126 assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
2127 assert!(error.to_string().contains("protocol mismatch"));
2128 assert!(error.to_string().contains("daemon protocol unknown"));
2129 assert!(error.to_string().contains("refusing automatic shutdown"));
2130 assert!(error.to_string().contains("wsx daemon stop"));
2131 server.join().unwrap();
2132 }
2133
2134 #[test]
2135 fn graceful_shutdown_waits_for_socket_cleanup() {
2136 let (path, listener) = test_listener("graceful-shutdown");
2137 let server_path = path.clone();
2138 let server = thread::spawn(move || {
2139 let (mut stream, _) = listener.accept().unwrap();
2140 assert_eq!(
2141 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2142 Request::Hello {
2143 protocol: PROTOCOL_VERSION
2144 }
2145 );
2146 send_response(
2147 &mut stream,
2148 &Response::Hello {
2149 protocol: PROTOCOL_VERSION,
2150 epoch: 1,
2151 capabilities: super::super::domain::Capabilities::default(),
2152 },
2153 );
2154 assert_eq!(
2155 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2156 Request::Shutdown
2157 );
2158 send_response(&mut stream, &Response::Ack { revision: 1 });
2159 drop(stream);
2160 drop(listener);
2161 std::fs::remove_file(server_path).unwrap();
2162 });
2163
2164 Client::new(path).shutdown().unwrap();
2165 server.join().unwrap();
2166 }
2167
2168 #[test]
2169 fn graceful_shutdown_surfaces_daemon_rejection() {
2170 let (path, listener) = test_listener("shutdown-rejection");
2171 let server_path = path.clone();
2172 let server = thread::spawn(move || {
2173 let (mut stream, _) = listener.accept().unwrap();
2174 assert!(matches!(
2175 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2176 Request::Hello {
2177 protocol: PROTOCOL_VERSION
2178 }
2179 ));
2180 send_response(
2181 &mut stream,
2182 &Response::Hello {
2183 protocol: PROTOCOL_VERSION,
2184 epoch: 1,
2185 capabilities: super::super::domain::Capabilities::default(),
2186 },
2187 );
2188 assert_eq!(
2189 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2190 Request::Shutdown
2191 );
2192 send_response(
2193 &mut stream,
2194 &Response::Error(super::super::protocol::ApiError::new(
2195 "shutdown_blocked",
2196 "still busy",
2197 )),
2198 );
2199 drop(listener);
2200 std::fs::remove_file(server_path).unwrap();
2201 });
2202
2203 let error = Client::new(path).shutdown().unwrap_err();
2204 assert!(error.to_string().contains("shutdown_blocked: still busy"));
2205 server.join().unwrap();
2206 }
2207
2208 #[test]
2209 fn graceful_shutdown_accepts_an_already_stopped_daemon() {
2210 let path = std::env::current_dir()
2211 .unwrap()
2212 .join(".work/s/already-stopped.sock");
2213 let _ = std::fs::remove_file(&path);
2214 Client::new(path).shutdown().unwrap();
2215 }
2216
2217 #[test]
2218 fn advertised_incompatible_daemon_shuts_down_on_the_handshake_connection() {
2219 let (path, listener) = test_listener("advertised-upgrade");
2220 let server_path = path.clone();
2221 let server = thread::spawn(move || {
2222 let (mut stream, _) = listener.accept().unwrap();
2223 assert!(matches!(
2224 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2225 Request::Hello {
2226 protocol: PROTOCOL_VERSION
2227 }
2228 ));
2229 let legacy_hello = serde_json::json!({
2230 "type": "hello",
2231 "data": {
2232 "protocol": PROTOCOL_VERSION - 1,
2233 "epoch": 1,
2234 "capabilities": {
2235 "pane_splits": true,
2236 "plugins": true,
2237 "agent_reports": true,
2238 "process_restore": false
2239 }
2240 }
2241 });
2242 stream
2243 .write_all(&encode_line(&legacy_hello).unwrap())
2244 .unwrap();
2245 assert_eq!(
2246 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2247 Request::Shutdown
2248 );
2249 send_response(&mut stream, &Response::Ack { revision: 1 });
2250 drop(listener);
2251 std::fs::remove_file(server_path).unwrap();
2252 });
2253
2254 Client::new(path).shutdown().unwrap();
2255 server.join().unwrap();
2256 }
2257
2258 #[test]
2259 fn unadvertised_protocol_two_daemon_shuts_down_on_the_handshake_connection() {
2260 let (path, listener) = test_listener("protocol-two-upgrade");
2261 let server_path = path.clone();
2262 let server = thread::spawn(move || {
2263 let (mut mismatch, _) = listener.accept().unwrap();
2264 assert_eq!(
2265 read_json_line::<Request>(&mut mismatch, MAX_RESPONSE_BYTES).unwrap(),
2266 Request::Hello {
2267 protocol: PROTOCOL_VERSION
2268 }
2269 );
2270 send_response(
2271 &mut mismatch,
2272 &Response::Error(super::super::protocol::ApiError::new(
2273 "protocol_mismatch",
2274 format!("client {PROTOCOL_VERSION}, daemon 2"),
2275 )),
2276 );
2277
2278 for protocol in (3..PROTOCOL_VERSION).rev() {
2279 let (mut probe, _) = listener.accept().unwrap();
2280 assert_eq!(
2281 read_json_line::<Request>(&mut probe, MAX_RESPONSE_BYTES).unwrap(),
2282 Request::Hello { protocol }
2283 );
2284 send_response(
2285 &mut probe,
2286 &Response::Error(super::super::protocol::ApiError::new(
2287 "protocol_mismatch",
2288 format!("client {protocol}, daemon 2"),
2289 )),
2290 );
2291 }
2292
2293 let (mut protocol_two, _) = listener.accept().unwrap();
2294 assert_eq!(
2295 read_json_line::<Request>(&mut protocol_two, MAX_RESPONSE_BYTES).unwrap(),
2296 Request::Hello { protocol: 2 }
2297 );
2298 send_response(
2299 &mut protocol_two,
2300 &Response::Hello {
2301 protocol: 2,
2302 epoch: 1,
2303 capabilities: super::super::domain::Capabilities::default(),
2304 },
2305 );
2306 assert_eq!(
2307 read_json_line::<Request>(&mut protocol_two, MAX_RESPONSE_BYTES).unwrap(),
2308 Request::Shutdown
2309 );
2310 send_response(&mut protocol_two, &Response::Ack { revision: 1 });
2311 drop(listener);
2312 std::fs::remove_file(server_path).unwrap();
2313 });
2314
2315 Client::new(path).shutdown().unwrap();
2316 server.join().unwrap();
2317 }
2318
2319 #[test]
2320 fn legacy_daemon_upgrade_uses_separate_request_connection() {
2321 let (path, listener) = test_listener("legacy-upgrade");
2322 let server_path = path.clone();
2323 let server = thread::spawn(move || {
2324 let (mut mismatch, _) = listener.accept().unwrap();
2325 assert_eq!(
2326 read_json_line::<Request>(&mut mismatch, MAX_RESPONSE_BYTES).unwrap(),
2327 Request::Hello {
2328 protocol: PROTOCOL_VERSION
2329 }
2330 );
2331 send_response(
2332 &mut mismatch,
2333 &Response::Error(super::super::protocol::ApiError::new(
2334 "protocol_mismatch",
2335 format!("client {PROTOCOL_VERSION}, daemon 1"),
2336 )),
2337 );
2338
2339 for protocol in (2..PROTOCOL_VERSION).rev() {
2340 let (mut probe, _) = listener.accept().unwrap();
2341 assert_eq!(
2342 read_json_line::<Request>(&mut probe, MAX_RESPONSE_BYTES).unwrap(),
2343 Request::Hello { protocol }
2344 );
2345 send_response(
2346 &mut probe,
2347 &Response::Error(super::super::protocol::ApiError::new(
2348 "protocol_mismatch",
2349 format!("client {protocol}, daemon 1"),
2350 )),
2351 );
2352 }
2353
2354 let (mut hello, _) = listener.accept().unwrap();
2355 assert_eq!(
2356 read_json_line::<Request>(&mut hello, MAX_RESPONSE_BYTES).unwrap(),
2357 Request::Hello { protocol: 1 }
2358 );
2359 send_response(
2360 &mut hello,
2361 &Response::Hello {
2362 protocol: 1,
2363 epoch: 1,
2364 capabilities: super::super::domain::Capabilities::default(),
2365 },
2366 );
2367 drop(hello);
2368
2369 let (mut shutdown, _) = listener.accept().unwrap();
2370 assert_eq!(
2371 read_json_line::<Request>(&mut shutdown, MAX_RESPONSE_BYTES).unwrap(),
2372 Request::Shutdown
2373 );
2374 send_response(&mut shutdown, &Response::Ack { revision: 1 });
2375 drop(listener);
2376 std::fs::remove_file(server_path).unwrap();
2377 });
2378
2379 Client::new(path).shutdown().unwrap();
2380 server.join().unwrap();
2381 }
2382
2383 #[test]
2384 fn large_json_line_uses_bounded_buffered_reads() {
2385 struct CountingReader {
2386 inner: io::Cursor<Vec<u8>>,
2387 reads: Arc<AtomicUsize>,
2388 }
2389 impl Read for CountingReader {
2390 fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
2391 self.reads.fetch_add(1, Ordering::Relaxed);
2392 self.inner.read(buffer)
2393 }
2394 }
2395
2396 let expected = "x".repeat(1024 * 1024);
2397 let mut encoded = serde_json::to_vec(&expected).unwrap();
2398 encoded.push(b'\n');
2399 let reads = Arc::new(AtomicUsize::new(0));
2400 let source = CountingReader {
2401 inner: io::Cursor::new(encoded),
2402 reads: Arc::clone(&reads),
2403 };
2404 let mut reader = BufReader::with_capacity(64 * 1024, source);
2405 let actual: String = read_buffered_json_line(&mut reader, 2 * 1024 * 1024).unwrap();
2406
2407 assert_eq!(actual, expected);
2408 assert!(reads.load(Ordering::Relaxed) < 32);
2409 }
2410
2411 #[test]
2412 fn terminal_stream_preserves_selection_updates_and_clipboard_order_after_subscribe_ack() {
2413 let (path, listener) = test_listener("buffered-subscribe");
2414 let server_path = path.clone();
2415 let server = thread::spawn(move || {
2416 let (mut stream, _) = listener.accept().unwrap();
2417 assert!(matches!(
2418 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2419 Request::Hello {
2420 protocol: PROTOCOL_VERSION
2421 }
2422 ));
2423 let mut bytes = encode_line(&Response::Hello {
2424 protocol: PROTOCOL_VERSION,
2425 epoch: 1,
2426 capabilities: super::super::domain::Capabilities::default(),
2427 })
2428 .unwrap();
2429 stream.write_all(&bytes).unwrap();
2430 assert!(matches!(
2431 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2432 Request::TerminalSubscribe {
2433 rows: 24,
2434 cols: 80,
2435 ..
2436 }
2437 ));
2438 bytes = encode_line(&Response::Ack { revision: 1 }).unwrap();
2439 bytes.extend(
2440 encode_line(&TerminalServerMessage::Update(TerminalUpdate::Full(
2441 TerminalFrame {
2442 pane_id: PaneId(1),
2443 terminal_id: TerminalId(2),
2444 revision: 1,
2445 cols: 1,
2446 rows: 1,
2447 cells: vec![Cell::default()],
2448 cursor: Cursor {
2449 x: 0,
2450 y: 0,
2451 visible: false,
2452 blinking: false,
2453 shape: 0,
2454 },
2455 selection: vec![TerminalSelectionRange {
2456 row: 0,
2457 start_col: 0,
2458 end_col: 0,
2459 }],
2460 },
2461 )))
2462 .unwrap(),
2463 );
2464 bytes.extend(
2465 encode_line(&TerminalServerMessage::Update(TerminalUpdate::Patch {
2466 pane_id: PaneId(1),
2467 terminal_id: TerminalId(2),
2468 base_revision: 1,
2469 revision: 2,
2470 cols: 1,
2471 rows: 1,
2472 changed_rows: Vec::new(),
2473 cursor: Cursor {
2474 x: 0,
2475 y: 0,
2476 visible: false,
2477 blinking: false,
2478 shape: 0,
2479 },
2480 selection: Vec::new(),
2481 }))
2482 .unwrap(),
2483 );
2484 bytes.extend(
2485 encode_line(&TerminalServerMessage::ClipboardWrite(b"copied".to_vec())).unwrap(),
2486 );
2487 bytes.extend(encode_line(&TerminalServerMessage::Exited).unwrap());
2488 stream.write_all(&bytes).unwrap();
2489 thread::sleep(Duration::from_millis(50));
2490 drop(listener);
2491 std::fs::remove_file(server_path).unwrap();
2492 });
2493
2494 let stream = TerminalStream::connect(
2495 &Client::new(path),
2496 super::super::domain::PaneId(1),
2497 7,
2498 false,
2499 24,
2500 80,
2501 )
2502 .unwrap();
2503 let deadline = Instant::now() + Duration::from_secs(1);
2504 let mut selections = Vec::new();
2505 let mut clipboard = None;
2506 loop {
2507 match stream.try_recv() {
2508 Ok(TerminalServerMessage::Update(TerminalUpdate::Full(frame))) => {
2509 selections.push(frame.selection)
2510 }
2511 Ok(TerminalServerMessage::Update(TerminalUpdate::Patch { selection, .. })) => {
2512 selections.push(selection)
2513 }
2514 Ok(TerminalServerMessage::ClipboardWrite(text)) => clipboard = Some(text),
2515 Ok(TerminalServerMessage::Exited) => break,
2516 Ok(message) => panic!("unexpected terminal message: {message:?}"),
2517 Err(mpsc::TryRecvError::Empty) if Instant::now() < deadline => {
2518 thread::sleep(Duration::from_millis(10));
2519 }
2520 Err(error) => panic!("terminal update missing after ACK: {error}"),
2521 }
2522 }
2523 assert_eq!(
2524 selections,
2525 vec![
2526 vec![TerminalSelectionRange {
2527 row: 0,
2528 start_col: 0,
2529 end_col: 0,
2530 }],
2531 Vec::new(),
2532 ]
2533 );
2534 assert_eq!(clipboard.as_deref(), Some(b"copied".as_slice()));
2535 drop(stream);
2536 server.join().unwrap();
2537 }
2538
2539 #[test]
2540 fn terminal_reader_shutdown_interrupts_full_update_queue() {
2541 let (mut server, client) = UnixStream::pair().unwrap();
2542 let (updates, receiver) = mpsc::sync_channel(1);
2543 updates.send(TerminalServerMessage::Exited).unwrap();
2544 let stopping = Arc::new(AtomicBool::new(false));
2545 let reader_stopping = Arc::clone(&stopping);
2546 let (done, finished) = mpsc::channel();
2547 let reader = thread::spawn(move || {
2548 terminal_reader(BufReader::new(client), updates, &reader_stopping);
2549 done.send(()).unwrap();
2550 });
2551
2552 server
2553 .write_all(&encode_line(&TerminalServerMessage::Exited).unwrap())
2554 .unwrap();
2555 thread::sleep(Duration::from_millis(50));
2556 stopping.store(true, Ordering::Release);
2557
2558 assert!(finished.recv_timeout(Duration::from_secs(1)).is_ok());
2559 drop(receiver);
2560 reader.join().unwrap();
2561 }
2562}