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