1use super::protocol::{
2 binary_identity, binary_identity_with_version, encode_line, Request, Response,
3 TerminalClientMessage, TerminalServerMessage, 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 let mut response = lifecycle_round_trip(client, &replacement_request)?;
644 if let Some(request) = legacy_handoff_retry_request(
648 live_handoff,
649 &status,
650 &daemon_version,
651 &response,
652 binary,
653 )? {
654 response = lifecycle_round_trip(client, &request)?;
655 }
656 match response {
657 Response::Replacement {
658 disposition: super::domain::ReplacementDisposition::Stopping,
659 ..
660 } => {
661 set_active_protocol(client.socket(), PROTOCOL_VERSION);
662 if live_handoff {
663 wait_until_handoff_ready(client, super::protocol::DAEMON_REVISION)?;
664 } else {
665 wait_until_stopped(client)?;
666 consume_planned_marker(
667 client.socket(),
668 Some(&target_binary_id),
669 super::protocol::DAEMON_REVISION,
670 )?;
671 start_daemon(client, binary, &mut bootstrap, true)?;
672 }
673 if live_handoff {
674 Ok(Availability::Current)
675 } else {
676 Ok(Availability::DaemonReplaced {
677 previous_version: daemon_version,
678 })
679 }
680 }
681 Response::Replacement {
682 disposition: super::domain::ReplacementDisposition::Deferred,
683 live_runtimes,
684 daemon_version: response_daemon_version,
685 target_version: response_target_version,
686 blockers,
687 use_current_daemon,
688 } if compatible => {
689 let daemon_version = nonempty_or(response_daemon_version, daemon_version);
690 if use_current_daemon {
691 Ok(Availability::NewerDaemon { daemon_version })
692 } else {
693 Ok(Availability::ReplacementDeferred {
694 daemon_version,
695 target_version: nonempty_or(response_target_version, target_version),
696 live_runtimes,
697 blockers: if blockers.is_empty() && !version_coordination {
698 vec![super::domain::ReplacementBlocker::LegacyDaemon]
699 } else {
700 blockers
701 },
702 })
703 }
704 }
705 Response::Replacement {
706 live_runtimes,
707 daemon_version: response_daemon_version,
708 target_version: response_target_version,
709 blockers,
710 ..
711 } if compatibility_protocol.is_some() => {
712 set_active_protocol(client.socket(), compatibility_protocol.unwrap());
713 Ok(Availability::ReplacementDeferred {
714 daemon_version: nonempty_or(response_daemon_version, daemon_version),
715 target_version: nonempty_or(response_target_version, target_version),
716 live_runtimes,
717 blockers,
718 })
719 }
720 Response::Replacement { live_runtimes, .. } => Err(io::Error::new(
721 io::ErrorKind::AlreadyExists,
722 format!(
723 "replacement_deferred: incompatible wsxd is protecting {live_runtimes} live runtime(s)"
724 ),
725 )),
726 Response::Error(error) if compatible && error.code == "replacement_conflict" => {
727 Ok(Availability::ReplacementDeferred {
728 daemon_version,
729 target_version,
730 live_runtimes: status.live_runtimes,
731 blockers: vec![super::domain::ReplacementBlocker::PendingTarget],
732 })
733 }
734 response => Err(io::Error::new(
735 io::ErrorKind::InvalidData,
736 format!("unexpected wsxd replacement response: {response:?}"),
737 )),
738 }
739 }
740 incompatible @ ExistingDaemon::Incompatible { .. } => {
741 daemon_needs_start(incompatible)?;
742 unreachable!("incompatible daemon must return an error");
743 }
744 ExistingDaemon::Ready { .. } => {
745 set_active_protocol(client.socket(), PROTOCOL_VERSION);
746 Ok(Availability::LegacyCompatible)
747 }
748 }
749}
750
751fn legacy_handoff_retry_request(
752 live_handoff: bool,
753 status: &super::domain::DaemonLifecycle,
754 daemon_version: &str,
755 response: &Response,
756 binary: &Path,
757) -> io::Result<Option<Request>> {
758 if !live_handoff
759 || status.daemon_revision >= super::protocol::DAEMON_REVISION
760 || super::protocol::compare_wsx_versions(daemon_version, super::protocol::WSX_VERSION)
761 != Some(std::cmp::Ordering::Less)
762 || !matches!(
763 response,
764 Response::Error(error) if error.code == "invalid_handoff_target"
765 )
766 {
767 return Ok(None);
768 }
769 Ok(Some(Request::PrepareHandoff {
770 target_binary_id: binary_identity_with_version(binary, daemon_version)?,
771 target_version: daemon_version.to_string(),
772 target_protocol: PROTOCOL_VERSION,
773 target_daemon_revision: super::protocol::DAEMON_REVISION,
774 executable: binary.to_path_buf(),
775 }))
776}
777
778fn lifecycle_status(client: &Client) -> io::Result<super::domain::DaemonLifecycle> {
779 match lifecycle_round_trip(client, &Request::LifecycleStatus)? {
780 Response::Lifecycle(status) => Ok(status),
781 response => Err(io::Error::new(
782 io::ErrorKind::InvalidData,
783 format!("unexpected wsxd lifecycle response: {response:?}"),
784 )),
785 }
786}
787
788fn legacy_daemon_has_other_clients(status: &super::domain::DaemonLifecycle) -> bool {
789 active_client_count_has_other_tui(
790 status.active_clients,
791 ACTIVE_TUI_MONITORS.load(Ordering::Acquire),
792 )
793}
794
795fn active_client_count_has_other_tui(active_clients: usize, own_tui_monitors: usize) -> bool {
796 active_clients > 1_usize.saturating_add(own_tui_monitors)
797}
798
799fn lifecycle_version(status: &super::domain::DaemonLifecycle) -> String {
800 if status.version.is_empty() {
801 super::protocol::binary_identity_version(&status.binary_id)
802 .unwrap_or("unknown")
803 .to_string()
804 } else {
805 status.version.clone()
806 }
807}
808
809fn nonempty_or(value: String, fallback: String) -> String {
810 if value.is_empty() {
811 fallback
812 } else {
813 value
814 }
815}
816
817fn ready_without_transition(
818 client: &Client,
819 existing: &ExistingDaemon,
820 target_binary_id: Option<&str>,
821) -> io::Result<Option<Availability>> {
822 let ExistingDaemon::Ready {
823 lifecycle_coordination,
824 daemon_revision_coordination,
825 ..
826 } = existing
827 else {
828 return Ok(None);
829 };
830 if !lifecycle_coordination {
831 return Ok(Some(Availability::LegacyCompatible));
832 }
833 let status = lifecycle_status(client)?;
834 let daemon_version = lifecycle_version(&status);
835 if super::protocol::compare_wsx_versions(&daemon_version, super::protocol::WSX_VERSION)
836 == Some(std::cmp::Ordering::Greater)
837 {
838 return Ok(Some(Availability::NewerDaemon { daemon_version }));
839 }
840 if *daemon_revision_coordination && status.daemon_revision >= super::protocol::DAEMON_REVISION {
841 return Ok(Some(if status.recovered_from_backup {
842 Availability::RecoveredFromBackup
843 } else {
844 Availability::Current
845 }));
846 }
847 if target_binary_id.is_none_or(|target| target == status.binary_id) {
848 Ok(Some(
849 if status.phase == super::domain::DaemonPhase::ReplacementPending {
850 Availability::ReplacementDeferred {
851 daemon_version,
852 target_version: nonempty_or(
853 status.replacement_target_version,
854 super::protocol::WSX_VERSION.to_string(),
855 ),
856 live_runtimes: status.live_runtimes,
857 blockers: status.replacement_blockers,
858 }
859 } else if status.recovered_from_backup {
860 Availability::RecoveredFromBackup
861 } else {
862 Availability::Current
863 },
864 ))
865 } else {
866 Ok(None)
867 }
868}
869
870struct SignalMaskGuard {
871 previous: libc::sigset_t,
872 restored: bool,
873}
874
875impl SignalMaskGuard {
876 fn block(signal: libc::c_int) -> io::Result<Self> {
877 let mut blocked = unsafe { std::mem::zeroed::<libc::sigset_t>() };
878 if unsafe { libc::sigemptyset(&mut blocked) } == -1
879 || unsafe { libc::sigaddset(&mut blocked, signal) } == -1
880 {
881 return Err(io::Error::last_os_error());
882 }
883 let mut previous = unsafe { std::mem::zeroed::<libc::sigset_t>() };
884 let result = unsafe { libc::pthread_sigmask(libc::SIG_BLOCK, &blocked, &mut previous) };
885 if result != 0 {
886 return Err(io::Error::from_raw_os_error(result));
887 }
888 Ok(Self {
889 previous,
890 restored: false,
891 })
892 }
893
894 fn restore(&mut self) -> io::Result<()> {
895 if self.restored {
896 return Ok(());
897 }
898 let result = unsafe {
899 libc::pthread_sigmask(libc::SIG_SETMASK, &self.previous, std::ptr::null_mut())
900 };
901 if result != 0 {
902 return Err(io::Error::from_raw_os_error(result));
903 }
904 self.restored = true;
905 Ok(())
906 }
907}
908
909impl Drop for SignalMaskGuard {
910 fn drop(&mut self) {
911 let _ = self.restore();
912 }
913}
914
915fn spawn_detached(command: &mut Command) -> io::Result<Child> {
916 let mut signal_mask = SignalMaskGuard::block(libc::SIGHUP)?;
917 let child_signal_mask = signal_mask.previous;
918 unsafe {
921 command.pre_exec(move || {
922 if libc::setsid() == -1 {
923 return Err(io::Error::last_os_error());
924 }
925 if libc::signal(libc::SIGHUP, libc::SIG_IGN) == libc::SIG_ERR {
926 return Err(io::Error::last_os_error());
927 }
928 let result =
929 libc::pthread_sigmask(libc::SIG_SETMASK, &child_signal_mask, std::ptr::null_mut());
930 if result != 0 {
931 return Err(io::Error::from_raw_os_error(result));
932 }
933 Ok(())
934 });
935 }
936
937 let child = command.spawn();
938 if let Err(error) = signal_mask.restore() {
939 if let Ok(mut child) = child {
940 let _ = child.kill();
941 let _ = child.wait();
942 }
943 return Err(io::Error::new(
944 error.kind(),
945 format!("could not restore the daemon launcher signal mask: {error}"),
946 ));
947 }
948 child
949}
950
951fn start_daemon(
952 client: &Client,
953 binary: &Path,
954 bootstrap: &mut BootstrapLock,
955 reset_crash_budget: bool,
956) -> io::Result<Availability> {
957 wait_for_singleton_release(client.socket())?;
958 record_start_attempt(&mut bootstrap.file, reset_crash_budget)?;
959 let expected_binary_id = binary_identity(binary).ok();
960 let mut command = Command::new(binary);
961 command
962 .stdin(Stdio::null())
963 .stdout(Stdio::null())
964 .stderr(Stdio::null());
965 let mut child = spawn_detached(&mut command).map_err(|error| {
966 io::Error::new(
967 error.kind(),
968 format!("could not start {}: {error}", binary.display()),
969 )
970 })?;
971 thread::Builder::new()
972 .name("wsxd-reaper".into())
973 .spawn(move || {
974 let _ = child.wait();
975 })?;
976
977 let deadline = Instant::now() + IO_TIMEOUT;
978 loop {
979 match client.call(&Request::Snapshot) {
980 Ok(Response::Snapshot(snapshot)) => {
981 let status = if snapshot.capabilities.lifecycle_coordination {
982 let Response::Lifecycle(status) = client.call(&Request::LifecycleStatus)?
983 else {
984 return Err(io::Error::new(
985 io::ErrorKind::InvalidData,
986 "started wsxd returned an invalid lifecycle response",
987 ));
988 };
989 Some(status)
990 } else {
991 None
992 };
993 if let Some(expected) = expected_binary_id.as_deref() {
994 let status = status.as_ref().ok_or_else(|| {
995 io::Error::new(
996 io::ErrorKind::InvalidData,
997 "started wsxd does not expose lifecycle identity",
998 )
999 })?;
1000 if status.binary_id != expected {
1001 return Err(io::Error::new(
1002 io::ErrorKind::AlreadyExists,
1003 "started wsxd binary identity does not match the elected replacement",
1004 ));
1005 }
1006 }
1007 return Ok(
1008 if status.is_some_and(|status| status.recovered_from_backup) {
1009 Availability::RecoveredFromBackup
1010 } else {
1011 Availability::Current
1012 },
1013 );
1014 }
1015 Ok(Response::Error(error)) => {
1016 return Err(io::Error::other(format!(
1017 "{}: {}",
1018 error.code, error.message
1019 )))
1020 }
1021 Ok(_) => {
1022 return Err(io::Error::new(
1023 io::ErrorKind::InvalidData,
1024 "unexpected wsxd response",
1025 ))
1026 }
1027 Err(_) if Instant::now() < deadline => thread::sleep(Duration::from_millis(50)),
1028 Err(error) => {
1029 return Err(io::Error::new(
1030 error.kind(),
1031 format!("wsxd did not become ready: {error}"),
1032 ))
1033 }
1034 }
1035 }
1036}
1037
1038struct BootstrapLock {
1039 file: File,
1040}
1041
1042fn wait_for_singleton_release(socket: &Path) -> io::Result<()> {
1043 let path = socket
1044 .parent()
1045 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "wsxd socket has no parent"))?
1046 .join("state.lock");
1047 let file = fs::OpenOptions::new()
1048 .create(true)
1049 .truncate(false)
1050 .read(true)
1051 .write(true)
1052 .mode(0o600)
1053 .custom_flags(libc::O_NOFOLLOW)
1054 .open(&path)?;
1055 let metadata = file.metadata()?;
1056 if !metadata.is_file()
1057 || metadata.uid() != unsafe { libc::geteuid() }
1058 || metadata.mode() & 0o077 != 0
1059 {
1060 return Err(io::Error::new(
1061 io::ErrorKind::PermissionDenied,
1062 "unsafe wsxd singleton lock",
1063 ));
1064 }
1065 let deadline = Instant::now() + IO_TIMEOUT;
1066 loop {
1067 let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
1068 if result == 0 {
1069 unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
1070 return Ok(());
1071 }
1072 let error = io::Error::last_os_error();
1073 if error.raw_os_error() != Some(libc::EWOULDBLOCK) || Instant::now() >= deadline {
1074 return Err(io::Error::new(
1075 error.kind(),
1076 format!("wsxd singleton lock did not become available: {error}"),
1077 ));
1078 }
1079 thread::sleep(Duration::from_millis(25));
1080 }
1081}
1082
1083fn acquire_bootstrap_lock(socket: &Path) -> io::Result<BootstrapLock> {
1084 let parent = socket
1085 .parent()
1086 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "wsxd socket has no parent"))?;
1087 match fs::symlink_metadata(parent) {
1088 Ok(_) => {}
1089 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1090 fs::create_dir_all(parent)?;
1091 fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
1092 }
1093 Err(error) => return Err(error),
1094 }
1095 let metadata = fs::symlink_metadata(parent)?;
1096 if metadata.file_type().is_symlink()
1097 || !metadata.is_dir()
1098 || metadata.uid() != unsafe { libc::geteuid() }
1099 || metadata.mode() & 0o077 != 0
1100 {
1101 return Err(io::Error::new(
1102 io::ErrorKind::PermissionDenied,
1103 "unsafe wsx state directory",
1104 ));
1105 }
1106 let path = socket.with_extension("bootstrap.lock");
1107 let file = fs::OpenOptions::new()
1108 .create(true)
1109 .truncate(false)
1110 .read(true)
1111 .write(true)
1112 .mode(0o600)
1113 .custom_flags(libc::O_NOFOLLOW)
1114 .open(&path)?;
1115 let metadata = file.metadata()?;
1116 if !metadata.is_file()
1117 || metadata.uid() != unsafe { libc::geteuid() }
1118 || metadata.mode() & 0o077 != 0
1119 || metadata.len() > 4096
1120 {
1121 return Err(io::Error::new(
1122 io::ErrorKind::PermissionDenied,
1123 "unsafe wsxd bootstrap lock",
1124 ));
1125 }
1126 let deadline = Instant::now() + IO_TIMEOUT;
1127 loop {
1128 let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
1129 if result == 0 {
1130 return Ok(BootstrapLock { file });
1131 }
1132 let error = io::Error::last_os_error();
1133 if error.raw_os_error() != Some(libc::EWOULDBLOCK) || Instant::now() >= deadline {
1134 return Err(io::Error::new(
1135 error.kind(),
1136 format!("could not coordinate wsxd startup: {error}"),
1137 ));
1138 }
1139 thread::sleep(Duration::from_millis(25));
1140 }
1141}
1142
1143fn record_start_attempt(file: &mut File, reset: bool) -> io::Result<()> {
1144 let now = SystemTime::now()
1145 .duration_since(UNIX_EPOCH)
1146 .unwrap_or_default()
1147 .as_secs();
1148 file.seek(SeekFrom::Start(0))?;
1149 let mut text = String::new();
1150 file.take(4097).read_to_string(&mut text)?;
1151 let mut attempts = if reset {
1152 Vec::new()
1153 } else {
1154 text.lines()
1155 .filter_map(|line| line.parse::<u64>().ok())
1156 .filter(|attempt| *attempt <= now && now - *attempt < START_WINDOW.as_secs())
1157 .collect::<Vec<_>>()
1158 };
1159 if attempts.len() >= MAX_START_ATTEMPTS {
1160 return Err(io::Error::new(
1161 io::ErrorKind::WouldBlock,
1162 "crash_loop: wsxd exceeded 3 automatic starts in 60 seconds; run `wsx daemon recover` to try explicitly",
1163 ));
1164 }
1165 if !attempts.is_empty() {
1166 thread::sleep(Duration::from_millis(100_u64 << attempts.len().min(3)));
1167 }
1168 attempts.push(now);
1169 file.seek(SeekFrom::Start(0))?;
1170 file.set_len(0)?;
1171 for attempt in attempts {
1172 writeln!(file, "{attempt}")?;
1173 }
1174 file.sync_all()
1175}
1176
1177fn write_lifecycle_marker(socket: &Path, reason: &str) -> io::Result<()> {
1178 let path = socket.with_extension("lifecycle");
1179 let temporary = path.with_extension(format!("lifecycle.tmp.{}", std::process::id()));
1180 let result = (|| {
1181 let mut file = fs::OpenOptions::new()
1182 .create(true)
1183 .truncate(true)
1184 .write(true)
1185 .mode(0o600)
1186 .custom_flags(libc::O_NOFOLLOW)
1187 .open(&temporary)?;
1188 file.set_permissions(fs::Permissions::from_mode(0o600))?;
1189 writeln!(file, "{reason}")?;
1190 file.sync_all()?;
1191 fs::rename(&temporary, path)
1192 })();
1193 if result.is_err() {
1194 let _ = fs::remove_file(&temporary);
1195 }
1196 result
1197}
1198
1199#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1200enum PlannedStart {
1201 None,
1202 Intentional,
1203 Replacement,
1204}
1205
1206fn consume_planned_marker(
1207 socket: &Path,
1208 target_binary_id: Option<&str>,
1209 target_daemon_revision: u32,
1210) -> io::Result<PlannedStart> {
1211 let Some(reason) = lifecycle_marker_reason(socket) else {
1212 return Ok(PlannedStart::None);
1213 };
1214 let planned = match reason.as_str() {
1215 "intentional" | "login_ended" => PlannedStart::Intentional,
1216 reason if reason.starts_with("replacement:") => {
1217 let expected = reason.trim_start_matches("replacement:");
1218 let compatible = expected
1219 .rsplit_once('|')
1220 .and_then(|(_, revision)| revision.parse::<u32>().ok())
1221 .is_some_and(|revision| revision > 0 && revision == target_daemon_revision)
1222 || target_binary_id == Some(expected);
1223 if !compatible {
1224 return Err(io::Error::new(
1225 io::ErrorKind::AlreadyExists,
1226 "replacement_protected: the pending wsxd replacement belongs to another daemon revision",
1227 ));
1228 }
1229 PlannedStart::Replacement
1230 }
1231 _ => PlannedStart::None,
1232 };
1233 if planned != PlannedStart::None {
1234 write_lifecycle_marker(socket, "starting")?;
1235 }
1236 Ok(planned)
1237}
1238
1239fn lifecycle_marker_reason(socket: &Path) -> Option<String> {
1240 let path = socket.with_extension("lifecycle");
1241 let file = fs::OpenOptions::new()
1242 .read(true)
1243 .custom_flags(libc::O_NOFOLLOW)
1244 .open(path)
1245 .ok()?;
1246 let metadata = file.metadata().ok()?;
1247 if !metadata.is_file()
1248 || metadata.uid() != unsafe { libc::geteuid() }
1249 || metadata.mode() & 0o077 != 0
1250 || metadata.len() > 1024
1251 {
1252 return None;
1253 }
1254 let mut reason = String::new();
1255 file.take(1025).read_to_string(&mut reason).ok()?;
1256 Some(reason.trim().to_string())
1257}
1258
1259fn background_recovery_allowed(socket: &Path) -> bool {
1260 match lifecycle_marker_reason(socket).as_deref() {
1261 None => !socket.with_extension("lifecycle").exists(),
1262 Some("ready" | "unexpected" | "starting") => true,
1263 Some(reason) if reason.starts_with("replacement:") => true,
1264 Some(_) => false,
1265 }
1266}
1267
1268fn lifecycle_round_trip(client: &Client, request: &Request) -> io::Result<Response> {
1269 let mut stream = client.connect()?;
1270 match round_trip(
1271 &mut stream,
1272 &Request::Hello {
1273 protocol: PROTOCOL_VERSION,
1274 },
1275 )? {
1276 Response::Hello { .. } => round_trip(&mut stream, request),
1277 Response::Error(error) => Err(io::Error::other(format!(
1278 "{}: {}",
1279 error.code, error.message
1280 ))),
1281 _ => Err(io::Error::new(
1282 io::ErrorKind::InvalidData,
1283 "wsxd lifecycle handshake failed",
1284 )),
1285 }
1286}
1287
1288#[derive(Debug)]
1289enum ExistingDaemon {
1290 Ready {
1291 lifecycle_coordination: bool,
1292 version_coordination: bool,
1293 daemon_revision_coordination: bool,
1294 live_handoff: bool,
1295 },
1296 Missing,
1297 Incompatible {
1298 stream: Option<UnixStream>,
1299 advertised_protocol: Option<u32>,
1300 lifecycle_coordination: bool,
1301 version_coordination: bool,
1302 },
1303}
1304
1305fn daemon_needs_start(existing: ExistingDaemon) -> io::Result<bool> {
1306 match existing {
1307 ExistingDaemon::Ready { .. } => Ok(false),
1308 ExistingDaemon::Missing => Ok(true),
1309 ExistingDaemon::Incompatible {
1311 advertised_protocol,
1312 ..
1313 } => Err(incompatible_daemon_error(advertised_protocol)),
1314 }
1315}
1316
1317fn incompatible_daemon_error(advertised_protocol: Option<u32>) -> io::Error {
1318 let daemon_protocol = advertised_protocol
1319 .map(|protocol| protocol.to_string())
1320 .unwrap_or_else(|| "unknown".into());
1321 let reason = if advertised_protocol == Some(PROTOCOL_VERSION) {
1322 "missing required capabilities"
1323 } else {
1324 "protocol mismatch"
1325 };
1326 io::Error::new(
1327 io::ErrorKind::AlreadyExists,
1328 format!(
1329 "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"
1330 ),
1331 )
1332}
1333
1334fn probe_existing_daemon(client: &Client) -> io::Result<ExistingDaemon> {
1335 let mut stream = match client.connect() {
1336 Ok(stream) => stream,
1337 Err(error)
1338 if matches!(
1339 error.kind(),
1340 io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused
1341 ) =>
1342 {
1343 return Ok(ExistingDaemon::Missing)
1344 }
1345 Err(error) => return Err(error),
1346 };
1347 match round_trip(
1348 &mut stream,
1349 &Request::Hello {
1350 protocol: PROTOCOL_VERSION,
1351 },
1352 )? {
1353 Response::Hello {
1354 protocol,
1355 capabilities,
1356 ..
1357 } if protocol == PROTOCOL_VERSION
1358 && capabilities.resume_shell_fallback
1359 && capabilities.foreground_jobs =>
1360 {
1361 let lifecycle_coordination = capabilities.lifecycle_coordination;
1362 let version_coordination = capabilities.version_coordination;
1363 let daemon_revision_coordination = capabilities.daemon_revision_coordination;
1364 let live_handoff = capabilities.live_handoff;
1365 match round_trip(&mut stream, &Request::Snapshot)? {
1366 Response::Snapshot(_) => Ok(ExistingDaemon::Ready {
1367 lifecycle_coordination,
1368 version_coordination,
1369 daemon_revision_coordination,
1370 live_handoff,
1371 }),
1372 Response::Error(error) => Err(io::Error::other(format!(
1373 "{}: {}",
1374 error.code, error.message
1375 ))),
1376 _ => Err(io::Error::new(
1377 io::ErrorKind::InvalidData,
1378 "unexpected wsxd response",
1379 )),
1380 }
1381 }
1382 Response::Hello {
1383 protocol,
1384 capabilities,
1385 ..
1386 } => Ok(ExistingDaemon::Incompatible {
1387 stream: Some(stream),
1388 advertised_protocol: Some(protocol),
1389 lifecycle_coordination: capabilities.lifecycle_coordination,
1390 version_coordination: capabilities.version_coordination,
1391 }),
1392 Response::Error(error) if error.code == "protocol_mismatch" => {
1393 Ok(ExistingDaemon::Incompatible {
1394 stream: None,
1395 advertised_protocol: None,
1396 lifecycle_coordination: false,
1397 version_coordination: false,
1398 })
1399 }
1400 Response::Error(error) => Err(io::Error::other(format!(
1401 "{}: {}",
1402 error.code, error.message
1403 ))),
1404 _ => Err(io::Error::new(
1405 io::ErrorKind::InvalidData,
1406 "wsxd protocol handshake failed",
1407 )),
1408 }
1409}
1410
1411fn shutdown_incompatible_daemon(
1412 client: &Client,
1413 stream: Option<UnixStream>,
1414 advertised_protocol: Option<u32>,
1415) -> io::Result<()> {
1416 let mut stream = match (stream, advertised_protocol) {
1417 (Some(stream), Some(_)) => stream,
1418 (None, None) => connect_unadvertised_legacy_daemon(client)?,
1419 _ => {
1420 return Err(io::Error::other(
1421 "incompatible wsxd did not advertise a restartable protocol",
1422 ))
1423 }
1424 };
1425 match round_trip(&mut stream, &Request::Shutdown)? {
1426 Response::Ack { .. } => Ok(()),
1427 Response::Error(error) => Err(io::Error::other(format!(
1428 "{}: {}",
1429 error.code, error.message
1430 ))),
1431 _ => Err(io::Error::new(
1432 io::ErrorKind::InvalidData,
1433 "unexpected wsxd shutdown response",
1434 )),
1435 }
1436}
1437
1438fn connect_unadvertised_legacy_daemon(client: &Client) -> io::Result<UnixStream> {
1439 for protocol in (1..PROTOCOL_VERSION).rev() {
1440 let mut stream = client.connect()?;
1441 match round_trip(&mut stream, &Request::Hello { protocol })? {
1442 Response::Hello {
1443 protocol: accepted, ..
1444 } if accepted == protocol => {
1445 if protocol == 1 {
1446 drop(stream);
1448 return client.connect();
1449 }
1450 return Ok(stream);
1451 }
1452 Response::Error(error) if error.code == "protocol_mismatch" => continue,
1453 Response::Error(error) => {
1454 return Err(io::Error::other(format!(
1455 "{}: {}",
1456 error.code, error.message
1457 )))
1458 }
1459 _ => {
1460 return Err(io::Error::new(
1461 io::ErrorKind::InvalidData,
1462 "legacy wsxd protocol handshake failed",
1463 ))
1464 }
1465 }
1466 }
1467 Err(io::Error::other(
1468 "incompatible wsxd protocol could not be negotiated for shutdown",
1469 ))
1470}
1471
1472fn daemon_is_stopped_error(error: &io::Error) -> bool {
1473 matches!(
1474 error.kind(),
1475 io::ErrorKind::NotFound
1476 | io::ErrorKind::ConnectionRefused
1477 | io::ErrorKind::ConnectionReset
1478 | io::ErrorKind::UnexpectedEof
1479 )
1480}
1481
1482fn wait_until_stopped(client: &Client) -> io::Result<()> {
1483 let deadline = Instant::now() + IO_TIMEOUT;
1484 loop {
1485 match probe_existing_daemon(client) {
1486 Ok(ExistingDaemon::Missing) => return Ok(()),
1487 Ok(ExistingDaemon::Ready { .. } | ExistingDaemon::Incompatible { .. })
1488 if Instant::now() < deadline => {}
1489 Err(error) if daemon_is_stopped_error(&error) => return Ok(()),
1490 Err(_) if Instant::now() < deadline => {}
1491 Ok(ExistingDaemon::Ready { .. } | ExistingDaemon::Incompatible { .. }) => {
1492 return Err(io::Error::new(io::ErrorKind::TimedOut, "wsxd did not stop"))
1493 }
1494 Err(error) => return Err(error),
1495 }
1496 thread::sleep(Duration::from_millis(50));
1497 }
1498}
1499
1500fn wait_until_handoff_ready(client: &Client, target_revision: u32) -> io::Result<()> {
1501 let deadline = Instant::now() + HANDOFF_WAIT;
1502 loop {
1503 match probe_existing_daemon(client) {
1504 Ok(ExistingDaemon::Ready { .. }) => {
1505 if let Ok(status) = lifecycle_status(client) {
1506 if status.daemon_revision >= target_revision
1507 && status.phase == super::domain::DaemonPhase::Ready
1508 {
1509 return Ok(());
1510 }
1511 }
1512 }
1513 Ok(ExistingDaemon::Missing | ExistingDaemon::Incompatible { .. }) => {}
1514 Err(error) if daemon_is_stopped_error(&error) => {}
1515 Err(error) if Instant::now() >= deadline => return Err(error),
1516 Err(_) => {}
1517 }
1518 if Instant::now() >= deadline {
1519 return Err(io::Error::new(
1520 io::ErrorKind::TimedOut,
1521 "wsxd live handoff did not become ready",
1522 ));
1523 }
1524 thread::sleep(Duration::from_millis(50));
1525 }
1526}
1527
1528fn daemon_binary() -> PathBuf {
1529 if let Some(path) = std::env::var_os("WSX_DAEMON_BIN").filter(|value| !value.is_empty()) {
1530 return PathBuf::from(path);
1531 }
1532 if let Ok(current) = std::env::current_exe() {
1533 if let Some(parent) = current.parent() {
1534 let adjacent = parent.join(format!("wsxd{}", std::env::consts::EXE_SUFFIX));
1535 if adjacent.is_file() {
1536 return adjacent;
1537 }
1538 }
1539 }
1540 PathBuf::from(format!("wsxd{}", std::env::consts::EXE_SUFFIX))
1541}
1542
1543#[derive(Debug, Clone, PartialEq, Eq)]
1544pub enum EventSignal {
1545 Dirty,
1546 Connected(Option<Availability>),
1547 Disconnected(String),
1548}
1549
1550pub struct EventMonitor {
1551 stopping: Arc<AtomicBool>,
1552 thread: Option<thread::JoinHandle<()>>,
1553}
1554impl EventMonitor {
1555 pub fn start(client: Client) -> io::Result<(Self, mpsc::Receiver<EventSignal>)> {
1556 let (sender, receiver) = mpsc::channel();
1557 let stopping = Arc::new(AtomicBool::new(false));
1558 let stop = Arc::clone(&stopping);
1559 let tui = super::domain::TuiClientPresence {
1560 instance_id: new_client_id(),
1561 version: super::protocol::WSX_VERSION.to_string(),
1562 target_binary_id: binary_identity(&daemon_binary()).unwrap_or_default(),
1563 target_daemon_revision: super::protocol::DAEMON_REVISION,
1564 };
1565 let thread = thread::Builder::new()
1566 .name("wsx-runtime-events".into())
1567 .spawn(move || {
1568 let mut revision = 0;
1569 let mut connected = false;
1570 let mut recovery_availability = None;
1571 while !stop.load(Ordering::Acquire) {
1572 match client.call(&Request::Poll {
1573 after_revision: revision,
1574 timeout_ms: 1_000,
1575 tui: Some(tui.clone()),
1576 }) {
1577 Ok(Response::Events {
1578 revision: next,
1579 events,
1580 }) => {
1581 if !connected {
1582 connected = true;
1583 let _ = sender
1584 .send(EventSignal::Connected(recovery_availability.take()));
1585 }
1586 revision = next;
1587 if !events.is_empty() {
1588 let _ = sender.send(EventSignal::Dirty);
1589 }
1590 }
1591 Ok(Response::Error(error)) => {
1592 connected = false;
1593 revision = 0;
1594 let _ = sender.send(EventSignal::Disconnected(format!(
1595 "{}: {}",
1596 error.code, error.message
1597 )));
1598 thread::sleep(Duration::from_millis(250));
1599 }
1600 Ok(_) => {
1601 connected = false;
1602 revision = 0;
1603 let _ = sender.send(EventSignal::Disconnected(
1604 "unexpected daemon poll response".into(),
1605 ));
1606 thread::sleep(Duration::from_millis(250));
1607 }
1608 Err(error) => {
1609 connected = false;
1610 revision = 0;
1611 let _ = sender.send(EventSignal::Disconnected(error.to_string()));
1612 if daemon_is_stopped_error(&error)
1613 && background_recovery_allowed(client.socket())
1614 {
1615 match ensure_background_available_with(&client) {
1616 Ok(availability) => {
1617 recovery_availability = Some(availability);
1618 continue;
1619 }
1620 Err(recovery_error) => {
1621 let _ = sender.send(EventSignal::Disconnected(
1622 recovery_error.to_string(),
1623 ));
1624 }
1625 }
1626 }
1627 thread::sleep(Duration::from_millis(250));
1628 }
1629 }
1630 }
1631 })?;
1632 ACTIVE_TUI_MONITORS.fetch_add(1, Ordering::AcqRel);
1633 Ok((
1634 Self {
1635 stopping,
1636 thread: Some(thread),
1637 },
1638 receiver,
1639 ))
1640 }
1641}
1642impl Drop for EventMonitor {
1643 fn drop(&mut self) {
1644 self.stopping.store(true, Ordering::Release);
1645 if let Some(thread) = self.thread.take() {
1646 let _ = thread.join();
1647 }
1648 ACTIVE_TUI_MONITORS.fetch_sub(1, Ordering::AcqRel);
1649 }
1650}
1651
1652#[cfg(test)]
1653mod tests {
1654 use super::*;
1655 use crate::runtime::{
1656 Cell, Cursor, PaneId, TerminalFrame, TerminalId, TerminalSelectionRange, TerminalUpdate,
1657 };
1658 use std::{
1659 os::unix::{fs::PermissionsExt, net::UnixListener},
1660 sync::atomic::AtomicUsize,
1661 };
1662
1663 fn test_listener(_name: &str) -> (PathBuf, UnixListener) {
1664 let dir = std::env::current_dir().unwrap().join(".work/s");
1665 std::fs::create_dir_all(&dir).unwrap();
1666 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
1667 let path = dir.join(format!("{:x}-{:x}", std::process::id(), new_client_id()));
1668 let _ = std::fs::remove_file(&path);
1669 let listener = UnixListener::bind(&path).unwrap();
1670 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1671 (path, listener)
1672 }
1673
1674 fn send_response(stream: &mut UnixStream, response: &Response) {
1675 stream.write_all(&encode_line(response).unwrap()).unwrap();
1676 }
1677
1678 fn current_capabilities() -> super::super::domain::Capabilities {
1679 super::super::domain::Capabilities {
1680 resume_shell_fallback: true,
1681 foreground_jobs: true,
1682 lifecycle_coordination: true,
1683 ..Default::default()
1684 }
1685 }
1686
1687 #[test]
1688 fn compatible_daemon_is_reused_without_shutdown() {
1689 let (path, listener) = test_listener("compatible-reuse");
1690 let server_path = path.clone();
1691 let server = thread::spawn(move || {
1692 let (mut stream, _) = listener.accept().unwrap();
1693 assert!(matches!(
1694 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1695 Request::Hello {
1696 protocol: PROTOCOL_VERSION
1697 }
1698 ));
1699 send_response(
1700 &mut stream,
1701 &Response::Hello {
1702 protocol: PROTOCOL_VERSION,
1703 epoch: 1,
1704 capabilities: current_capabilities(),
1705 },
1706 );
1707 assert_eq!(
1708 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1709 Request::Snapshot
1710 );
1711 send_response(
1712 &mut stream,
1713 &Response::Snapshot(super::super::domain::Snapshot {
1714 protocol: PROTOCOL_VERSION,
1715 epoch: 1,
1716 revision: 1,
1717 projects: Vec::new(),
1718 worktrees: Vec::new(),
1719 sessions: Vec::new(),
1720 panes: Vec::new(),
1721 listening_ports: Vec::new(),
1722 pane_activity: Vec::new(),
1723 plugin_sidecars: Vec::new(),
1724 capabilities: current_capabilities(),
1725 }),
1726 );
1727 drop(listener);
1728 std::fs::remove_file(server_path).unwrap();
1729 });
1730
1731 assert!(!daemon_needs_start(probe_existing_daemon(&Client::new(path)).unwrap()).unwrap());
1732 assert!(daemon_needs_start(ExistingDaemon::Missing).unwrap());
1733 server.join().unwrap();
1734 }
1735
1736 #[test]
1737 fn rejected_cross_version_handoff_retries_with_the_legacy_daemon_prefix() {
1738 let executable = std::env::current_exe().unwrap();
1739 let status = super::super::domain::DaemonLifecycle {
1740 protocol: PROTOCOL_VERSION,
1741 epoch: 7,
1742 binary_id: "0.25.0:1:2:3:4".into(),
1743 version: "0.25.0".into(),
1744 daemon_revision: 5,
1745 started_unix_ms: 1,
1746 phase: super::super::domain::DaemonPhase::Ready,
1747 live_runtimes: 1,
1748 active_clients: 1,
1749 active_tuis: 0,
1750 recovered_from_backup: false,
1751 replacement_target: None,
1752 replacement_target_version: String::new(),
1753 replacement_blockers: vec![],
1754 };
1755 let response = Response::Error(super::super::protocol::ApiError::new(
1756 "invalid_handoff_target",
1757 "unsafe wsxd handoff executable",
1758 ));
1759
1760 let request = legacy_handoff_retry_request(true, &status, "0.25.0", &response, &executable)
1761 .unwrap()
1762 .expect("affected live-handoff daemon must receive one compatibility retry");
1763 let Request::PrepareHandoff {
1764 target_binary_id,
1765 target_version,
1766 target_daemon_revision,
1767 ..
1768 } = request
1769 else {
1770 panic!("expected handoff retry");
1771 };
1772 assert_eq!(target_version, "0.25.0");
1773 assert_eq!(
1774 super::super::protocol::binary_identity_version(&target_binary_id),
1775 Some("0.25.0")
1776 );
1777 assert_eq!(
1778 target_daemon_revision,
1779 super::super::protocol::DAEMON_REVISION
1780 );
1781
1782 assert!(
1783 legacy_handoff_retry_request(false, &status, "0.25.0", &response, &executable,)
1784 .unwrap()
1785 .is_none()
1786 );
1787 assert!(legacy_handoff_retry_request(
1788 true,
1789 &status,
1790 super::super::protocol::WSX_VERSION,
1791 &response,
1792 &executable,
1793 )
1794 .unwrap()
1795 .is_none());
1796 }
1797
1798 #[test]
1799 fn matching_daemon_revision_reuses_a_different_wsx_build() {
1800 let (path, listener) = test_listener("matching-daemon-revision");
1801 let server_path = path.clone();
1802 let server = thread::spawn(move || {
1803 let (mut stream, _) = listener.accept().unwrap();
1804 assert!(matches!(
1805 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1806 Request::Hello { .. }
1807 ));
1808 let mut capabilities = current_capabilities();
1809 capabilities.daemon_revision_coordination = true;
1810 send_response(
1811 &mut stream,
1812 &Response::Hello {
1813 protocol: PROTOCOL_VERSION,
1814 epoch: 7,
1815 capabilities,
1816 },
1817 );
1818 assert_eq!(
1819 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1820 Request::LifecycleStatus
1821 );
1822 send_response(
1823 &mut stream,
1824 &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1825 protocol: PROTOCOL_VERSION,
1826 epoch: 7,
1827 binary_id: "0.22.1:1:2:3:10".into(),
1828 version: "0.22.1".into(),
1829 daemon_revision: super::super::protocol::DAEMON_REVISION,
1830 started_unix_ms: 1,
1831 phase: super::super::domain::DaemonPhase::Ready,
1832 live_runtimes: 1,
1833 active_clients: 1,
1834 active_tuis: 1,
1835 recovered_from_backup: false,
1836 replacement_target: None,
1837 replacement_target_version: String::new(),
1838 replacement_blockers: vec![],
1839 }),
1840 );
1841 drop(listener);
1842 std::fs::remove_file(server_path).unwrap();
1843 });
1844 let ready = ExistingDaemon::Ready {
1845 lifecycle_coordination: true,
1846 version_coordination: true,
1847 daemon_revision_coordination: true,
1848 live_handoff: false,
1849 };
1850
1851 assert_eq!(
1852 ready_without_transition(&Client::new(path), &ready, Some("0.22.2:4:5:6:20")).unwrap(),
1853 Some(Availability::Current)
1854 );
1855 server.join().unwrap();
1856 }
1857
1858 #[test]
1859 fn lifecycle_ready_and_deferred_are_typed_healthy_outcomes() {
1860 let (path, listener) = test_listener("lifecycle-outcomes");
1861 let server_path = path.clone();
1862 let server = thread::spawn(move || {
1863 for (phase, live_runtimes) in [
1864 (super::super::domain::DaemonPhase::Ready, 0),
1865 (super::super::domain::DaemonPhase::ReplacementPending, 2),
1866 ] {
1867 let (mut stream, _) = listener.accept().unwrap();
1868 assert!(matches!(
1869 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1870 Request::Hello { .. }
1871 ));
1872 send_response(
1873 &mut stream,
1874 &Response::Hello {
1875 protocol: PROTOCOL_VERSION,
1876 epoch: 7,
1877 capabilities: current_capabilities(),
1878 },
1879 );
1880 assert_eq!(
1881 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1882 Request::LifecycleStatus
1883 );
1884 send_response(
1885 &mut stream,
1886 &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1887 protocol: PROTOCOL_VERSION,
1888 epoch: 7,
1889 binary_id: "target".into(),
1890 version: "0.21.0".into(),
1891 daemon_revision: 0,
1892 started_unix_ms: 1,
1893 phase,
1894 live_runtimes,
1895 active_clients: 1,
1896 active_tuis: 1,
1897 recovered_from_backup: false,
1898 replacement_target: None,
1899 replacement_target_version: "0.21.0".into(),
1900 replacement_blockers: vec![],
1901 }),
1902 );
1903 }
1904 drop(listener);
1905 std::fs::remove_file(server_path).unwrap();
1906 });
1907 let client = Client::new(path);
1908 let ready = ExistingDaemon::Ready {
1909 lifecycle_coordination: true,
1910 version_coordination: true,
1911 daemon_revision_coordination: false,
1912 live_handoff: false,
1913 };
1914 assert_eq!(
1915 ready_without_transition(&client, &ready, Some("target")).unwrap(),
1916 Some(Availability::Current)
1917 );
1918 assert_eq!(
1919 ready_without_transition(&client, &ready, Some("target")).unwrap(),
1920 Some(Availability::ReplacementDeferred {
1921 daemon_version: "0.21.0".into(),
1922 target_version: "0.21.0".into(),
1923 live_runtimes: 2,
1924 blockers: vec![],
1925 })
1926 );
1927 server.join().unwrap();
1928 }
1929
1930 #[test]
1931 fn legacy_client_count_excludes_the_requester_and_own_tui_monitor() {
1932 assert!(!active_client_count_has_other_tui(1, 0));
1933 assert!(!active_client_count_has_other_tui(2, 1));
1934 assert!(active_client_count_has_other_tui(2, 0));
1935 assert!(active_client_count_has_other_tui(3, 1));
1936 }
1937
1938 #[test]
1939 fn newer_daemon_is_reused_without_a_downgrade_request() {
1940 let (path, listener) = test_listener("newer-daemon");
1941 let server_path = path.clone();
1942 let server = thread::spawn(move || {
1943 let (mut stream, _) = listener.accept().unwrap();
1944 assert!(matches!(
1945 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1946 Request::Hello { .. }
1947 ));
1948 send_response(
1949 &mut stream,
1950 &Response::Hello {
1951 protocol: PROTOCOL_VERSION,
1952 epoch: 7,
1953 capabilities: current_capabilities(),
1954 },
1955 );
1956 assert_eq!(
1957 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1958 Request::LifecycleStatus
1959 );
1960 send_response(
1961 &mut stream,
1962 &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1963 protocol: PROTOCOL_VERSION,
1964 epoch: 7,
1965 binary_id: "99.0.0:1:2:3:20".into(),
1966 version: "99.0.0".into(),
1967 daemon_revision: 0,
1968 started_unix_ms: 1,
1969 phase: super::super::domain::DaemonPhase::Ready,
1970 live_runtimes: 2,
1971 active_clients: 2,
1972 active_tuis: 1,
1973 recovered_from_backup: false,
1974 replacement_target: None,
1975 replacement_target_version: String::new(),
1976 replacement_blockers: vec![],
1977 }),
1978 );
1979 drop(listener);
1980 std::fs::remove_file(server_path).unwrap();
1981 });
1982 let ready = ExistingDaemon::Ready {
1983 lifecycle_coordination: true,
1984 version_coordination: true,
1985 daemon_revision_coordination: false,
1986 live_handoff: false,
1987 };
1988 assert_eq!(
1989 ready_without_transition(&Client::new(path), &ready, Some("0.22.0:1:2:3:10")).unwrap(),
1990 Some(Availability::NewerDaemon {
1991 daemon_version: "99.0.0".into()
1992 })
1993 );
1994 server.join().unwrap();
1995 }
1996
1997 #[test]
1998 fn legacy_pending_target_keeps_the_new_client_healthy() {
1999 let directory = std::env::current_dir().unwrap().join(".work").join(format!(
2000 "{:x}-{:x}",
2001 std::process::id(),
2002 new_client_id()
2003 ));
2004 std::fs::create_dir_all(&directory).unwrap();
2005 std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)).unwrap();
2006 let path = directory.join("wsx.sock");
2007 let listener = UnixListener::bind(&path).unwrap();
2008 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2009 let server_path = path.clone();
2010 let server = thread::spawn(move || {
2011 for step in 0..6 {
2012 let (mut stream, _) = listener.accept().unwrap();
2013 assert!(matches!(
2014 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2015 Request::Hello { .. }
2016 ));
2017 send_response(
2018 &mut stream,
2019 &Response::Hello {
2020 protocol: PROTOCOL_VERSION,
2021 epoch: 7,
2022 capabilities: current_capabilities(),
2023 },
2024 );
2025 let request = read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap();
2026 match step {
2027 0 | 2 => {
2028 assert_eq!(request, Request::Snapshot);
2029 send_response(
2030 &mut stream,
2031 &Response::Snapshot(super::super::domain::Snapshot {
2032 protocol: PROTOCOL_VERSION,
2033 epoch: 7,
2034 revision: 1,
2035 projects: vec![],
2036 worktrees: vec![],
2037 sessions: vec![],
2038 panes: vec![],
2039 listening_ports: vec![],
2040 pane_activity: vec![],
2041 plugin_sidecars: Vec::new(),
2042 capabilities: current_capabilities(),
2043 }),
2044 );
2045 }
2046 1 | 3 | 4 => {
2047 assert_eq!(request, Request::LifecycleStatus);
2048 send_response(
2049 &mut stream,
2050 &Response::Lifecycle(super::super::domain::DaemonLifecycle {
2051 protocol: PROTOCOL_VERSION,
2052 epoch: 7,
2053 binary_id: "0.20.0:1:2:3:10".into(),
2054 version: String::new(),
2055 daemon_revision: 0,
2056 started_unix_ms: 1,
2057 phase: super::super::domain::DaemonPhase::ReplacementPending,
2058 live_runtimes: 4,
2059 active_clients: 1,
2060 active_tuis: 0,
2061 recovered_from_backup: false,
2062 replacement_target: Some("0.20.0:1:2:3:20".into()),
2063 replacement_target_version: String::new(),
2064 replacement_blockers: vec![],
2065 }),
2066 );
2067 }
2068 5 => {
2069 assert!(matches!(request, Request::PrepareReplacement { .. }));
2070 send_response(
2071 &mut stream,
2072 &Response::Error(super::super::protocol::ApiError::new(
2073 "replacement_conflict",
2074 "another wsxd binary is already pending replacement",
2075 )),
2076 );
2077 }
2078 _ => unreachable!(),
2079 }
2080 }
2081 drop(listener);
2082 std::fs::remove_file(server_path).unwrap();
2083 });
2084
2085 let availability = ensure_available_with_binary(
2086 &Client::new(path.clone()),
2087 false,
2088 &std::env::current_exe().unwrap(),
2089 )
2090 .unwrap();
2091 assert_eq!(
2092 availability,
2093 Availability::ReplacementDeferred {
2094 daemon_version: "0.20.0".into(),
2095 target_version: super::super::protocol::WSX_VERSION.into(),
2096 live_runtimes: 4,
2097 blockers: vec![super::super::domain::ReplacementBlocker::PendingTarget],
2098 }
2099 );
2100 server.join().unwrap();
2101 let _ = std::fs::remove_file(path.with_extension("bootstrap.lock"));
2102 std::fs::remove_dir(directory).unwrap();
2103 }
2104
2105 #[test]
2106 fn bootstrap_lock_serializes_startup_owners() {
2107 let (path, listener) = test_listener("bootstrap-lock");
2108 drop(listener);
2109 let _ = std::fs::remove_file(&path);
2110 let first = acquire_bootstrap_lock(&path).unwrap();
2111 let contender_path = path.clone();
2112 let (acquired_tx, acquired_rx) = std::sync::mpsc::channel();
2113 let contender = thread::spawn(move || {
2114 let lock = acquire_bootstrap_lock(&contender_path).unwrap();
2115 acquired_tx.send(()).unwrap();
2116 drop(lock);
2117 });
2118 assert!(acquired_rx
2119 .recv_timeout(Duration::from_millis(100))
2120 .is_err());
2121 drop(first);
2122 acquired_rx.recv_timeout(Duration::from_secs(1)).unwrap();
2123 contender.join().unwrap();
2124 let _ = std::fs::remove_file(path.with_extension("bootstrap.lock"));
2125 }
2126
2127 #[test]
2128 fn successor_waits_for_the_daemon_singleton_lock_not_only_socket_removal() {
2129 let (seed, listener) = test_listener("singleton-release");
2130 drop(listener);
2131 let _ = std::fs::remove_file(&seed);
2132 let directory = seed.with_extension("state");
2133 std::fs::create_dir(&directory).unwrap();
2134 std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)).unwrap();
2135 let path = directory.join("wsx.sock");
2136 let lock_path = directory.join("state.lock");
2137 let owner = std::fs::OpenOptions::new()
2138 .create(true)
2139 .truncate(false)
2140 .read(true)
2141 .write(true)
2142 .mode(0o600)
2143 .open(&lock_path)
2144 .unwrap();
2145 assert_eq!(
2146 unsafe { libc::flock(owner.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
2147 0
2148 );
2149 let contender_path = path.clone();
2150 let (released_tx, released_rx) = std::sync::mpsc::channel();
2151 let contender = thread::spawn(move || {
2152 wait_for_singleton_release(&contender_path).unwrap();
2153 released_tx.send(()).unwrap();
2154 });
2155 assert!(released_rx
2156 .recv_timeout(Duration::from_millis(100))
2157 .is_err());
2158 drop(owner);
2159 released_rx.recv_timeout(Duration::from_secs(1)).unwrap();
2160 contender.join().unwrap();
2161 let _ = std::fs::remove_file(lock_path);
2162 let _ = std::fs::remove_dir(directory);
2163 }
2164
2165 #[test]
2166 fn shared_start_budget_blocks_a_loop_and_explicit_recovery_resets_it() {
2167 let (path, listener) = test_listener("start-budget");
2168 drop(listener);
2169 let _ = std::fs::remove_file(&path);
2170 let mut lock = acquire_bootstrap_lock(&path).unwrap();
2171 for _ in 0..MAX_START_ATTEMPTS {
2172 record_start_attempt(&mut lock.file, false).unwrap();
2173 }
2174 assert!(record_start_attempt(&mut lock.file, false)
2175 .unwrap_err()
2176 .to_string()
2177 .contains("crash_loop"));
2178 record_start_attempt(&mut lock.file, true).unwrap();
2179 drop(lock);
2180 let _ = std::fs::remove_file(path.with_extension("bootstrap.lock"));
2181 }
2182
2183 #[test]
2184 fn supported_legacy_daemon_remains_usable_while_replacement_is_deferred() {
2185 for legacy_protocol in [LEGACY_BASELINE_PROTOCOL, 14] {
2186 let (path, listener) = test_listener("legacy-protocol-bridge");
2187 let server_path = path.clone();
2188 let server = thread::spawn(move || {
2189 for step in 0..5 {
2190 let (mut stream, _) = listener.accept().unwrap();
2191 let hello = read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap();
2192 let expected_protocol = if step == 4 {
2193 legacy_protocol
2194 } else {
2195 PROTOCOL_VERSION
2196 };
2197 assert_eq!(
2198 hello,
2199 Request::Hello {
2200 protocol: expected_protocol
2201 }
2202 );
2203 let hello = format!(
2204 "{{\"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"
2205 );
2206 stream.write_all(hello.as_bytes()).unwrap();
2207 if matches!(step, 0 | 1) {
2208 continue;
2209 }
2210 let request =
2211 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap();
2212 match step {
2213 2 => {
2214 assert_eq!(request, Request::LifecycleStatus);
2215 send_response(
2216 &mut stream,
2217 &Response::Lifecycle(super::super::domain::DaemonLifecycle {
2218 protocol: legacy_protocol,
2219 epoch: 7,
2220 binary_id: "0.23.0:1:2:3:1".into(),
2221 version: "0.23.0".into(),
2222 daemon_revision: 4,
2223 started_unix_ms: 1,
2224 phase: super::super::domain::DaemonPhase::Ready,
2225 live_runtimes: 1,
2226 active_clients: 1,
2227 active_tuis: 0,
2228 recovered_from_backup: false,
2229 replacement_target: None,
2230 replacement_target_version: String::new(),
2231 replacement_blockers: vec![],
2232 }),
2233 );
2234 }
2235 3 => {
2236 assert!(matches!(request, Request::PrepareReplacement { .. }));
2237 send_response(
2238 &mut stream,
2239 &Response::Replacement {
2240 disposition:
2241 super::super::domain::ReplacementDisposition::Deferred,
2242 live_runtimes: 1,
2243 daemon_version: "0.23.0".into(),
2244 target_version: super::super::protocol::WSX_VERSION.into(),
2245 blockers: vec![
2246 super::super::domain::ReplacementBlocker::WorkingAgent,
2247 ],
2248 use_current_daemon: false,
2249 },
2250 );
2251 }
2252 4 => {
2253 assert_eq!(request, Request::Snapshot);
2254 let snapshot = format!(
2255 "{{\"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"
2256 );
2257 stream.write_all(snapshot.as_bytes()).unwrap();
2258 }
2259 _ => unreachable!(),
2260 }
2261 }
2262 drop(listener);
2263 std::fs::remove_file(server_path).unwrap();
2264 });
2265
2266 let client = Client::new(path);
2267 let availability =
2268 ensure_available_with_binary(&client, false, &std::env::current_exe().unwrap())
2269 .unwrap();
2270 assert!(matches!(
2271 availability,
2272 Availability::ReplacementDeferred {
2273 blockers,
2274 ..
2275 } if blockers == [super::super::domain::ReplacementBlocker::WorkingAgent]
2276 ));
2277 let Response::Snapshot(snapshot) = client.call(&Request::Snapshot).unwrap() else {
2278 panic!("expected bridged snapshot");
2279 };
2280 assert_eq!(snapshot.protocol, legacy_protocol);
2281 assert_eq!(snapshot.revision, 9);
2282 assert_eq!(snapshot.projects[0].name, "legacy");
2283 assert_eq!(snapshot.projects[0].last_agent_active_unix_ms, Some(1));
2284 assert_eq!(snapshot.worktrees[0].branch, "main");
2285 set_active_protocol(client.socket(), PROTOCOL_VERSION);
2286 server.join().unwrap();
2287 }
2288 }
2289
2290 #[test]
2291 fn custom_client_never_spawns_a_default_daemon() {
2292 let (path, listener) = test_listener("custom-no-recovery");
2293 drop(listener);
2294 std::fs::remove_file(&path).unwrap();
2295 assert_eq!(
2296 ensure_background_available_with(&Client::new(path.clone()))
2297 .unwrap_err()
2298 .kind(),
2299 io::ErrorKind::Unsupported
2300 );
2301 assert!(!path.exists());
2302 }
2303
2304 #[test]
2305 fn intentional_stop_marker_disables_background_recovery() {
2306 let (path, listener) = test_listener("intentional-marker");
2307 drop(listener);
2308 std::fs::remove_file(&path).unwrap();
2309 let marker = path.with_extension("lifecycle");
2310 std::fs::write(&marker, "intentional\n").unwrap();
2311 std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o600)).unwrap();
2312 assert!(!background_recovery_allowed(&path));
2313 assert_eq!(
2314 ensure_background_available_with(&Client {
2315 socket: path.clone(),
2316 automatic_start: true,
2317 })
2318 .unwrap_err()
2319 .kind(),
2320 io::ErrorKind::ConnectionAborted
2321 );
2322 assert_eq!(
2323 consume_planned_marker(&path, Some("target"), 1).unwrap(),
2324 PlannedStart::Intentional
2325 );
2326 assert_eq!(lifecycle_marker_reason(&path).as_deref(), Some("starting"));
2327 assert_eq!(
2328 consume_planned_marker(&path, Some("target"), 1).unwrap(),
2329 PlannedStart::None
2330 );
2331 std::fs::write(&marker, "login_ended\n").unwrap();
2332 assert!(!background_recovery_allowed(&path));
2333 std::fs::write(&marker, "replacement:other\n").unwrap();
2334 assert!(consume_planned_marker(&path, Some("target"), 1).is_err());
2335 std::fs::write(&marker, "replacement:other|1\n").unwrap();
2336 assert_eq!(
2337 consume_planned_marker(&path, Some("target"), 1).unwrap(),
2338 PlannedStart::Replacement
2339 );
2340 std::fs::write(&marker, "unexpected\n").unwrap();
2341 assert!(background_recovery_allowed(&path));
2342 let _ = std::fs::remove_file(marker);
2343 let _ = std::fs::remove_file(path);
2344 }
2345
2346 #[test]
2347 fn detached_spawn_is_session_leader_and_survives_hangup() {
2348 let mut command = Command::new("sh");
2349 command.arg("-c").arg("kill -HUP $$; exec sleep 5");
2350
2351 let mut child = spawn_detached(&mut command).unwrap();
2352 thread::sleep(Duration::from_millis(20));
2353 let pid = child.id() as libc::pid_t;
2354 let session_id = unsafe { libc::getsid(pid) };
2355 let status = child.try_wait().unwrap();
2356 if status.is_none() {
2357 child.kill().unwrap();
2358 child.wait().unwrap();
2359 }
2360
2361 assert_eq!(session_id, pid, "detached daemon must own its session");
2362 assert!(status.is_none(), "detached daemon exited after SIGHUP");
2363 }
2364
2365 #[cfg(target_os = "macos")]
2366 #[test]
2367 fn local_socket_peer_matches_the_current_user() {
2368 let (client, server) = UnixStream::pair().unwrap();
2369 validate_peer_owner(&server).unwrap();
2370 validate_peer_owner(&client).unwrap();
2371 }
2372
2373 #[test]
2374 fn same_protocol_daemon_without_required_capabilities_is_not_stopped() {
2375 let (path, listener) = test_listener("missing-capabilities");
2376 let server_path = path.clone();
2377 let server = thread::spawn(move || {
2378 let (mut stream, _) = listener.accept().unwrap();
2379 assert_eq!(
2380 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2381 Request::Hello {
2382 protocol: PROTOCOL_VERSION
2383 }
2384 );
2385 send_response(
2386 &mut stream,
2387 &Response::Hello {
2388 protocol: PROTOCOL_VERSION,
2389 epoch: 1,
2390 capabilities: super::super::domain::Capabilities::default(),
2391 },
2392 );
2393 assert_eq!(
2394 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES)
2395 .unwrap_err()
2396 .kind(),
2397 io::ErrorKind::UnexpectedEof
2398 );
2399 drop(listener);
2400 std::fs::remove_file(server_path).unwrap();
2401 });
2402
2403 let error =
2404 daemon_needs_start(probe_existing_daemon(&Client::new(path)).unwrap()).unwrap_err();
2405 assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
2406 assert!(error.to_string().contains("missing required capabilities"));
2407 assert!(error.to_string().contains("refusing automatic shutdown"));
2408 assert!(error.to_string().contains("wsx daemon stop"));
2409 server.join().unwrap();
2410 }
2411
2412 #[test]
2413 fn protocol_mismatch_does_not_send_shutdown() {
2414 let (path, listener) = test_listener("protocol-skew");
2415 let server_path = path.clone();
2416 let server = thread::spawn(move || {
2417 let (mut stream, _) = listener.accept().unwrap();
2418 assert_eq!(
2419 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2420 Request::Hello {
2421 protocol: PROTOCOL_VERSION
2422 }
2423 );
2424 send_response(
2425 &mut stream,
2426 &Response::Error(super::super::protocol::ApiError::new(
2427 "protocol_mismatch",
2428 format!("client {PROTOCOL_VERSION}, daemon 8"),
2429 )),
2430 );
2431 assert_eq!(
2432 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES)
2433 .unwrap_err()
2434 .kind(),
2435 io::ErrorKind::UnexpectedEof
2436 );
2437 drop(listener);
2438 std::fs::remove_file(server_path).unwrap();
2439 });
2440
2441 let error =
2442 daemon_needs_start(probe_existing_daemon(&Client::new(path)).unwrap()).unwrap_err();
2443 assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
2444 assert!(error.to_string().contains("protocol mismatch"));
2445 assert!(error.to_string().contains("daemon protocol unknown"));
2446 assert!(error.to_string().contains("refusing automatic shutdown"));
2447 assert!(error.to_string().contains("wsx daemon stop"));
2448 server.join().unwrap();
2449 }
2450
2451 #[test]
2452 fn graceful_shutdown_waits_for_socket_cleanup() {
2453 let (path, listener) = test_listener("graceful-shutdown");
2454 let server_path = path.clone();
2455 let server = thread::spawn(move || {
2456 let (mut stream, _) = listener.accept().unwrap();
2457 assert_eq!(
2458 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2459 Request::Hello {
2460 protocol: PROTOCOL_VERSION
2461 }
2462 );
2463 send_response(
2464 &mut stream,
2465 &Response::Hello {
2466 protocol: PROTOCOL_VERSION,
2467 epoch: 1,
2468 capabilities: super::super::domain::Capabilities::default(),
2469 },
2470 );
2471 assert_eq!(
2472 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2473 Request::Shutdown
2474 );
2475 send_response(&mut stream, &Response::Ack { revision: 1 });
2476 drop(stream);
2477 drop(listener);
2478 std::fs::remove_file(server_path).unwrap();
2479 });
2480
2481 Client::new(path).shutdown().unwrap();
2482 server.join().unwrap();
2483 }
2484
2485 #[test]
2486 fn graceful_shutdown_surfaces_daemon_rejection() {
2487 let (path, listener) = test_listener("shutdown-rejection");
2488 let server_path = path.clone();
2489 let server = thread::spawn(move || {
2490 let (mut stream, _) = listener.accept().unwrap();
2491 assert!(matches!(
2492 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2493 Request::Hello {
2494 protocol: PROTOCOL_VERSION
2495 }
2496 ));
2497 send_response(
2498 &mut stream,
2499 &Response::Hello {
2500 protocol: PROTOCOL_VERSION,
2501 epoch: 1,
2502 capabilities: super::super::domain::Capabilities::default(),
2503 },
2504 );
2505 assert_eq!(
2506 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2507 Request::Shutdown
2508 );
2509 send_response(
2510 &mut stream,
2511 &Response::Error(super::super::protocol::ApiError::new(
2512 "shutdown_blocked",
2513 "still busy",
2514 )),
2515 );
2516 drop(listener);
2517 std::fs::remove_file(server_path).unwrap();
2518 });
2519
2520 let error = Client::new(path).shutdown().unwrap_err();
2521 assert!(error.to_string().contains("shutdown_blocked: still busy"));
2522 server.join().unwrap();
2523 }
2524
2525 #[test]
2526 fn graceful_shutdown_accepts_an_already_stopped_daemon() {
2527 let path = std::env::current_dir()
2528 .unwrap()
2529 .join(".work/s/already-stopped.sock");
2530 let _ = std::fs::remove_file(&path);
2531 Client::new(path).shutdown().unwrap();
2532 }
2533
2534 #[test]
2535 fn advertised_incompatible_daemon_shuts_down_on_the_handshake_connection() {
2536 let (path, listener) = test_listener("advertised-upgrade");
2537 let server_path = path.clone();
2538 let server = thread::spawn(move || {
2539 let (mut stream, _) = listener.accept().unwrap();
2540 assert!(matches!(
2541 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2542 Request::Hello {
2543 protocol: PROTOCOL_VERSION
2544 }
2545 ));
2546 let legacy_hello = serde_json::json!({
2547 "type": "hello",
2548 "data": {
2549 "protocol": PROTOCOL_VERSION - 1,
2550 "epoch": 1,
2551 "capabilities": {
2552 "pane_splits": true,
2553 "plugins": true,
2554 "agent_reports": true,
2555 "process_restore": false
2556 }
2557 }
2558 });
2559 stream
2560 .write_all(&encode_line(&legacy_hello).unwrap())
2561 .unwrap();
2562 assert_eq!(
2563 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2564 Request::Shutdown
2565 );
2566 send_response(&mut stream, &Response::Ack { revision: 1 });
2567 drop(listener);
2568 std::fs::remove_file(server_path).unwrap();
2569 });
2570
2571 Client::new(path).shutdown().unwrap();
2572 server.join().unwrap();
2573 }
2574
2575 #[test]
2576 fn unadvertised_protocol_two_daemon_shuts_down_on_the_handshake_connection() {
2577 let (path, listener) = test_listener("protocol-two-upgrade");
2578 let server_path = path.clone();
2579 let server = thread::spawn(move || {
2580 let (mut mismatch, _) = listener.accept().unwrap();
2581 assert_eq!(
2582 read_json_line::<Request>(&mut mismatch, MAX_RESPONSE_BYTES).unwrap(),
2583 Request::Hello {
2584 protocol: PROTOCOL_VERSION
2585 }
2586 );
2587 send_response(
2588 &mut mismatch,
2589 &Response::Error(super::super::protocol::ApiError::new(
2590 "protocol_mismatch",
2591 format!("client {PROTOCOL_VERSION}, daemon 2"),
2592 )),
2593 );
2594
2595 for protocol in (3..PROTOCOL_VERSION).rev() {
2596 let (mut probe, _) = listener.accept().unwrap();
2597 assert_eq!(
2598 read_json_line::<Request>(&mut probe, MAX_RESPONSE_BYTES).unwrap(),
2599 Request::Hello { protocol }
2600 );
2601 send_response(
2602 &mut probe,
2603 &Response::Error(super::super::protocol::ApiError::new(
2604 "protocol_mismatch",
2605 format!("client {protocol}, daemon 2"),
2606 )),
2607 );
2608 }
2609
2610 let (mut protocol_two, _) = listener.accept().unwrap();
2611 assert_eq!(
2612 read_json_line::<Request>(&mut protocol_two, MAX_RESPONSE_BYTES).unwrap(),
2613 Request::Hello { protocol: 2 }
2614 );
2615 send_response(
2616 &mut protocol_two,
2617 &Response::Hello {
2618 protocol: 2,
2619 epoch: 1,
2620 capabilities: super::super::domain::Capabilities::default(),
2621 },
2622 );
2623 assert_eq!(
2624 read_json_line::<Request>(&mut protocol_two, MAX_RESPONSE_BYTES).unwrap(),
2625 Request::Shutdown
2626 );
2627 send_response(&mut protocol_two, &Response::Ack { revision: 1 });
2628 drop(listener);
2629 std::fs::remove_file(server_path).unwrap();
2630 });
2631
2632 Client::new(path).shutdown().unwrap();
2633 server.join().unwrap();
2634 }
2635
2636 #[test]
2637 fn legacy_daemon_upgrade_uses_separate_request_connection() {
2638 let (path, listener) = test_listener("legacy-upgrade");
2639 let server_path = path.clone();
2640 let server = thread::spawn(move || {
2641 let (mut mismatch, _) = listener.accept().unwrap();
2642 assert_eq!(
2643 read_json_line::<Request>(&mut mismatch, MAX_RESPONSE_BYTES).unwrap(),
2644 Request::Hello {
2645 protocol: PROTOCOL_VERSION
2646 }
2647 );
2648 send_response(
2649 &mut mismatch,
2650 &Response::Error(super::super::protocol::ApiError::new(
2651 "protocol_mismatch",
2652 format!("client {PROTOCOL_VERSION}, daemon 1"),
2653 )),
2654 );
2655
2656 for protocol in (2..PROTOCOL_VERSION).rev() {
2657 let (mut probe, _) = listener.accept().unwrap();
2658 assert_eq!(
2659 read_json_line::<Request>(&mut probe, MAX_RESPONSE_BYTES).unwrap(),
2660 Request::Hello { protocol }
2661 );
2662 send_response(
2663 &mut probe,
2664 &Response::Error(super::super::protocol::ApiError::new(
2665 "protocol_mismatch",
2666 format!("client {protocol}, daemon 1"),
2667 )),
2668 );
2669 }
2670
2671 let (mut hello, _) = listener.accept().unwrap();
2672 assert_eq!(
2673 read_json_line::<Request>(&mut hello, MAX_RESPONSE_BYTES).unwrap(),
2674 Request::Hello { protocol: 1 }
2675 );
2676 send_response(
2677 &mut hello,
2678 &Response::Hello {
2679 protocol: 1,
2680 epoch: 1,
2681 capabilities: super::super::domain::Capabilities::default(),
2682 },
2683 );
2684 drop(hello);
2685
2686 let (mut shutdown, _) = listener.accept().unwrap();
2687 assert_eq!(
2688 read_json_line::<Request>(&mut shutdown, MAX_RESPONSE_BYTES).unwrap(),
2689 Request::Shutdown
2690 );
2691 send_response(&mut shutdown, &Response::Ack { revision: 1 });
2692 drop(listener);
2693 std::fs::remove_file(server_path).unwrap();
2694 });
2695
2696 Client::new(path).shutdown().unwrap();
2697 server.join().unwrap();
2698 }
2699
2700 #[test]
2701 fn large_json_line_uses_bounded_buffered_reads() {
2702 struct CountingReader {
2703 inner: io::Cursor<Vec<u8>>,
2704 reads: Arc<AtomicUsize>,
2705 }
2706 impl Read for CountingReader {
2707 fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
2708 self.reads.fetch_add(1, Ordering::Relaxed);
2709 self.inner.read(buffer)
2710 }
2711 }
2712
2713 let expected = "x".repeat(1024 * 1024);
2714 let mut encoded = serde_json::to_vec(&expected).unwrap();
2715 encoded.push(b'\n');
2716 let reads = Arc::new(AtomicUsize::new(0));
2717 let source = CountingReader {
2718 inner: io::Cursor::new(encoded),
2719 reads: Arc::clone(&reads),
2720 };
2721 let mut reader = BufReader::with_capacity(64 * 1024, source);
2722 let actual: String = read_buffered_json_line(&mut reader, 2 * 1024 * 1024).unwrap();
2723
2724 assert_eq!(actual, expected);
2725 assert!(reads.load(Ordering::Relaxed) < 32);
2726 }
2727
2728 #[test]
2729 fn terminal_stream_preserves_selection_updates_and_clipboard_order_after_subscribe_ack() {
2730 let (path, listener) = test_listener("buffered-subscribe");
2731 let server_path = path.clone();
2732 let server = thread::spawn(move || {
2733 let (mut stream, _) = listener.accept().unwrap();
2734 assert!(matches!(
2735 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2736 Request::Hello {
2737 protocol: PROTOCOL_VERSION
2738 }
2739 ));
2740 let mut bytes = encode_line(&Response::Hello {
2741 protocol: PROTOCOL_VERSION,
2742 epoch: 1,
2743 capabilities: super::super::domain::Capabilities::default(),
2744 })
2745 .unwrap();
2746 stream.write_all(&bytes).unwrap();
2747 assert!(matches!(
2748 read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2749 Request::TerminalSubscribe {
2750 rows: 24,
2751 cols: 80,
2752 ..
2753 }
2754 ));
2755 bytes = encode_line(&Response::Ack { revision: 1 }).unwrap();
2756 bytes.extend(
2757 encode_line(&TerminalServerMessage::Update(TerminalUpdate::Full(
2758 TerminalFrame {
2759 pane_id: PaneId(1),
2760 terminal_id: TerminalId(2),
2761 revision: 1,
2762 cols: 1,
2763 rows: 1,
2764 cells: vec![Cell::default()],
2765 cursor: Cursor {
2766 x: 0,
2767 y: 0,
2768 visible: false,
2769 blinking: false,
2770 shape: 0,
2771 },
2772 selection: vec![TerminalSelectionRange {
2773 row: 0,
2774 start_col: 0,
2775 end_col: 0,
2776 }],
2777 },
2778 )))
2779 .unwrap(),
2780 );
2781 bytes.extend(
2782 encode_line(&TerminalServerMessage::Update(TerminalUpdate::Patch {
2783 pane_id: PaneId(1),
2784 terminal_id: TerminalId(2),
2785 base_revision: 1,
2786 revision: 2,
2787 cols: 1,
2788 rows: 1,
2789 changed_rows: Vec::new(),
2790 cursor: Cursor {
2791 x: 0,
2792 y: 0,
2793 visible: false,
2794 blinking: false,
2795 shape: 0,
2796 },
2797 selection: Vec::new(),
2798 }))
2799 .unwrap(),
2800 );
2801 bytes.extend(
2802 encode_line(&TerminalServerMessage::ClipboardWrite(b"copied".to_vec())).unwrap(),
2803 );
2804 bytes.extend(encode_line(&TerminalServerMessage::Exited).unwrap());
2805 stream.write_all(&bytes).unwrap();
2806 thread::sleep(Duration::from_millis(50));
2807 drop(listener);
2808 std::fs::remove_file(server_path).unwrap();
2809 });
2810
2811 let stream = TerminalStream::connect(
2812 &Client::new(path),
2813 super::super::domain::PaneId(1),
2814 7,
2815 false,
2816 24,
2817 80,
2818 )
2819 .unwrap();
2820 let deadline = Instant::now() + Duration::from_secs(1);
2821 let mut selections = Vec::new();
2822 let mut clipboard = None;
2823 loop {
2824 match stream.try_recv() {
2825 Ok(TerminalServerMessage::Update(TerminalUpdate::Full(frame))) => {
2826 selections.push(frame.selection)
2827 }
2828 Ok(TerminalServerMessage::Update(TerminalUpdate::Patch { selection, .. })) => {
2829 selections.push(selection)
2830 }
2831 Ok(TerminalServerMessage::ClipboardWrite(text)) => clipboard = Some(text),
2832 Ok(TerminalServerMessage::Exited) => break,
2833 Ok(message) => panic!("unexpected terminal message: {message:?}"),
2834 Err(mpsc::TryRecvError::Empty) if Instant::now() < deadline => {
2835 thread::sleep(Duration::from_millis(10));
2836 }
2837 Err(error) => panic!("terminal update missing after ACK: {error}"),
2838 }
2839 }
2840 assert_eq!(
2841 selections,
2842 vec![
2843 vec![TerminalSelectionRange {
2844 row: 0,
2845 start_col: 0,
2846 end_col: 0,
2847 }],
2848 Vec::new(),
2849 ]
2850 );
2851 assert_eq!(clipboard.as_deref(), Some(b"copied".as_slice()));
2852 drop(stream);
2853 server.join().unwrap();
2854 }
2855
2856 #[test]
2857 fn terminal_reader_shutdown_interrupts_full_update_queue() {
2858 let (mut server, client) = UnixStream::pair().unwrap();
2859 let (updates, receiver) = mpsc::sync_channel(1);
2860 updates.send(TerminalServerMessage::Exited).unwrap();
2861 let stopping = Arc::new(AtomicBool::new(false));
2862 let reader_stopping = Arc::clone(&stopping);
2863 let (done, finished) = mpsc::channel();
2864 let reader = thread::spawn(move || {
2865 terminal_reader(BufReader::new(client), updates, &reader_stopping);
2866 done.send(()).unwrap();
2867 });
2868
2869 server
2870 .write_all(&encode_line(&TerminalServerMessage::Exited).unwrap())
2871 .unwrap();
2872 thread::sleep(Duration::from_millis(50));
2873 stopping.store(true, Ordering::Release);
2874
2875 assert!(finished.recv_timeout(Duration::from_secs(1)).is_ok());
2876 drop(receiver);
2877 reader.join().unwrap();
2878 }
2879}