Skip to main content

wsx_core/runtime/
client.rs

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