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                    conversations: Vec::new(),
1575                    listening_ports: Vec::new(),
1576                    pane_activity: Vec::new(),
1577                    plugin_sidecars: Vec::new(),
1578                    capabilities: current_capabilities(),
1579                }),
1580            );
1581            drop(listener);
1582            std::fs::remove_file(server_path).unwrap();
1583        });
1584
1585        assert!(!daemon_needs_start(probe_existing_daemon(&Client::new(path)).unwrap()).unwrap());
1586        assert!(daemon_needs_start(ExistingDaemon::Missing).unwrap());
1587        server.join().unwrap();
1588    }
1589
1590    #[test]
1591    fn matching_daemon_revision_reuses_a_different_wsx_build() {
1592        let (path, listener) = test_listener("matching-daemon-revision");
1593        let server_path = path.clone();
1594        let server = thread::spawn(move || {
1595            let (mut stream, _) = listener.accept().unwrap();
1596            assert!(matches!(
1597                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1598                Request::Hello { .. }
1599            ));
1600            let mut capabilities = current_capabilities();
1601            capabilities.daemon_revision_coordination = true;
1602            send_response(
1603                &mut stream,
1604                &Response::Hello {
1605                    protocol: PROTOCOL_VERSION,
1606                    epoch: 7,
1607                    capabilities,
1608                },
1609            );
1610            assert_eq!(
1611                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1612                Request::LifecycleStatus
1613            );
1614            send_response(
1615                &mut stream,
1616                &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1617                    protocol: PROTOCOL_VERSION,
1618                    epoch: 7,
1619                    binary_id: "0.22.1:1:2:3:10".into(),
1620                    version: "0.22.1".into(),
1621                    daemon_revision: super::super::protocol::DAEMON_REVISION,
1622                    started_unix_ms: 1,
1623                    phase: super::super::domain::DaemonPhase::Ready,
1624                    live_runtimes: 1,
1625                    active_clients: 1,
1626                    active_tuis: 1,
1627                    recovered_from_backup: false,
1628                    replacement_target: None,
1629                    replacement_target_version: String::new(),
1630                    replacement_blockers: vec![],
1631                }),
1632            );
1633            drop(listener);
1634            std::fs::remove_file(server_path).unwrap();
1635        });
1636        let ready = ExistingDaemon::Ready {
1637            lifecycle_coordination: true,
1638            version_coordination: true,
1639            daemon_revision_coordination: true,
1640        };
1641
1642        assert_eq!(
1643            ready_without_transition(&Client::new(path), &ready, Some("0.22.2:4:5:6:20")).unwrap(),
1644            Some(Availability::Current)
1645        );
1646        server.join().unwrap();
1647    }
1648
1649    #[test]
1650    fn lifecycle_ready_and_deferred_are_typed_healthy_outcomes() {
1651        let (path, listener) = test_listener("lifecycle-outcomes");
1652        let server_path = path.clone();
1653        let server = thread::spawn(move || {
1654            for (phase, live_runtimes) in [
1655                (super::super::domain::DaemonPhase::Ready, 0),
1656                (super::super::domain::DaemonPhase::ReplacementPending, 2),
1657            ] {
1658                let (mut stream, _) = listener.accept().unwrap();
1659                assert!(matches!(
1660                    read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1661                    Request::Hello { .. }
1662                ));
1663                send_response(
1664                    &mut stream,
1665                    &Response::Hello {
1666                        protocol: PROTOCOL_VERSION,
1667                        epoch: 7,
1668                        capabilities: current_capabilities(),
1669                    },
1670                );
1671                assert_eq!(
1672                    read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1673                    Request::LifecycleStatus
1674                );
1675                send_response(
1676                    &mut stream,
1677                    &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1678                        protocol: PROTOCOL_VERSION,
1679                        epoch: 7,
1680                        binary_id: "target".into(),
1681                        version: "0.21.0".into(),
1682                        daemon_revision: 0,
1683                        started_unix_ms: 1,
1684                        phase,
1685                        live_runtimes,
1686                        active_clients: 1,
1687                        active_tuis: 1,
1688                        recovered_from_backup: false,
1689                        replacement_target: None,
1690                        replacement_target_version: "0.21.0".into(),
1691                        replacement_blockers: vec![],
1692                    }),
1693                );
1694            }
1695            drop(listener);
1696            std::fs::remove_file(server_path).unwrap();
1697        });
1698        let client = Client::new(path);
1699        let ready = ExistingDaemon::Ready {
1700            lifecycle_coordination: true,
1701            version_coordination: true,
1702            daemon_revision_coordination: false,
1703        };
1704        assert_eq!(
1705            ready_without_transition(&client, &ready, Some("target")).unwrap(),
1706            Some(Availability::Current)
1707        );
1708        assert_eq!(
1709            ready_without_transition(&client, &ready, Some("target")).unwrap(),
1710            Some(Availability::ReplacementDeferred {
1711                daemon_version: "0.21.0".into(),
1712                target_version: "0.21.0".into(),
1713                live_runtimes: 2,
1714                blockers: vec![],
1715            })
1716        );
1717        server.join().unwrap();
1718    }
1719
1720    #[test]
1721    fn legacy_client_count_excludes_the_requester_and_own_tui_monitor() {
1722        assert!(!active_client_count_has_other_tui(1, 0));
1723        assert!(!active_client_count_has_other_tui(2, 1));
1724        assert!(active_client_count_has_other_tui(2, 0));
1725        assert!(active_client_count_has_other_tui(3, 1));
1726    }
1727
1728    #[test]
1729    fn newer_daemon_is_reused_without_a_downgrade_request() {
1730        let (path, listener) = test_listener("newer-daemon");
1731        let server_path = path.clone();
1732        let server = thread::spawn(move || {
1733            let (mut stream, _) = listener.accept().unwrap();
1734            assert!(matches!(
1735                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1736                Request::Hello { .. }
1737            ));
1738            send_response(
1739                &mut stream,
1740                &Response::Hello {
1741                    protocol: PROTOCOL_VERSION,
1742                    epoch: 7,
1743                    capabilities: current_capabilities(),
1744                },
1745            );
1746            assert_eq!(
1747                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1748                Request::LifecycleStatus
1749            );
1750            send_response(
1751                &mut stream,
1752                &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1753                    protocol: PROTOCOL_VERSION,
1754                    epoch: 7,
1755                    binary_id: "99.0.0:1:2:3:20".into(),
1756                    version: "99.0.0".into(),
1757                    daemon_revision: 0,
1758                    started_unix_ms: 1,
1759                    phase: super::super::domain::DaemonPhase::Ready,
1760                    live_runtimes: 2,
1761                    active_clients: 2,
1762                    active_tuis: 1,
1763                    recovered_from_backup: false,
1764                    replacement_target: None,
1765                    replacement_target_version: String::new(),
1766                    replacement_blockers: vec![],
1767                }),
1768            );
1769            drop(listener);
1770            std::fs::remove_file(server_path).unwrap();
1771        });
1772        let ready = ExistingDaemon::Ready {
1773            lifecycle_coordination: true,
1774            version_coordination: true,
1775            daemon_revision_coordination: false,
1776        };
1777        assert_eq!(
1778            ready_without_transition(&Client::new(path), &ready, Some("0.22.0:1:2:3:10")).unwrap(),
1779            Some(Availability::NewerDaemon {
1780                daemon_version: "99.0.0".into()
1781            })
1782        );
1783        server.join().unwrap();
1784    }
1785
1786    #[test]
1787    fn legacy_pending_target_keeps_the_new_client_healthy() {
1788        let directory = std::env::current_dir().unwrap().join(".work").join(format!(
1789            "{:x}-{:x}",
1790            std::process::id(),
1791            new_client_id()
1792        ));
1793        std::fs::create_dir_all(&directory).unwrap();
1794        std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)).unwrap();
1795        let path = directory.join("wsx.sock");
1796        let listener = UnixListener::bind(&path).unwrap();
1797        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1798        let server_path = path.clone();
1799        let server = thread::spawn(move || {
1800            for step in 0..6 {
1801                let (mut stream, _) = listener.accept().unwrap();
1802                assert!(matches!(
1803                    read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
1804                    Request::Hello { .. }
1805                ));
1806                send_response(
1807                    &mut stream,
1808                    &Response::Hello {
1809                        protocol: PROTOCOL_VERSION,
1810                        epoch: 7,
1811                        capabilities: current_capabilities(),
1812                    },
1813                );
1814                let request = read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap();
1815                match step {
1816                    0 | 2 => {
1817                        assert_eq!(request, Request::Snapshot);
1818                        send_response(
1819                            &mut stream,
1820                            &Response::Snapshot(super::super::domain::Snapshot {
1821                                protocol: PROTOCOL_VERSION,
1822                                epoch: 7,
1823                                revision: 1,
1824                                projects: vec![],
1825                                worktrees: vec![],
1826                                sessions: vec![],
1827                                panes: vec![],
1828                                conversations: vec![],
1829                                listening_ports: vec![],
1830                                pane_activity: vec![],
1831                                plugin_sidecars: Vec::new(),
1832                                capabilities: current_capabilities(),
1833                            }),
1834                        );
1835                    }
1836                    1 | 3 | 4 => {
1837                        assert_eq!(request, Request::LifecycleStatus);
1838                        send_response(
1839                            &mut stream,
1840                            &Response::Lifecycle(super::super::domain::DaemonLifecycle {
1841                                protocol: PROTOCOL_VERSION,
1842                                epoch: 7,
1843                                binary_id: "0.20.0:1:2:3:10".into(),
1844                                version: String::new(),
1845                                daemon_revision: 0,
1846                                started_unix_ms: 1,
1847                                phase: super::super::domain::DaemonPhase::ReplacementPending,
1848                                live_runtimes: 4,
1849                                active_clients: 1,
1850                                active_tuis: 0,
1851                                recovered_from_backup: false,
1852                                replacement_target: Some("0.20.0:1:2:3:20".into()),
1853                                replacement_target_version: String::new(),
1854                                replacement_blockers: vec![],
1855                            }),
1856                        );
1857                    }
1858                    5 => {
1859                        assert!(matches!(request, Request::PrepareReplacement { .. }));
1860                        send_response(
1861                            &mut stream,
1862                            &Response::Error(super::super::protocol::ApiError::new(
1863                                "replacement_conflict",
1864                                "another wsxd binary is already pending replacement",
1865                            )),
1866                        );
1867                    }
1868                    _ => unreachable!(),
1869                }
1870            }
1871            drop(listener);
1872            std::fs::remove_file(server_path).unwrap();
1873        });
1874
1875        let availability = ensure_available_with_binary(
1876            &Client::new(path.clone()),
1877            false,
1878            &std::env::current_exe().unwrap(),
1879        )
1880        .unwrap();
1881        assert_eq!(
1882            availability,
1883            Availability::ReplacementDeferred {
1884                daemon_version: "0.20.0".into(),
1885                target_version: super::super::protocol::WSX_VERSION.into(),
1886                live_runtimes: 4,
1887                blockers: vec![super::super::domain::ReplacementBlocker::PendingTarget],
1888            }
1889        );
1890        server.join().unwrap();
1891        let _ = std::fs::remove_file(path.with_extension("bootstrap.lock"));
1892        std::fs::remove_dir(directory).unwrap();
1893    }
1894
1895    #[test]
1896    fn bootstrap_lock_serializes_startup_owners() {
1897        let (path, listener) = test_listener("bootstrap-lock");
1898        drop(listener);
1899        let _ = std::fs::remove_file(&path);
1900        let first = acquire_bootstrap_lock(&path).unwrap();
1901        let contender_path = path.clone();
1902        let (acquired_tx, acquired_rx) = std::sync::mpsc::channel();
1903        let contender = thread::spawn(move || {
1904            let lock = acquire_bootstrap_lock(&contender_path).unwrap();
1905            acquired_tx.send(()).unwrap();
1906            drop(lock);
1907        });
1908        assert!(acquired_rx
1909            .recv_timeout(Duration::from_millis(100))
1910            .is_err());
1911        drop(first);
1912        acquired_rx.recv_timeout(Duration::from_secs(1)).unwrap();
1913        contender.join().unwrap();
1914        let _ = std::fs::remove_file(path.with_extension("bootstrap.lock"));
1915    }
1916
1917    #[test]
1918    fn successor_waits_for_the_daemon_singleton_lock_not_only_socket_removal() {
1919        let (seed, listener) = test_listener("singleton-release");
1920        drop(listener);
1921        let _ = std::fs::remove_file(&seed);
1922        let directory = seed.with_extension("state");
1923        std::fs::create_dir(&directory).unwrap();
1924        std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)).unwrap();
1925        let path = directory.join("wsx.sock");
1926        let lock_path = directory.join("state.lock");
1927        let owner = std::fs::OpenOptions::new()
1928            .create(true)
1929            .truncate(false)
1930            .read(true)
1931            .write(true)
1932            .mode(0o600)
1933            .open(&lock_path)
1934            .unwrap();
1935        assert_eq!(
1936            unsafe { libc::flock(owner.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
1937            0
1938        );
1939        let contender_path = path.clone();
1940        let (released_tx, released_rx) = std::sync::mpsc::channel();
1941        let contender = thread::spawn(move || {
1942            wait_for_singleton_release(&contender_path).unwrap();
1943            released_tx.send(()).unwrap();
1944        });
1945        assert!(released_rx
1946            .recv_timeout(Duration::from_millis(100))
1947            .is_err());
1948        drop(owner);
1949        released_rx.recv_timeout(Duration::from_secs(1)).unwrap();
1950        contender.join().unwrap();
1951        let _ = std::fs::remove_file(lock_path);
1952        let _ = std::fs::remove_dir(directory);
1953    }
1954
1955    #[test]
1956    fn shared_start_budget_blocks_a_loop_and_explicit_recovery_resets_it() {
1957        let (path, listener) = test_listener("start-budget");
1958        drop(listener);
1959        let _ = std::fs::remove_file(&path);
1960        let mut lock = acquire_bootstrap_lock(&path).unwrap();
1961        for _ in 0..MAX_START_ATTEMPTS {
1962            record_start_attempt(&mut lock.file, false).unwrap();
1963        }
1964        assert!(record_start_attempt(&mut lock.file, false)
1965            .unwrap_err()
1966            .to_string()
1967            .contains("crash_loop"));
1968        record_start_attempt(&mut lock.file, true).unwrap();
1969        drop(lock);
1970        let _ = std::fs::remove_file(path.with_extension("bootstrap.lock"));
1971    }
1972
1973    #[test]
1974    fn custom_client_never_spawns_a_default_daemon() {
1975        let (path, listener) = test_listener("custom-no-recovery");
1976        drop(listener);
1977        std::fs::remove_file(&path).unwrap();
1978        assert_eq!(
1979            ensure_background_available_with(&Client::new(path.clone()))
1980                .unwrap_err()
1981                .kind(),
1982            io::ErrorKind::Unsupported
1983        );
1984        assert!(!path.exists());
1985    }
1986
1987    #[test]
1988    fn intentional_stop_marker_disables_background_recovery() {
1989        let (path, listener) = test_listener("intentional-marker");
1990        drop(listener);
1991        std::fs::remove_file(&path).unwrap();
1992        let marker = path.with_extension("lifecycle");
1993        std::fs::write(&marker, "intentional\n").unwrap();
1994        std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o600)).unwrap();
1995        assert!(!background_recovery_allowed(&path));
1996        assert_eq!(
1997            ensure_background_available_with(&Client {
1998                socket: path.clone(),
1999                automatic_start: true,
2000            })
2001            .unwrap_err()
2002            .kind(),
2003            io::ErrorKind::ConnectionAborted
2004        );
2005        assert_eq!(
2006            consume_planned_marker(&path, Some("target"), 1).unwrap(),
2007            PlannedStart::Intentional
2008        );
2009        assert_eq!(lifecycle_marker_reason(&path).as_deref(), Some("starting"));
2010        assert_eq!(
2011            consume_planned_marker(&path, Some("target"), 1).unwrap(),
2012            PlannedStart::None
2013        );
2014        std::fs::write(&marker, "login_ended\n").unwrap();
2015        assert!(!background_recovery_allowed(&path));
2016        std::fs::write(&marker, "replacement:other\n").unwrap();
2017        assert!(consume_planned_marker(&path, Some("target"), 1).is_err());
2018        std::fs::write(&marker, "replacement:other|1\n").unwrap();
2019        assert_eq!(
2020            consume_planned_marker(&path, Some("target"), 1).unwrap(),
2021            PlannedStart::Replacement
2022        );
2023        std::fs::write(&marker, "unexpected\n").unwrap();
2024        assert!(background_recovery_allowed(&path));
2025        let _ = std::fs::remove_file(marker);
2026        let _ = std::fs::remove_file(path);
2027    }
2028
2029    #[test]
2030    fn detached_spawn_is_session_leader_and_survives_hangup() {
2031        let mut command = Command::new("sh");
2032        command.arg("-c").arg("kill -HUP $$; exec sleep 5");
2033
2034        let mut child = spawn_detached(&mut command).unwrap();
2035        thread::sleep(Duration::from_millis(20));
2036        let pid = child.id() as libc::pid_t;
2037        let session_id = unsafe { libc::getsid(pid) };
2038        let status = child.try_wait().unwrap();
2039        if status.is_none() {
2040            child.kill().unwrap();
2041            child.wait().unwrap();
2042        }
2043
2044        assert_eq!(session_id, pid, "detached daemon must own its session");
2045        assert!(status.is_none(), "detached daemon exited after SIGHUP");
2046    }
2047
2048    #[cfg(target_os = "macos")]
2049    #[test]
2050    fn local_socket_peer_matches_the_current_user() {
2051        let (client, server) = UnixStream::pair().unwrap();
2052        validate_peer_owner(&server).unwrap();
2053        validate_peer_owner(&client).unwrap();
2054    }
2055
2056    #[test]
2057    fn same_protocol_daemon_without_required_capabilities_is_not_stopped() {
2058        let (path, listener) = test_listener("missing-capabilities");
2059        let server_path = path.clone();
2060        let server = thread::spawn(move || {
2061            let (mut stream, _) = listener.accept().unwrap();
2062            assert_eq!(
2063                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2064                Request::Hello {
2065                    protocol: PROTOCOL_VERSION
2066                }
2067            );
2068            send_response(
2069                &mut stream,
2070                &Response::Hello {
2071                    protocol: PROTOCOL_VERSION,
2072                    epoch: 1,
2073                    capabilities: super::super::domain::Capabilities::default(),
2074                },
2075            );
2076            assert_eq!(
2077                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES)
2078                    .unwrap_err()
2079                    .kind(),
2080                io::ErrorKind::UnexpectedEof
2081            );
2082            drop(listener);
2083            std::fs::remove_file(server_path).unwrap();
2084        });
2085
2086        let error =
2087            daemon_needs_start(probe_existing_daemon(&Client::new(path)).unwrap()).unwrap_err();
2088        assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
2089        assert!(error.to_string().contains("missing required capabilities"));
2090        assert!(error.to_string().contains("refusing automatic shutdown"));
2091        assert!(error.to_string().contains("wsx daemon stop"));
2092        server.join().unwrap();
2093    }
2094
2095    #[test]
2096    fn protocol_mismatch_does_not_send_shutdown() {
2097        let (path, listener) = test_listener("protocol-skew");
2098        let server_path = path.clone();
2099        let server = thread::spawn(move || {
2100            let (mut stream, _) = listener.accept().unwrap();
2101            assert_eq!(
2102                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2103                Request::Hello {
2104                    protocol: PROTOCOL_VERSION
2105                }
2106            );
2107            send_response(
2108                &mut stream,
2109                &Response::Error(super::super::protocol::ApiError::new(
2110                    "protocol_mismatch",
2111                    format!("client {PROTOCOL_VERSION}, daemon 8"),
2112                )),
2113            );
2114            assert_eq!(
2115                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES)
2116                    .unwrap_err()
2117                    .kind(),
2118                io::ErrorKind::UnexpectedEof
2119            );
2120            drop(listener);
2121            std::fs::remove_file(server_path).unwrap();
2122        });
2123
2124        let error =
2125            daemon_needs_start(probe_existing_daemon(&Client::new(path)).unwrap()).unwrap_err();
2126        assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
2127        assert!(error.to_string().contains("protocol mismatch"));
2128        assert!(error.to_string().contains("daemon protocol unknown"));
2129        assert!(error.to_string().contains("refusing automatic shutdown"));
2130        assert!(error.to_string().contains("wsx daemon stop"));
2131        server.join().unwrap();
2132    }
2133
2134    #[test]
2135    fn graceful_shutdown_waits_for_socket_cleanup() {
2136        let (path, listener) = test_listener("graceful-shutdown");
2137        let server_path = path.clone();
2138        let server = thread::spawn(move || {
2139            let (mut stream, _) = listener.accept().unwrap();
2140            assert_eq!(
2141                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2142                Request::Hello {
2143                    protocol: PROTOCOL_VERSION
2144                }
2145            );
2146            send_response(
2147                &mut stream,
2148                &Response::Hello {
2149                    protocol: PROTOCOL_VERSION,
2150                    epoch: 1,
2151                    capabilities: super::super::domain::Capabilities::default(),
2152                },
2153            );
2154            assert_eq!(
2155                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2156                Request::Shutdown
2157            );
2158            send_response(&mut stream, &Response::Ack { revision: 1 });
2159            drop(stream);
2160            drop(listener);
2161            std::fs::remove_file(server_path).unwrap();
2162        });
2163
2164        Client::new(path).shutdown().unwrap();
2165        server.join().unwrap();
2166    }
2167
2168    #[test]
2169    fn graceful_shutdown_surfaces_daemon_rejection() {
2170        let (path, listener) = test_listener("shutdown-rejection");
2171        let server_path = path.clone();
2172        let server = thread::spawn(move || {
2173            let (mut stream, _) = listener.accept().unwrap();
2174            assert!(matches!(
2175                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2176                Request::Hello {
2177                    protocol: PROTOCOL_VERSION
2178                }
2179            ));
2180            send_response(
2181                &mut stream,
2182                &Response::Hello {
2183                    protocol: PROTOCOL_VERSION,
2184                    epoch: 1,
2185                    capabilities: super::super::domain::Capabilities::default(),
2186                },
2187            );
2188            assert_eq!(
2189                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2190                Request::Shutdown
2191            );
2192            send_response(
2193                &mut stream,
2194                &Response::Error(super::super::protocol::ApiError::new(
2195                    "shutdown_blocked",
2196                    "still busy",
2197                )),
2198            );
2199            drop(listener);
2200            std::fs::remove_file(server_path).unwrap();
2201        });
2202
2203        let error = Client::new(path).shutdown().unwrap_err();
2204        assert!(error.to_string().contains("shutdown_blocked: still busy"));
2205        server.join().unwrap();
2206    }
2207
2208    #[test]
2209    fn graceful_shutdown_accepts_an_already_stopped_daemon() {
2210        let path = std::env::current_dir()
2211            .unwrap()
2212            .join(".work/s/already-stopped.sock");
2213        let _ = std::fs::remove_file(&path);
2214        Client::new(path).shutdown().unwrap();
2215    }
2216
2217    #[test]
2218    fn advertised_incompatible_daemon_shuts_down_on_the_handshake_connection() {
2219        let (path, listener) = test_listener("advertised-upgrade");
2220        let server_path = path.clone();
2221        let server = thread::spawn(move || {
2222            let (mut stream, _) = listener.accept().unwrap();
2223            assert!(matches!(
2224                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2225                Request::Hello {
2226                    protocol: PROTOCOL_VERSION
2227                }
2228            ));
2229            let legacy_hello = serde_json::json!({
2230                "type": "hello",
2231                "data": {
2232                    "protocol": PROTOCOL_VERSION - 1,
2233                    "epoch": 1,
2234                    "capabilities": {
2235                        "pane_splits": true,
2236                        "plugins": true,
2237                        "agent_reports": true,
2238                        "process_restore": false
2239                    }
2240                }
2241            });
2242            stream
2243                .write_all(&encode_line(&legacy_hello).unwrap())
2244                .unwrap();
2245            assert_eq!(
2246                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2247                Request::Shutdown
2248            );
2249            send_response(&mut stream, &Response::Ack { revision: 1 });
2250            drop(listener);
2251            std::fs::remove_file(server_path).unwrap();
2252        });
2253
2254        Client::new(path).shutdown().unwrap();
2255        server.join().unwrap();
2256    }
2257
2258    #[test]
2259    fn unadvertised_protocol_two_daemon_shuts_down_on_the_handshake_connection() {
2260        let (path, listener) = test_listener("protocol-two-upgrade");
2261        let server_path = path.clone();
2262        let server = thread::spawn(move || {
2263            let (mut mismatch, _) = listener.accept().unwrap();
2264            assert_eq!(
2265                read_json_line::<Request>(&mut mismatch, MAX_RESPONSE_BYTES).unwrap(),
2266                Request::Hello {
2267                    protocol: PROTOCOL_VERSION
2268                }
2269            );
2270            send_response(
2271                &mut mismatch,
2272                &Response::Error(super::super::protocol::ApiError::new(
2273                    "protocol_mismatch",
2274                    format!("client {PROTOCOL_VERSION}, daemon 2"),
2275                )),
2276            );
2277
2278            for protocol in (3..PROTOCOL_VERSION).rev() {
2279                let (mut probe, _) = listener.accept().unwrap();
2280                assert_eq!(
2281                    read_json_line::<Request>(&mut probe, MAX_RESPONSE_BYTES).unwrap(),
2282                    Request::Hello { protocol }
2283                );
2284                send_response(
2285                    &mut probe,
2286                    &Response::Error(super::super::protocol::ApiError::new(
2287                        "protocol_mismatch",
2288                        format!("client {protocol}, daemon 2"),
2289                    )),
2290                );
2291            }
2292
2293            let (mut protocol_two, _) = listener.accept().unwrap();
2294            assert_eq!(
2295                read_json_line::<Request>(&mut protocol_two, MAX_RESPONSE_BYTES).unwrap(),
2296                Request::Hello { protocol: 2 }
2297            );
2298            send_response(
2299                &mut protocol_two,
2300                &Response::Hello {
2301                    protocol: 2,
2302                    epoch: 1,
2303                    capabilities: super::super::domain::Capabilities::default(),
2304                },
2305            );
2306            assert_eq!(
2307                read_json_line::<Request>(&mut protocol_two, MAX_RESPONSE_BYTES).unwrap(),
2308                Request::Shutdown
2309            );
2310            send_response(&mut protocol_two, &Response::Ack { revision: 1 });
2311            drop(listener);
2312            std::fs::remove_file(server_path).unwrap();
2313        });
2314
2315        Client::new(path).shutdown().unwrap();
2316        server.join().unwrap();
2317    }
2318
2319    #[test]
2320    fn legacy_daemon_upgrade_uses_separate_request_connection() {
2321        let (path, listener) = test_listener("legacy-upgrade");
2322        let server_path = path.clone();
2323        let server = thread::spawn(move || {
2324            let (mut mismatch, _) = listener.accept().unwrap();
2325            assert_eq!(
2326                read_json_line::<Request>(&mut mismatch, MAX_RESPONSE_BYTES).unwrap(),
2327                Request::Hello {
2328                    protocol: PROTOCOL_VERSION
2329                }
2330            );
2331            send_response(
2332                &mut mismatch,
2333                &Response::Error(super::super::protocol::ApiError::new(
2334                    "protocol_mismatch",
2335                    format!("client {PROTOCOL_VERSION}, daemon 1"),
2336                )),
2337            );
2338
2339            for protocol in (2..PROTOCOL_VERSION).rev() {
2340                let (mut probe, _) = listener.accept().unwrap();
2341                assert_eq!(
2342                    read_json_line::<Request>(&mut probe, MAX_RESPONSE_BYTES).unwrap(),
2343                    Request::Hello { protocol }
2344                );
2345                send_response(
2346                    &mut probe,
2347                    &Response::Error(super::super::protocol::ApiError::new(
2348                        "protocol_mismatch",
2349                        format!("client {protocol}, daemon 1"),
2350                    )),
2351                );
2352            }
2353
2354            let (mut hello, _) = listener.accept().unwrap();
2355            assert_eq!(
2356                read_json_line::<Request>(&mut hello, MAX_RESPONSE_BYTES).unwrap(),
2357                Request::Hello { protocol: 1 }
2358            );
2359            send_response(
2360                &mut hello,
2361                &Response::Hello {
2362                    protocol: 1,
2363                    epoch: 1,
2364                    capabilities: super::super::domain::Capabilities::default(),
2365                },
2366            );
2367            drop(hello);
2368
2369            let (mut shutdown, _) = listener.accept().unwrap();
2370            assert_eq!(
2371                read_json_line::<Request>(&mut shutdown, MAX_RESPONSE_BYTES).unwrap(),
2372                Request::Shutdown
2373            );
2374            send_response(&mut shutdown, &Response::Ack { revision: 1 });
2375            drop(listener);
2376            std::fs::remove_file(server_path).unwrap();
2377        });
2378
2379        Client::new(path).shutdown().unwrap();
2380        server.join().unwrap();
2381    }
2382
2383    #[test]
2384    fn large_json_line_uses_bounded_buffered_reads() {
2385        struct CountingReader {
2386            inner: io::Cursor<Vec<u8>>,
2387            reads: Arc<AtomicUsize>,
2388        }
2389        impl Read for CountingReader {
2390            fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
2391                self.reads.fetch_add(1, Ordering::Relaxed);
2392                self.inner.read(buffer)
2393            }
2394        }
2395
2396        let expected = "x".repeat(1024 * 1024);
2397        let mut encoded = serde_json::to_vec(&expected).unwrap();
2398        encoded.push(b'\n');
2399        let reads = Arc::new(AtomicUsize::new(0));
2400        let source = CountingReader {
2401            inner: io::Cursor::new(encoded),
2402            reads: Arc::clone(&reads),
2403        };
2404        let mut reader = BufReader::with_capacity(64 * 1024, source);
2405        let actual: String = read_buffered_json_line(&mut reader, 2 * 1024 * 1024).unwrap();
2406
2407        assert_eq!(actual, expected);
2408        assert!(reads.load(Ordering::Relaxed) < 32);
2409    }
2410
2411    #[test]
2412    fn terminal_stream_preserves_selection_updates_and_clipboard_order_after_subscribe_ack() {
2413        let (path, listener) = test_listener("buffered-subscribe");
2414        let server_path = path.clone();
2415        let server = thread::spawn(move || {
2416            let (mut stream, _) = listener.accept().unwrap();
2417            assert!(matches!(
2418                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2419                Request::Hello {
2420                    protocol: PROTOCOL_VERSION
2421                }
2422            ));
2423            let mut bytes = encode_line(&Response::Hello {
2424                protocol: PROTOCOL_VERSION,
2425                epoch: 1,
2426                capabilities: super::super::domain::Capabilities::default(),
2427            })
2428            .unwrap();
2429            stream.write_all(&bytes).unwrap();
2430            assert!(matches!(
2431                read_json_line::<Request>(&mut stream, MAX_RESPONSE_BYTES).unwrap(),
2432                Request::TerminalSubscribe {
2433                    rows: 24,
2434                    cols: 80,
2435                    ..
2436                }
2437            ));
2438            bytes = encode_line(&Response::Ack { revision: 1 }).unwrap();
2439            bytes.extend(
2440                encode_line(&TerminalServerMessage::Update(TerminalUpdate::Full(
2441                    TerminalFrame {
2442                        pane_id: PaneId(1),
2443                        terminal_id: TerminalId(2),
2444                        revision: 1,
2445                        cols: 1,
2446                        rows: 1,
2447                        cells: vec![Cell::default()],
2448                        cursor: Cursor {
2449                            x: 0,
2450                            y: 0,
2451                            visible: false,
2452                            blinking: false,
2453                            shape: 0,
2454                        },
2455                        selection: vec![TerminalSelectionRange {
2456                            row: 0,
2457                            start_col: 0,
2458                            end_col: 0,
2459                        }],
2460                    },
2461                )))
2462                .unwrap(),
2463            );
2464            bytes.extend(
2465                encode_line(&TerminalServerMessage::Update(TerminalUpdate::Patch {
2466                    pane_id: PaneId(1),
2467                    terminal_id: TerminalId(2),
2468                    base_revision: 1,
2469                    revision: 2,
2470                    cols: 1,
2471                    rows: 1,
2472                    changed_rows: Vec::new(),
2473                    cursor: Cursor {
2474                        x: 0,
2475                        y: 0,
2476                        visible: false,
2477                        blinking: false,
2478                        shape: 0,
2479                    },
2480                    selection: Vec::new(),
2481                }))
2482                .unwrap(),
2483            );
2484            bytes.extend(
2485                encode_line(&TerminalServerMessage::ClipboardWrite(b"copied".to_vec())).unwrap(),
2486            );
2487            bytes.extend(encode_line(&TerminalServerMessage::Exited).unwrap());
2488            stream.write_all(&bytes).unwrap();
2489            thread::sleep(Duration::from_millis(50));
2490            drop(listener);
2491            std::fs::remove_file(server_path).unwrap();
2492        });
2493
2494        let stream = TerminalStream::connect(
2495            &Client::new(path),
2496            super::super::domain::PaneId(1),
2497            7,
2498            false,
2499            24,
2500            80,
2501        )
2502        .unwrap();
2503        let deadline = Instant::now() + Duration::from_secs(1);
2504        let mut selections = Vec::new();
2505        let mut clipboard = None;
2506        loop {
2507            match stream.try_recv() {
2508                Ok(TerminalServerMessage::Update(TerminalUpdate::Full(frame))) => {
2509                    selections.push(frame.selection)
2510                }
2511                Ok(TerminalServerMessage::Update(TerminalUpdate::Patch { selection, .. })) => {
2512                    selections.push(selection)
2513                }
2514                Ok(TerminalServerMessage::ClipboardWrite(text)) => clipboard = Some(text),
2515                Ok(TerminalServerMessage::Exited) => break,
2516                Ok(message) => panic!("unexpected terminal message: {message:?}"),
2517                Err(mpsc::TryRecvError::Empty) if Instant::now() < deadline => {
2518                    thread::sleep(Duration::from_millis(10));
2519                }
2520                Err(error) => panic!("terminal update missing after ACK: {error}"),
2521            }
2522        }
2523        assert_eq!(
2524            selections,
2525            vec![
2526                vec![TerminalSelectionRange {
2527                    row: 0,
2528                    start_col: 0,
2529                    end_col: 0,
2530                }],
2531                Vec::new(),
2532            ]
2533        );
2534        assert_eq!(clipboard.as_deref(), Some(b"copied".as_slice()));
2535        drop(stream);
2536        server.join().unwrap();
2537    }
2538
2539    #[test]
2540    fn terminal_reader_shutdown_interrupts_full_update_queue() {
2541        let (mut server, client) = UnixStream::pair().unwrap();
2542        let (updates, receiver) = mpsc::sync_channel(1);
2543        updates.send(TerminalServerMessage::Exited).unwrap();
2544        let stopping = Arc::new(AtomicBool::new(false));
2545        let reader_stopping = Arc::clone(&stopping);
2546        let (done, finished) = mpsc::channel();
2547        let reader = thread::spawn(move || {
2548            terminal_reader(BufReader::new(client), updates, &reader_stopping);
2549            done.send(()).unwrap();
2550        });
2551
2552        server
2553            .write_all(&encode_line(&TerminalServerMessage::Exited).unwrap())
2554            .unwrap();
2555        thread::sleep(Duration::from_millis(50));
2556        stopping.store(true, Ordering::Release);
2557
2558        assert!(finished.recv_timeout(Duration::from_secs(1)).is_ok());
2559        drop(receiver);
2560        reader.join().unwrap();
2561    }
2562}