Skip to main content

wsx_core/runtime/
client.rs

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    /// Gracefully stop the current daemon and wait for its socket cleanup.
58    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    // ^ [[wsx Architecture]] Backpressure may pause this reader, but
382    // stream shutdown must always be able to interrupt a full update queue.
383    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// ^ [[MacOS Daemon Authentication and Recovery]]
428// macOS LOCAL_PEERTOKEN returns audit_token_t, defined as eight natural_t values.
429// See the platform SDK's mach/message.h and bsm/libbsm.h contracts.
430#[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    // ^ The kernel writes at most `length` bytes into this correctly sized C buffer.
449    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    // ^ libbsm accepts the complete kernel-issued token by value.
475    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    // ^ Both calls are side-effect-free identity reads for the connected processes.
482    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    // ^ libbsm accepts the complete kernel-issued token by value.
499    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        // ^ [[wsx Architecture]] Binary skew must not terminate daemon-owned live PTYs.
1030        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                    // ^ Protocol 1 closes after Hello, so Shutdown requires a fresh connection.
1184                    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}