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