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