Skip to main content

wsx_core/runtime/
client.rs

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