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