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