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 listening_ports: Vec::new(),
1575 pane_activity: Vec::new(),
1576 capabilities: current_capabilities(),
1577 }),
1578 );
1579 drop(listener);
1580 std::fs::remove_file(server_path).unwrap();
1581 });
1582
1583 assert!(!daemon_needs_start(probe_existing_daemon(&Client::new(path)).unwrap()).unwrap());
1584 assert!(daemon_needs_start(ExistingDaemon::Missing).unwrap());
1585 server.join().unwrap();
1586 }
1587
1588 #[test]
1589 fn matching_daemon_revision_reuses_a_different_wsx_build() {
1590 let (path, listener) = test_listener("matching-daemon-revision");
1591 let server_path = path.clone();
1592 let server = thread::spawn(move || {
1593 let (mut stream, _) = listener.accept().unwrap();
1594 assert!(matches!(
1595 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1596 Request::Hello { .. }
1597 ));
1598 let mut capabilities = current_capabilities();
1599 capabilities.daemon_revision_coordination = true;
1600 send_response(
1601 &mut stream,
1602 &Response::Hello {
1603 protocol: PROTOCOL_VERSION,
1604 epoch: 7,
1605 capabilities,
1606 },
1607 );
1608 assert_eq!(
1609 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1610 Request::LifecycleStatus
1611 );
1612 send_response(
1613 &mut stream,
1614 &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1615 protocol: PROTOCOL_VERSION,
1616 epoch: 7,
1617 binary_id: "0.22.1:1:2:3:10".into(),
1618 version: "0.22.1".into(),
1619 daemon_revision: super::super::protocol::DAEMON_REVISION,
1620 started_unix_ms: 1,
1621 phase: super::super::domain::DaemonPhase::Ready,
1622 live_runtimes: 1,
1623 active_clients: 1,
1624 active_tuis: 1,
1625 recovered_from_backup: false,
1626 replacement_target: None,
1627 replacement_target_version: String::new(),
1628 replacement_blockers: vec![],
1629 }),
1630 );
1631 drop(listener);
1632 std::fs::remove_file(server_path).unwrap();
1633 });
1634 let ready = ExistingDaemon::Ready {
1635 lifecycle_coordination: true,
1636 version_coordination: true,
1637 daemon_revision_coordination: true,
1638 };
1639
1640 assert_eq!(
1641 ready_without_transition(&Client::new(path), &ready, Some("0.22.2:4:5:6:20")).unwrap(),
1642 Some(Availability::Current)
1643 );
1644 server.join().unwrap();
1645 }
1646
1647 #[test]
1648 fn lifecycle_ready_and_deferred_are_typed_healthy_outcomes() {
1649 let (path, listener) = test_listener("lifecycle-outcomes");
1650 let server_path = path.clone();
1651 let server = thread::spawn(move || {
1652 for (phase, live_runtimes) in [
1653 (super::super::domain::DaemonPhase::Ready, 0),
1654 (super::super::domain::DaemonPhase::ReplacementPending, 2),
1655 ] {
1656 let (mut stream, _) = listener.accept().unwrap();
1657 assert!(matches!(
1658 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1659 Request::Hello { .. }
1660 ));
1661 send_response(
1662 &mut stream,
1663 &Response::Hello {
1664 protocol: PROTOCOL_VERSION,
1665 epoch: 7,
1666 capabilities: current_capabilities(),
1667 },
1668 );
1669 assert_eq!(
1670 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1671 Request::LifecycleStatus
1672 );
1673 send_response(
1674 &mut stream,
1675 &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1676 protocol: PROTOCOL_VERSION,
1677 epoch: 7,
1678 binary_id: "target".into(),
1679 version: "0.21.0".into(),
1680 daemon_revision: 0,
1681 started_unix_ms: 1,
1682 phase,
1683 live_runtimes,
1684 active_clients: 1,
1685 active_tuis: 1,
1686 recovered_from_backup: false,
1687 replacement_target: None,
1688 replacement_target_version: "0.21.0".into(),
1689 replacement_blockers: vec![],
1690 }),
1691 );
1692 }
1693 drop(listener);
1694 std::fs::remove_file(server_path).unwrap();
1695 });
1696 let client = Client::new(path);
1697 let ready = ExistingDaemon::Ready {
1698 lifecycle_coordination: true,
1699 version_coordination: true,
1700 daemon_revision_coordination: false,
1701 };
1702 assert_eq!(
1703 ready_without_transition(&client, &ready, Some("target")).unwrap(),
1704 Some(Availability::Current)
1705 );
1706 assert_eq!(
1707 ready_without_transition(&client, &ready, Some("target")).unwrap(),
1708 Some(Availability::ReplacementDeferred {
1709 daemon_version: "0.21.0".into(),
1710 target_version: "0.21.0".into(),
1711 live_runtimes: 2,
1712 blockers: vec![],
1713 })
1714 );
1715 server.join().unwrap();
1716 }
1717
1718 #[test]
1719 fn legacy_client_count_excludes_the_requester_and_own_tui_monitor() {
1720 assert!(!active_client_count_has_other_tui(1, 0));
1721 assert!(!active_client_count_has_other_tui(2, 1));
1722 assert!(active_client_count_has_other_tui(2, 0));
1723 assert!(active_client_count_has_other_tui(3, 1));
1724 }
1725
1726 #[test]
1727 fn newer_daemon_is_reused_without_a_downgrade_request() {
1728 let (path, listener) = test_listener("newer-daemon");
1729 let server_path = path.clone();
1730 let server = thread::spawn(move || {
1731 let (mut stream, _) = listener.accept().unwrap();
1732 assert!(matches!(
1733 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1734 Request::Hello { .. }
1735 ));
1736 send_response(
1737 &mut stream,
1738 &Response::Hello {
1739 protocol: PROTOCOL_VERSION,
1740 epoch: 7,
1741 capabilities: current_capabilities(),
1742 },
1743 );
1744 assert_eq!(
1745 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1746 Request::LifecycleStatus
1747 );
1748 send_response(
1749 &mut stream,
1750 &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1751 protocol: PROTOCOL_VERSION,
1752 epoch: 7,
1753 binary_id: "99.0.0:1:2:3:20".into(),
1754 version: "99.0.0".into(),
1755 daemon_revision: 0,
1756 started_unix_ms: 1,
1757 phase: super::super::domain::DaemonPhase::Ready,
1758 live_runtimes: 2,
1759 active_clients: 2,
1760 active_tuis: 1,
1761 recovered_from_backup: false,
1762 replacement_target: None,
1763 replacement_target_version: String::new(),
1764 replacement_blockers: vec![],
1765 }),
1766 );
1767 drop(listener);
1768 std::fs::remove_file(server_path).unwrap();
1769 });
1770 let ready = ExistingDaemon::Ready {
1771 lifecycle_coordination: true,
1772 version_coordination: true,
1773 daemon_revision_coordination: false,
1774 };
1775 assert_eq!(
1776 ready_without_transition(&Client::new(path), &ready, Some("0.22.0:1:2:3:10")).unwrap(),
1777 Some(Availability::NewerDaemon {
1778 daemon_version: "99.0.0".into()
1779 })
1780 );
1781 server.join().unwrap();
1782 }
1783
1784 #[test]
1785 fn legacy_pending_target_keeps_the_new_client_healthy() {
1786 let directory = std::env::current_dir().unwrap().join(".work").join(format!(
1787 "{:x}-{:x}",
1788 std::process::id(),
1789 new_client_id()
1790 ));
1791 std::fs::create_dir_all(&directory).unwrap();
1792 std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)).unwrap();
1793 let path = directory.join("wsx.sock");
1794 let listener = UnixListener::bind(&path).unwrap();
1795 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1796 let server_path = path.clone();
1797 let server = thread::spawn(move || {
1798 for step in 0..6 {
1799 let (mut stream, _) = listener.accept().unwrap();
1800 assert!(matches!(
1801 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1802 Request::Hello { .. }
1803 ));
1804 send_response(
1805 &mut stream,
1806 &Response::Hello {
1807 protocol: PROTOCOL_VERSION,
1808 epoch: 7,
1809 capabilities: current_capabilities(),
1810 },
1811 );
1812 let request = read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap();
1813 match step {
1814 0 | 2 => {
1815 assert_eq!(request, Request::Snapshot);
1816 send_response(
1817 &mut stream,
1818 &Response::Snapshot(super::super::domain::Snapshot {
1819 protocol: PROTOCOL_VERSION,
1820 epoch: 7,
1821 revision: 1,
1822 projects: vec![],
1823 worktrees: vec![],
1824 sessions: vec![],
1825 panes: vec![],
1826 listening_ports: vec![],
1827 pane_activity: vec![],
1828 capabilities: current_capabilities(),
1829 }),
1830 );
1831 }
1832 1 | 3 | 4 => {
1833 assert_eq!(request, Request::LifecycleStatus);
1834 send_response(
1835 &mut stream,
1836 &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1837 protocol: PROTOCOL_VERSION,
1838 epoch: 7,
1839 binary_id: "0.20.0:1:2:3:10".into(),
1840 version: String::new(),
1841 daemon_revision: 0,
1842 started_unix_ms: 1,
1843 phase: super::super::domain::DaemonPhase::ReplacementPending,
1844 live_runtimes: 4,
1845 active_clients: 1,
1846 active_tuis: 0,
1847 recovered_from_backup: false,
1848 replacement_target: Some("0.20.0:1:2:3:20".into()),
1849 replacement_target_version: String::new(),
1850 replacement_blockers: vec![],
1851 }),
1852 );
1853 }
1854 5 => {
1855 assert!(matches!(request, Request::PrepareReplacement { .. }));
1856 send_response(
1857 &mut stream,
1858 &Response::Error(super::super::protocol::ApiError::new(
1859 "replacement_conflict",
1860 "another wsxd binary is already pending replacement",
1861 )),
1862 );
1863 }
1864 _ => unreachable!(),
1865 }
1866 }
1867 drop(listener);
1868 std::fs::remove_file(server_path).unwrap();
1869 });
1870
1871 let availability = ensure_available_with_binary(
1872 &Client::new(path.clone()),
1873 false,
1874 &std::env::current_exe().unwrap(),
1875 )
1876 .unwrap();
1877 assert_eq!(
1878 availability,
1879 Availability::ReplacementDeferred {
1880 daemon_version: "0.20.0".into(),
1881 target_version: super::super::protocol::WSX_VERSION.into(),
1882 live_runtimes: 4,
1883 blockers: vec![super::super::domain::ReplacementBlocker::PendingTarget],
1884 }
1885 );
1886 server.join().unwrap();
1887 let _ = std::fs::remove_file(path.with_extension("bootstrap.lock"));
1888 std::fs::remove_dir(directory).unwrap();
1889 }
1890
1891 #[test]
1892 fn bootstrap_lock_serializes_startup_owners() {
1893 let (path, listener) = test_listener("bootstrap-lock");
1894 drop(listener);
1895 let _ = std::fs::remove_file(&path);
1896 let first = acquire_bootstrap_lock(&path).unwrap();
1897 let contender_path = path.clone();
1898 let (acquired_tx, acquired_rx) = std::sync::mpsc::channel();
1899 let contender = thread::spawn(move || {
1900 let lock = acquire_bootstrap_lock(&contender_path).unwrap();
1901 acquired_tx.send(()).unwrap();
1902 drop(lock);
1903 });
1904 assert!(acquired_rx
1905 .recv_timeout(Duration::from_millis(100))
1906 .is_err());
1907 drop(first);
1908 acquired_rx.recv_timeout(Duration::from_secs(1)).unwrap();
1909 contender.join().unwrap();
1910 let _ = std::fs::remove_file(path.with_extension("bootstrap.lock"));
1911 }
1912
1913 #[test]
1914 fn successor_waits_for_the_daemon_singleton_lock_not_only_socket_removal() {
1915 let (seed, listener) = test_listener("singleton-release");
1916 drop(listener);
1917 let _ = std::fs::remove_file(&seed);
1918 let directory = seed.with_extension("state");
1919 std::fs::create_dir(&directory).unwrap();
1920 std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)).unwrap();
1921 let path = directory.join("wsx.sock");
1922 let lock_path = directory.join("state.lock");
1923 let owner = std::fs::OpenOptions::new()
1924 .create(true)
1925 .truncate(false)
1926 .read(true)
1927 .write(true)
1928 .mode(0o600)
1929 .open(&lock_path)
1930 .unwrap();
1931 assert_eq!(
1932 unsafe { libc::flock(owner.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
1933 0
1934 );
1935 let contender_path = path.clone();
1936 let (released_tx, released_rx) = std::sync::mpsc::channel();
1937 let contender = thread::spawn(move || {
1938 wait_for_singleton_release(&contender_path).unwrap();
1939 released_tx.send(()).unwrap();
1940 });
1941 assert!(released_rx
1942 .recv_timeout(Duration::from_millis(100))
1943 .is_err());
1944 drop(owner);
1945 released_rx.recv_timeout(Duration::from_secs(1)).unwrap();
1946 contender.join().unwrap();
1947 let _ = std::fs::remove_file(lock_path);
1948 let _ = std::fs::remove_dir(directory);
1949 }
1950
1951 #[test]
1952 fn shared_start_budget_blocks_a_loop_and_explicit_recovery_resets_it() {
1953 let (path, listener) = test_listener("start-budget");
1954 drop(listener);
1955 let _ = std::fs::remove_file(&path);
1956 let mut lock = acquire_bootstrap_lock(&path).unwrap();
1957 for _ in 0..MAX_START_ATTEMPTS {
1958 record_start_attempt(&mut lock.file, false).unwrap();
1959 }
1960 assert!(record_start_attempt(&mut lock.file, false)
1961 .unwrap_err()
1962 .to_string()
1963 .contains("crash_loop"));
1964 record_start_attempt(&mut lock.file, true).unwrap();
1965 drop(lock);
1966 let _ = std::fs::remove_file(path.with_extension("bootstrap.lock"));
1967 }
1968
1969 #[test]
1970 fn custom_client_never_spawns_a_default_daemon() {
1971 let (path, listener) = test_listener("custom-no-recovery");
1972 drop(listener);
1973 std::fs::remove_file(&path).unwrap();
1974 assert_eq!(
1975 ensure_background_available_with(&Client::new(path.clone()))
1976 .unwrap_err()
1977 .kind(),
1978 io::ErrorKind::Unsupported
1979 );
1980 assert!(!path.exists());
1981 }
1982
1983 #[test]
1984 fn intentional_stop_marker_disables_background_recovery() {
1985 let (path, listener) = test_listener("intentional-marker");
1986 drop(listener);
1987 std::fs::remove_file(&path).unwrap();
1988 let marker = path.with_extension("lifecycle");
1989 std::fs::write(&marker, "intentional\n").unwrap();
1990 std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o600)).unwrap();
1991 assert!(!background_recovery_allowed(&path));
1992 assert_eq!(
1993 ensure_background_available_with(&Client {
1994 socket: path.clone(),
1995 automatic_start: true,
1996 })
1997 .unwrap_err()
1998 .kind(),
1999 io::ErrorKind::ConnectionAborted
2000 );
2001 assert_eq!(
2002 consume_planned_marker(&path, Some("target"), 1).unwrap(),
2003 PlannedStart::Intentional
2004 );
2005 assert_eq!(lifecycle_marker_reason(&path).as_deref(), Some("starting"));
2006 assert_eq!(
2007 consume_planned_marker(&path, Some("target"), 1).unwrap(),
2008 PlannedStart::None
2009 );
2010 std::fs::write(&marker, "login_ended\n").unwrap();
2011 assert!(!background_recovery_allowed(&path));
2012 std::fs::write(&marker, "replacement:other\n").unwrap();
2013 assert!(consume_planned_marker(&path, Some("target"), 1).is_err());
2014 std::fs::write(&marker, "replacement:other|1\n").unwrap();
2015 assert_eq!(
2016 consume_planned_marker(&path, Some("target"), 1).unwrap(),
2017 PlannedStart::Replacement
2018 );
2019 std::fs::write(&marker, "unexpected\n").unwrap();
2020 assert!(background_recovery_allowed(&path));
2021 let _ = std::fs::remove_file(marker);
2022 let _ = std::fs::remove_file(path);
2023 }
2024
2025 #[test]
2026 fn detached_spawn_is_session_leader_and_survives_hangup() {
2027 let mut command = Command::new("sh");
2028 command.arg("-c").arg("kill -HUP $$; exec sleep 5");
2029
2030 let mut child = spawn_detached(&mut command).unwrap();
2031 thread::sleep(Duration::from_millis(20));
2032 let pid = child.id() as libc::pid_t;
2033 let session_id = unsafe { libc::getsid(pid) };
2034 let status = child.try_wait().unwrap();
2035 if status.is_none() {
2036 child.kill().unwrap();
2037 child.wait().unwrap();
2038 }
2039
2040 assert_eq!(session_id, pid, "detached daemon must own its session");
2041 assert!(status.is_none(), "detached daemon exited after SIGHUP");
2042 }
2043
2044 #[cfg(target_os = "macos")]
2045 #[test]
2046 fn local_socket_peer_matches_the_current_user() {
2047 let (client, server) = UnixStream::pair().unwrap();
2048 validate_peer_owner(&server).unwrap();
2049 validate_peer_owner(&client).unwrap();
2050 }
2051
2052 #[test]
2053 fn same_protocol_daemon_without_required_capabilities_is_not_stopped() {
2054 let (path, listener) = test_listener("missing-capabilities");
2055 let server_path = path.clone();
2056 let server = thread::spawn(move || {
2057 let (mut stream, _) = listener.accept().unwrap();
2058 assert_eq!(
2059 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2060 Request::Hello {
2061 protocol: PROTOCOL_VERSION
2062 }
2063 );
2064 send_response(
2065 &mut stream,
2066 &Response::Hello {
2067 protocol: PROTOCOL_VERSION,
2068 epoch: 1,
2069 capabilities: super::super::domain::Capabilities::default(),
2070 },
2071 );
2072 assert_eq!(
2073 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES)
2074 .unwrap_err()
2075 .kind(),
2076 io::ErrorKind::UnexpectedEof
2077 );
2078 drop(listener);
2079 std::fs::remove_file(server_path).unwrap();
2080 });
2081
2082 let error =
2083 daemon_needs_start(probe_existing_daemon(&Client::new(path)).unwrap()).unwrap_err();
2084 assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
2085 assert!(error.to_string().contains("missing required capabilities"));
2086 assert!(error.to_string().contains("refusing automatic shutdown"));
2087 assert!(error.to_string().contains("wsx daemon stop"));
2088 server.join().unwrap();
2089 }
2090
2091 #[test]
2092 fn protocol_mismatch_does_not_send_shutdown() {
2093 let (path, listener) = test_listener("protocol-skew");
2094 let server_path = path.clone();
2095 let server = thread::spawn(move || {
2096 let (mut stream, _) = listener.accept().unwrap();
2097 assert_eq!(
2098 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2099 Request::Hello {
2100 protocol: PROTOCOL_VERSION
2101 }
2102 );
2103 send_response(
2104 &mut stream,
2105 &Response::Error(super::super::protocol::ApiError::new(
2106 "protocol_mismatch",
2107 format!("client {PROTOCOL_VERSION}, daemon 8"),
2108 )),
2109 );
2110 assert_eq!(
2111 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES)
2112 .unwrap_err()
2113 .kind(),
2114 io::ErrorKind::UnexpectedEof
2115 );
2116 drop(listener);
2117 std::fs::remove_file(server_path).unwrap();
2118 });
2119
2120 let error =
2121 daemon_needs_start(probe_existing_daemon(&Client::new(path)).unwrap()).unwrap_err();
2122 assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
2123 assert!(error.to_string().contains("protocol mismatch"));
2124 assert!(error.to_string().contains("daemon protocol unknown"));
2125 assert!(error.to_string().contains("refusing automatic shutdown"));
2126 assert!(error.to_string().contains("wsx daemon stop"));
2127 server.join().unwrap();
2128 }
2129
2130 #[test]
2131 fn graceful_shutdown_waits_for_socket_cleanup() {
2132 let (path, listener) = test_listener("graceful-shutdown");
2133 let server_path = path.clone();
2134 let server = thread::spawn(move || {
2135 let (mut stream, _) = listener.accept().unwrap();
2136 assert_eq!(
2137 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2138 Request::Hello {
2139 protocol: PROTOCOL_VERSION
2140 }
2141 );
2142 send_response(
2143 &mut stream,
2144 &Response::Hello {
2145 protocol: PROTOCOL_VERSION,
2146 epoch: 1,
2147 capabilities: super::super::domain::Capabilities::default(),
2148 },
2149 );
2150 assert_eq!(
2151 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2152 Request::Shutdown
2153 );
2154 send_response(&mut stream, &Response::Ack { revision: 1 });
2155 drop(stream);
2156 drop(listener);
2157 std::fs::remove_file(server_path).unwrap();
2158 });
2159
2160 Client::new(path).shutdown().unwrap();
2161 server.join().unwrap();
2162 }
2163
2164 #[test]
2165 fn graceful_shutdown_surfaces_daemon_rejection() {
2166 let (path, listener) = test_listener("shutdown-rejection");
2167 let server_path = path.clone();
2168 let server = thread::spawn(move || {
2169 let (mut stream, _) = listener.accept().unwrap();
2170 assert!(matches!(
2171 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2172 Request::Hello {
2173 protocol: PROTOCOL_VERSION
2174 }
2175 ));
2176 send_response(
2177 &mut stream,
2178 &Response::Hello {
2179 protocol: PROTOCOL_VERSION,
2180 epoch: 1,
2181 capabilities: super::super::domain::Capabilities::default(),
2182 },
2183 );
2184 assert_eq!(
2185 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2186 Request::Shutdown
2187 );
2188 send_response(
2189 &mut stream,
2190 &Response::Error(super::super::protocol::ApiError::new(
2191 "shutdown_blocked",
2192 "still busy",
2193 )),
2194 );
2195 drop(listener);
2196 std::fs::remove_file(server_path).unwrap();
2197 });
2198
2199 let error = Client::new(path).shutdown().unwrap_err();
2200 assert!(error.to_string().contains("shutdown_blocked: still busy"));
2201 server.join().unwrap();
2202 }
2203
2204 #[test]
2205 fn graceful_shutdown_accepts_an_already_stopped_daemon() {
2206 let path = std::env::current_dir()
2207 .unwrap()
2208 .join(".work/s/already-stopped.sock");
2209 let _ = std::fs::remove_file(&path);
2210 Client::new(path).shutdown().unwrap();
2211 }
2212
2213 #[test]
2214 fn advertised_incompatible_daemon_shuts_down_on_the_handshake_connection() {
2215 let (path, listener) = test_listener("advertised-upgrade");
2216 let server_path = path.clone();
2217 let server = thread::spawn(move || {
2218 let (mut stream, _) = listener.accept().unwrap();
2219 assert!(matches!(
2220 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2221 Request::Hello {
2222 protocol: PROTOCOL_VERSION
2223 }
2224 ));
2225 let legacy_hello = serde_json::json!({
2226 "type": "hello",
2227 "data": {
2228 "protocol": PROTOCOL_VERSION - 1,
2229 "epoch": 1,
2230 "capabilities": {
2231 "pane_splits": true,
2232 "plugins": true,
2233 "agent_reports": true,
2234 "process_restore": false
2235 }
2236 }
2237 });
2238 stream
2239 .write_all(&encode_line(&legacy_hello).unwrap())
2240 .unwrap();
2241 assert_eq!(
2242 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2243 Request::Shutdown
2244 );
2245 send_response(&mut stream, &Response::Ack { revision: 1 });
2246 drop(listener);
2247 std::fs::remove_file(server_path).unwrap();
2248 });
2249
2250 Client::new(path).shutdown().unwrap();
2251 server.join().unwrap();
2252 }
2253
2254 #[test]
2255 fn unadvertised_protocol_two_daemon_shuts_down_on_the_handshake_connection() {
2256 let (path, listener) = test_listener("protocol-two-upgrade");
2257 let server_path = path.clone();
2258 let server = thread::spawn(move || {
2259 let (mut mismatch, _) = listener.accept().unwrap();
2260 assert_eq!(
2261 read_json_line::<Request>(&mut mismatch, MAX_RESPONSE_BYTES).unwrap(),
2262 Request::Hello {
2263 protocol: PROTOCOL_VERSION
2264 }
2265 );
2266 send_response(
2267 &mut mismatch,
2268 &Response::Error(super::super::protocol::ApiError::new(
2269 "protocol_mismatch",
2270 format!("client {PROTOCOL_VERSION}, daemon 2"),
2271 )),
2272 );
2273
2274 for protocol in (3..PROTOCOL_VERSION).rev() {
2275 let (mut probe, _) = listener.accept().unwrap();
2276 assert_eq!(
2277 read_json_line::<Request>(&mut probe, MAX_RESPONSE_BYTES).unwrap(),
2278 Request::Hello { protocol }
2279 );
2280 send_response(
2281 &mut probe,
2282 &Response::Error(super::super::protocol::ApiError::new(
2283 "protocol_mismatch",
2284 format!("client {protocol}, daemon 2"),
2285 )),
2286 );
2287 }
2288
2289 let (mut protocol_two, _) = listener.accept().unwrap();
2290 assert_eq!(
2291 read_json_line::<Request>(&mut protocol_two, MAX_RESPONSE_BYTES).unwrap(),
2292 Request::Hello { protocol: 2 }
2293 );
2294 send_response(
2295 &mut protocol_two,
2296 &Response::Hello {
2297 protocol: 2,
2298 epoch: 1,
2299 capabilities: super::super::domain::Capabilities::default(),
2300 },
2301 );
2302 assert_eq!(
2303 read_json_line::<Request>(&mut protocol_two, MAX_RESPONSE_BYTES).unwrap(),
2304 Request::Shutdown
2305 );
2306 send_response(&mut protocol_two, &Response::Ack { revision: 1 });
2307 drop(listener);
2308 std::fs::remove_file(server_path).unwrap();
2309 });
2310
2311 Client::new(path).shutdown().unwrap();
2312 server.join().unwrap();
2313 }
2314
2315 #[test]
2316 fn legacy_daemon_upgrade_uses_separate_request_connection() {
2317 let (path, listener) = test_listener("legacy-upgrade");
2318 let server_path = path.clone();
2319 let server = thread::spawn(move || {
2320 let (mut mismatch, _) = listener.accept().unwrap();
2321 assert_eq!(
2322 read_json_line::<Request>(&mut mismatch, MAX_RESPONSE_BYTES).unwrap(),
2323 Request::Hello {
2324 protocol: PROTOCOL_VERSION
2325 }
2326 );
2327 send_response(
2328 &mut mismatch,
2329 &Response::Error(super::super::protocol::ApiError::new(
2330 "protocol_mismatch",
2331 format!("client {PROTOCOL_VERSION}, daemon 1"),
2332 )),
2333 );
2334
2335 for protocol in (2..PROTOCOL_VERSION).rev() {
2336 let (mut probe, _) = listener.accept().unwrap();
2337 assert_eq!(
2338 read_json_line::<Request>(&mut probe, MAX_RESPONSE_BYTES).unwrap(),
2339 Request::Hello { protocol }
2340 );
2341 send_response(
2342 &mut probe,
2343 &Response::Error(super::super::protocol::ApiError::new(
2344 "protocol_mismatch",
2345 format!("client {protocol}, daemon 1"),
2346 )),
2347 );
2348 }
2349
2350 let (mut hello, _) = listener.accept().unwrap();
2351 assert_eq!(
2352 read_json_line::<Request>(&mut hello, MAX_RESPONSE_BYTES).unwrap(),
2353 Request::Hello { protocol: 1 }
2354 );
2355 send_response(
2356 &mut hello,
2357 &Response::Hello {
2358 protocol: 1,
2359 epoch: 1,
2360 capabilities: super::super::domain::Capabilities::default(),
2361 },
2362 );
2363 drop(hello);
2364
2365 let (mut shutdown, _) = listener.accept().unwrap();
2366 assert_eq!(
2367 read_json_line::<Request>(&mut shutdown, MAX_RESPONSE_BYTES).unwrap(),
2368 Request::Shutdown
2369 );
2370 send_response(&mut shutdown, &Response::Ack { revision: 1 });
2371 drop(listener);
2372 std::fs::remove_file(server_path).unwrap();
2373 });
2374
2375 Client::new(path).shutdown().unwrap();
2376 server.join().unwrap();
2377 }
2378
2379 #[test]
2380 fn large_json_line_uses_bounded_buffered_reads() {
2381 struct CountingReader {
2382 inner: io::Cursor<Vec<u8>>,
2383 reads: Arc<AtomicUsize>,
2384 }
2385 impl Read for CountingReader {
2386 fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
2387 self.reads.fetch_add(1, Ordering::Relaxed);
2388 self.inner.read(buffer)
2389 }
2390 }
2391
2392 let expected = "x".repeat(1024 * 1024);
2393 let mut encoded = serde_json::to_vec(&expected).unwrap();
2394 encoded.push(b'\n');
2395 let reads = Arc::new(AtomicUsize::new(0));
2396 let source = CountingReader {
2397 inner: io::Cursor::new(encoded),
2398 reads: Arc::clone(&reads),
2399 };
2400 let mut reader = BufReader::with_capacity(64 * 1024, source);
2401 let actual: String = read_buffered_json_line(&mut reader, 2 * 1024 * 1024).unwrap();
2402
2403 assert_eq!(actual, expected);
2404 assert!(reads.load(Ordering::Relaxed) < 32);
2405 }
2406
2407 #[test]
2408 fn terminal_stream_preserves_selection_updates_and_clipboard_order_after_subscribe_ack() {
2409 let (path, listener) = test_listener("buffered-subscribe");
2410 let server_path = path.clone();
2411 let server = thread::spawn(move || {
2412 let (mut stream, _) = listener.accept().unwrap();
2413 assert!(matches!(
2414 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2415 Request::Hello {
2416 protocol: PROTOCOL_VERSION
2417 }
2418 ));
2419 let mut bytes = encode_line(&Response::Hello {
2420 protocol: PROTOCOL_VERSION,
2421 epoch: 1,
2422 capabilities: super::super::domain::Capabilities::default(),
2423 })
2424 .unwrap();
2425 stream.write_all(&bytes).unwrap();
2426 assert!(matches!(
2427 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2428 Request::TerminalSubscribe {
2429 rows: 24,
2430 cols: 80,
2431 ..
2432 }
2433 ));
2434 bytes = encode_line(&Response::Ack { revision: 1 }).unwrap();
2435 bytes.extend(
2436 encode_line(&TerminalServerMessage::Update(TerminalUpdate::Full(
2437 TerminalFrame {
2438 pane_id: PaneId(1),
2439 terminal_id: TerminalId(2),
2440 revision: 1,
2441 cols: 1,
2442 rows: 1,
2443 cells: vec![Cell::default()],
2444 cursor: Cursor {
2445 x: 0,
2446 y: 0,
2447 visible: false,
2448 blinking: false,
2449 shape: 0,
2450 },
2451 selection: vec![TerminalSelectionRange {
2452 row: 0,
2453 start_col: 0,
2454 end_col: 0,
2455 }],
2456 },
2457 )))
2458 .unwrap(),
2459 );
2460 bytes.extend(
2461 encode_line(&TerminalServerMessage::Update(TerminalUpdate::Patch {
2462 pane_id: PaneId(1),
2463 terminal_id: TerminalId(2),
2464 base_revision: 1,
2465 revision: 2,
2466 cols: 1,
2467 rows: 1,
2468 changed_rows: Vec::new(),
2469 cursor: Cursor {
2470 x: 0,
2471 y: 0,
2472 visible: false,
2473 blinking: false,
2474 shape: 0,
2475 },
2476 selection: Vec::new(),
2477 }))
2478 .unwrap(),
2479 );
2480 bytes.extend(
2481 encode_line(&TerminalServerMessage::ClipboardWrite(b"copied".to_vec())).unwrap(),
2482 );
2483 bytes.extend(encode_line(&TerminalServerMessage::Exited).unwrap());
2484 stream.write_all(&bytes).unwrap();
2485 thread::sleep(Duration::from_millis(50));
2486 drop(listener);
2487 std::fs::remove_file(server_path).unwrap();
2488 });
2489
2490 let stream = TerminalStream::connect(
2491 &Client::new(path),
2492 super::super::domain::PaneId(1),
2493 7,
2494 false,
2495 24,
2496 80,
2497 )
2498 .unwrap();
2499 let deadline = Instant::now() + Duration::from_secs(1);
2500 let mut selections = Vec::new();
2501 let mut clipboard = None;
2502 loop {
2503 match stream.try_recv() {
2504 Ok(TerminalServerMessage::Update(TerminalUpdate::Full(frame))) => {
2505 selections.push(frame.selection)
2506 }
2507 Ok(TerminalServerMessage::Update(TerminalUpdate::Patch { selection, .. })) => {
2508 selections.push(selection)
2509 }
2510 Ok(TerminalServerMessage::ClipboardWrite(text)) => clipboard = Some(text),
2511 Ok(TerminalServerMessage::Exited) => break,
2512 Ok(message) => panic!("unexpected terminal message: {message:?}"),
2513 Err(mpsc::TryRecvError::Empty) if Instant::now() < deadline => {
2514 thread::sleep(Duration::from_millis(10));
2515 }
2516 Err(error) => panic!("terminal update missing after ACK: {error}"),
2517 }
2518 }
2519 assert_eq!(
2520 selections,
2521 vec![
2522 vec![TerminalSelectionRange {
2523 row: 0,
2524 start_col: 0,
2525 end_col: 0,
2526 }],
2527 Vec::new(),
2528 ]
2529 );
2530 assert_eq!(clipboard.as_deref(), Some(b"copied".as_slice()));
2531 drop(stream);
2532 server.join().unwrap();
2533 }
2534
2535 #[test]
2536 fn terminal_reader_shutdown_interrupts_full_update_queue() {
2537 let (mut server, client) = UnixStream::pair().unwrap();
2538 let (updates, receiver) = mpsc::sync_channel(1);
2539 updates.send(TerminalServerMessage::Exited).unwrap();
2540 let stopping = Arc::new(AtomicBool::new(false));
2541 let reader_stopping = Arc::clone(&stopping);
2542 let (done, finished) = mpsc::channel();
2543 let reader = thread::spawn(move || {
2544 terminal_reader(BufReader::new(client), updates, &reader_stopping);
2545 done.send(()).unwrap();
2546 });
2547
2548 server
2549 .write_all(&encode_line(&TerminalServerMessage::Exited).unwrap())
2550 .unwrap();
2551 thread::sleep(Duration::from_millis(50));
2552 stopping.store(true, Ordering::Release);
2553
2554 assert!(finished.recv_timeout(Duration::from_secs(1)).is_ok());
2555 drop(receiver);
2556 reader.join().unwrap();
2557 }
2558}