Skip to main content

choreo_client_core/
connection.rs

1use crate::error::ClientError;
2use choreo_proto::{
3    ClientMessage, DaemonMessage, ProtoError, UnixStream, connect_unix, read_message, write_message,
4};
5use choreo_transport::error::TransportError;
6use choreo_transport::handshake::{
7    PREAMBLE_IK, PREAMBLE_XX, handshake_initiator, handshake_initiator_xx,
8};
9use choreo_transport::key::ensure_transport_keypair;
10// In-process transport: raw channel ends from an `choreo_daemon::embedded::EmbeddedLink`.
11// Pure values — client-core never depends on choreo-daemon; the GUI creates
12// the link and stuffs the ends into `ConnectionMode::InProcess`.
13use crossbeam_channel::{Receiver as CrossbeamReceiver, Sender as CrossbeamSender};
14use std::fmt;
15use std::io::{BufRead, BufReader, BufWriter, Write};
16use std::sync::mpsc;
17use std::thread;
18use std::time::Duration;
19use tracing::{debug, error, info, warn};
20
21/// Poll step for the writer's shutdown/queue select loop.
22const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
23/// Read `DaemonMessages` from `reader` in a blocking loop, calling
24/// `handle_daemon_message` for each successfully decoded message.
25///
26/// Returns `Ok(())` when the stream ends cleanly (EOF / connection reset).
27/// Returns `Err` on protocol or I/O errors.
28/// Drain a `BufRead` of framed `DaemonMessages` until EOF.
29///
30/// # Errors
31///
32/// Returns [`ClientError`] when the stream ends (EOF/closed connection), a
33/// frame fails to decode, or an I/O error occurs mid-stream.
34pub fn run_daemon_reader<R: BufRead>(
35    mut reader: R,
36    mut handle_daemon_message: impl FnMut(DaemonMessage),
37) -> Result<(), ClientError> {
38    loop {
39        debug!("daemon reader waiting for message");
40        match read_message::<_, DaemonMessage>(&mut reader) {
41            Ok(message) => {
42                debug!("received daemon message");
43                handle_daemon_message(message);
44            }
45            // Clean termination: the daemon closed its side of the connection.
46            Err(ProtoError::Io(error))
47                if matches!(
48                    error.kind(),
49                    std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::ConnectionReset
50                ) =>
51            {
52                break;
53            }
54            // Non-EOF I/O errors (broken pipe, connection aborted, etc.)
55            // are also fatal — the transport is gone.
56            Err(ProtoError::Io(error)) => {
57                error!(kind = %error.kind(), "daemon reader I/O error");
58                return Err(error.into());
59            }
60            // Protocol-level decode errors (Postcard, FrameTooLarge,
61            // TrailingBytes, UnsupportedVersion) are per-message failures.
62            // Because we use length-prefixed framing, a corrupt payload
63            // never desynchronises the stream — log and carry on.
64            Err(error) => {
65                error!(%error, "skipping corrupt daemon message");
66            }
67        }
68    }
69    info!("daemon reader loop ended normally");
70    Ok(())
71}
72
73///
74/// # Errors
75///
76/// Returns [`ClientError`] if the unix socket cannot be connected, the
77/// Noise handshake fails, or the connection loop hits an I/O or protocol
78/// error.
79// needless_pass_by_value waived: the receivers are channel endpoints the
80// caller must move in; external TUI/GUI/IM callers rely on this signature.
81#[allow(clippy::needless_pass_by_value)]
82pub fn run_daemon_connection(
83    socket_path: &str,
84    handle_daemon_message: impl FnMut(DaemonMessage),
85    from_ui: mpsc::Receiver<choreo_proto::ClientMessage>,
86    shutdown_rx: Option<mpsc::Receiver<()>>,
87) -> Result<(), ClientError> {
88    info!("connecting to daemon at {socket_path}");
89    let stream = connect_unix(socket_path)?;
90    pump_connection(stream, handle_daemon_message, from_ui, shutdown_rx)
91}
92
93/// Connect to the daemon at `socket_path`, starting one via `ensure_daemon` if
94/// (and ONLY if) the initial dial itself finds nothing listening.
95///
96/// There is no pre-flight probe: the first [`connect_unix`] IS the real
97/// connection attempt, and when the daemon is up it is used directly — the
98/// stream is never thrown away. Only a dial failure classified as "nothing is
99/// listening" (via [`choreo_proto::dial_error_means_no_listener`], shared
100/// with the daemon side so the classification can never drift) invokes
101/// `ensure_daemon`, after which the connection is retried. Any other dial
102/// error is returned unchanged — autostart cannot fix a permission problem,
103/// for example.
104///
105/// Race note: between the failed first dial and the post-autostart retry, a
106/// third party could bind the socket, or an auto-exit daemon could be racing
107/// its own shutdown. The retry dial therefore surfaces its errors verbatim —
108/// the caller sees exactly what the retry saw, never a synthetic "autostart
109/// failed".
110///
111/// # Errors
112///
113/// Returns the error from the first dial if it is classified as more than
114/// "nothing listening", or the retry dial's error verbatim if the autostart
115/// hook ran but the connection still failed.
116pub fn run_daemon_connection_with_autostart(
117    socket_path: &str,
118    ensure_daemon: &mut dyn FnMut() -> Result<(), ClientError>,
119    handle_daemon_message: impl FnMut(DaemonMessage),
120    from_ui: mpsc::Receiver<choreo_proto::ClientMessage>,
121    shutdown_rx: Option<mpsc::Receiver<()>>,
122) -> Result<(), ClientError> {
123    info!("connecting to daemon at {socket_path}");
124    let stream = match connect_unix(socket_path) {
125        Ok(stream) => stream,
126        Err(error) if choreo_proto::dial_error_means_no_listener(&error) => {
127            info!(%error, "no daemon listening on the socket; requesting autostart");
128            ensure_daemon()?;
129            info!("autostart done; retrying connection to daemon at {socket_path}");
130            connect_unix(socket_path)?
131        }
132        Err(error) => return Err(error.into()),
133    };
134    pump_connection(stream, handle_daemon_message, from_ui, shutdown_rx)
135}
136
137/// Drive an ESTABLISHED daemon connection: spawn the writer thread, install
138/// the optional shutdown hook, and block in the reader loop until the daemon
139/// closes the stream. Split out of `run_daemon_connection` so the autostart
140/// variant can hand over a stream it already dialed (the successful first
141/// dial is never discarded).
142fn pump_connection(
143    stream: UnixStream,
144    handle_daemon_message: impl FnMut(DaemonMessage),
145    from_ui: mpsc::Receiver<choreo_proto::ClientMessage>,
146    shutdown_rx: Option<mpsc::Receiver<()>>,
147) -> Result<(), ClientError> {
148    let reader = BufReader::new(stream.try_clone()?);
149    let mut writer = BufWriter::new(stream);
150
151    // Channel to signal the writer thread to stop when the reader finishes.
152    let (writer_shutdown_tx, writer_shutdown_rx) = mpsc::channel::<()>();
153
154    let writer_handle = thread::spawn(move || {
155        loop {
156            match from_ui.recv_timeout(SHUTDOWN_POLL_INTERVAL) {
157                Ok(msg) => {
158                    if let Err(e) = write_message(&mut writer, &msg) {
159                        warn!("writer thread write error: {e}");
160                        break;
161                    }
162                    let _ = writer.flush();
163                }
164                Err(mpsc::RecvTimeoutError::Timeout) => {
165                    // Poll the shutdown signal periodically so we don't hang
166                    // indefinitely on recv() when the daemon disconnects.
167                    if writer_shutdown_rx.try_recv().is_ok() {
168                        break;
169                    }
170                }
171                Err(mpsc::RecvTimeoutError::Disconnected) => break,
172            }
173        }
174    });
175
176    if let Some(shutdown_rx) = shutdown_rx {
177        let shutdown_stream = reader.get_ref().try_clone()?;
178        thread::spawn(move || {
179            let _ = shutdown_rx.recv();
180            let _ = shutdown_stream.shutdown(std::net::Shutdown::Both);
181        });
182    }
183
184    let reader_result = run_daemon_reader(reader, handle_daemon_message);
185    // Signal the writer to stop and wait for it to flush pending writes.
186    let _ = writer_shutdown_tx.send(());
187    let _ = writer_handle.join();
188    reader_result
189}
190
191/// Selects the transport for connecting to a daemon.
192///
193// Clone stays derived: crossbeam channel ends are `Clone`, so a cloned mode
194// shares the same underlying channel pair (fine — cloning a mode is only used
195// to stash it for `connection_addr`-style display lookups).
196#[derive(Clone)]
197pub enum ConnectionMode {
198    /// Connect via Unix domain socket at the given path.
199    UnixSocket(String),
200    /// Connect via TCP/Noise IK at the given address with the server's
201    /// 32-byte X25519 public key (resolved before constructing this variant).
202    Tcp { addr: String, server_pk: [u8; 32] },
203    /// Connect via TCP/Noise IK against the key PINNED in
204    /// `known_servers.toml` for the address. The pin is loaded at connect
205    /// time; a handshake failure is reported WITH the pinned fingerprint and
206    /// the re-pair guidance, so a server key change is loud instead of an
207    /// opaque connection error (the `known_hosts` behavior).
208    TcpPinned(String),
209    /// Connect to an EMBEDDED daemon in the same process: `daemon_tx` is the
210    /// client→daemon channel end, `daemon_rx` the daemon→client end (the raw
211    /// pair from `choreo_daemon::embedded::EmbeddedLink`, moved here as
212    /// values). Messages travel as VALUES — no codec, no socket. Channel
213    /// close IS the EOF in both directions, exactly like the daemon-side
214    /// embedded path. client-core never links choreo-daemon: the GUI (the
215    /// owner of the `EmbeddedDaemon`) creates the link and hands over the ends.
216    InProcess {
217        daemon_tx: CrossbeamSender<ClientMessage>,
218        daemon_rx: CrossbeamReceiver<DaemonMessage>,
219    },
220}
221
222// Manual Debug instead of a derive: crossbeam channel ends are not `Debug`,
223// and the embedded link carries no printable identity — it is rendered as a
224// placeholder. Every other variant formats EXACTLY as the derive output would
225// (`UnixSocket("path")`, `Tcp { addr: "…", server_pk: [..] }`,
226// `TcpPinned("addr")`) so log/error messages keep their previous shape.
227impl fmt::Debug for ConnectionMode {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        match self {
230            ConnectionMode::UnixSocket(path) => f.debug_tuple("UnixSocket").field(path).finish(),
231            ConnectionMode::Tcp { addr, server_pk } => f
232                .debug_struct("Tcp")
233                .field("addr", addr)
234                .field("server_pk", server_pk)
235                .finish(),
236            ConnectionMode::TcpPinned(addr) => f.debug_tuple("TcpPinned").field(addr).finish(),
237            // The ends are opaque by design: printing channel internals would
238            // leak nothing useful and might suggest a stable identity that
239            // does not exist.
240            ConnectionMode::InProcess { .. } => f.write_str("InProcess(<embedded link>)"),
241        }
242    }
243}
244
245impl Default for ConnectionMode {
246    fn default() -> Self {
247        ConnectionMode::UnixSocket(choreo_proto::socket_path())
248    }
249}
250
251/// Connect to a daemon via Noise IK over TCP.
252///
253/// Uses two blocking threads:
254/// - Reader thread: blocks on `NoiseStream::recv_daemon_message()`
255/// - Writer thread: blocks on `from_ui.recv_timeout()`
256/// - Shutdown: blocks on `shutdown_rx.recv()`, then shuts down the TCP stream
257///
258/// The reader thread has no read timeout — it blocks until a message arrives
259/// or the connection is closed. The writer thread uses a short timeout on its
260/// channel receive so it can also check for shutdown signals.
261///
262/// # Errors
263///
264/// Returns [`ClientError`] if dialing fails, the Noise IK handshake fails,
265/// or any reader/writer I/O error kills the connection.
266// needless_pass_by_value waived: same channel-endpoint ownership as
267// run_daemon_connection; external callers rely on this signature.
268#[allow(clippy::needless_pass_by_value)]
269pub fn run_daemon_tcp_connection(
270    addr: &str,
271    server_pk: &[u8; 32],
272    handle_daemon_message: impl FnMut(DaemonMessage),
273    from_ui: mpsc::Receiver<ClientMessage>,
274    shutdown_rx: Option<mpsc::Receiver<()>>,
275) -> Result<(), ClientError> {
276    info!("connecting to daemon at {addr}");
277
278    // Load the client transport keypair (generates one if absent).
279    let (client_sk, _client_pk) =
280        ensure_transport_keypair().map_err(|e| ClientError::Io(std::io::Error::other(e)))?;
281
282    // Dial FIRST, as its own step. Keeping the dial and the handshake
283    // separate is what lets the pinned-mode wrapper (see
284    // `run_daemon_tcp_connection_pinned`) attach trust guidance to
285    // HANDSHAKE failures only — a plain dial failure (daemon down, network
286    // down) is reported verbatim and must never suggest re-pairing.
287    let tcp = std::net::TcpStream::connect(addr).map_err(ClientError::Io)?;
288
289    ik_handshake_and_serve(
290        tcp,
291        client_sk.as_bytes(),
292        server_pk,
293        handle_daemon_message,
294        from_ui,
295        shutdown_rx,
296    )
297}
298
299/// The 1-byte handshake-mode preamble (TCP wire v5) goes out BEFORE any
300/// handshake message, then the Noise IK handshake runs. Returns the raw
301/// `TransportError` so callers can classify the failure (the
302/// `ConnectionRefused` wrapping lives one layer up, in
303/// [`ik_handshake_and_serve`]). Shared by the session-opening path and the
304/// [`verify_daemon_authorization`] preflight so both exercise the exact
305/// same wire sequence the daemon will judge.
306fn ik_handshake_raw(
307    mut tcp: std::net::TcpStream,
308    client_sk: &[u8; 32],
309    server_pk: &[u8; 32],
310) -> Result<choreo_transport::noise::NoiseStream, TransportError> {
311    // The preamble is unauthenticated by design — the daemon uses it
312    // only to pick which equally-authenticated handshake to run; the IK
313    // handshake itself authenticates both static keys and gates the ACL.
314    // A single-byte write to a fresh blocking socket cannot meaningfully
315    // block (it fits any socket buffer), so no timeout is armed for it.
316    tcp.write_all(&[PREAMBLE_IK])?;
317    handshake_initiator(tcp, client_sk, server_pk)
318}
319
320/// Preamble + Noise IK handshake over an ALREADY-DIALED TCP stream, then
321/// the encrypted session loop. Shared by [`run_daemon_tcp_connection`] and
322/// [`run_daemon_tcp_connection_pinned`] so both paths run the identical
323/// preamble+handshake sequence. A handshake failure surfaces as
324/// `ConnectionRefused` (the pre-existing wire convention) — which is why
325/// the caller must dial separately: only then does a `ConnectionRefused`
326/// returned from here unambiguously mean the HANDSHAKE failed, not the
327/// network.
328fn ik_handshake_and_serve(
329    tcp: std::net::TcpStream,
330    client_sk: &[u8; 32],
331    server_pk: &[u8; 32],
332    handle_daemon_message: impl FnMut(DaemonMessage),
333    from_ui: mpsc::Receiver<ClientMessage>,
334    shutdown_rx: Option<mpsc::Receiver<()>>,
335) -> Result<(), ClientError> {
336    let noise = ik_handshake_raw(tcp, client_sk, server_pk).map_err(|e| {
337        ClientError::Io(std::io::Error::new(
338            std::io::ErrorKind::ConnectionRefused,
339            e,
340        ))
341    })?;
342    serve_noise_connection(noise, handle_daemon_message, from_ui, shutdown_rx)
343}
344
345/// Connect to a daemon via Noise XX over TCP (first contact).
346///
347/// Use this when the client has NO pinned server public key for `addr`: the
348/// XX handshake reveals the server's static key, which is handed to
349/// `on_first_contact` BEFORE the encrypted session loop starts. The caller
350/// is expected to render the key's fingerprint for a human and return
351/// `true` only after an out-of-band confirmation (this is the trust gate —
352/// on `false` the connection is closed and NOTHING is sent, in particular
353/// no `Unlock`, so a first-contact MITM can never harvest the daemon's
354/// private key). The confirmed key is NOT stored here — pinning to
355/// `known_servers.toml` is the caller's job (phase 2), because the trust
356/// decision belongs to the UI layer, not the transport plumbing.
357///
358/// The writer thread only starts after `on_first_contact` returns `true`,
359/// so any `ClientMessage` already queued by the UI (including the TUI's
360/// auto-`Unlock`) is structurally held back until the trust decision is
361/// made — the gating is by construction, not by convention.
362///
363/// Otherwise identical to [`run_daemon_tcp_connection`] (same reader/writer
364/// thread shape, same shutdown semantics — see [`serve_noise_connection`]).
365///
366/// # Errors
367///
368/// Returns [`ClientError`] if dialing fails, the XX handshake fails, the
369/// learned server key is rejected by `on_first_contact`, or an I/O error
370/// kills the connection.
371pub fn run_daemon_tcp_connection_xx_first_contact(
372    addr: &str,
373    handle_daemon_message: impl FnMut(DaemonMessage),
374    from_ui: mpsc::Receiver<ClientMessage>,
375    shutdown_rx: Option<mpsc::Receiver<()>>,
376    on_first_contact: impl FnOnce([u8; 32]) -> bool,
377) -> Result<(), ClientError> {
378    info!("first-contact connect to daemon at {addr}");
379
380    // Load the client transport keypair (generates one if absent).
381    let (client_sk, _client_pk) =
382        ensure_transport_keypair().map_err(|e| ClientError::Io(std::io::Error::other(e)))?;
383
384    // Connect TCP, declare first-contact mode, run Noise XX.
385    let mut tcp = std::net::TcpStream::connect(addr).map_err(ClientError::Io)?;
386    // Same preamble reasoning as the IK path: unauthenticated mode selector,
387    // authenticated by nothing, needed for nothing — the handshake that
388    // follows carries all the cryptographic guarantees.
389    tcp.write_all(&[PREAMBLE_XX]).map_err(ClientError::Io)?;
390    let (noise, server_pk) = handshake_initiator_xx(tcp, client_sk.as_bytes()).map_err(|e| {
391        ClientError::Io(std::io::Error::new(
392            std::io::ErrorKind::ConnectionRefused,
393            e,
394        ))
395    })?;
396
397    // Trust gate: the caller verifies the learned key out-of-band. On
398    // refusal, drop the (already-established) encrypted transport without
399    // sending a single protocol message — the connection simply closes.
400    if !on_first_contact(server_pk) {
401        info!("first-contact trust rejected by caller; closing connection");
402        return Ok(());
403    }
404
405    serve_noise_connection(noise, handle_daemon_message, from_ui, shutdown_rx)
406}
407
408/// Shared tail of both TCP connection modes: the encrypted session loop over
409/// an already-established `NoiseStream`.
410///
411/// Uses two blocking threads:
412/// - Reader thread (the caller's): blocks on `NoiseStream::recv_daemon_message()`
413/// - Writer thread: blocks on `from_ui.recv_timeout()`
414/// - Shutdown: blocks on `shutdown_rx.recv()`, then shuts down the TCP stream
415///
416/// Extracted so [`run_daemon_tcp_connection`] (IK) and
417/// [`run_daemon_tcp_connection_xx_first_contact`] (XX) share one writer/reader
418/// implementation — the two modes differ ONLY in preamble + handshake, not in
419/// how the established transport is served.
420fn serve_noise_connection(
421    mut noise: choreo_transport::noise::NoiseStream,
422    mut handle_daemon_message: impl FnMut(DaemonMessage),
423    from_ui: mpsc::Receiver<ClientMessage>,
424    shutdown_rx: Option<mpsc::Receiver<()>>,
425) -> Result<(), ClientError> {
426    // Channel to signal writer thread to stop when reader finishes.
427    let (writer_shutdown_tx, writer_shutdown_rx) = mpsc::channel::<()>();
428
429    // Writer thread: blocks on from_ui.recv_timeout(), sends via NoiseStream.
430    // The timeout is only so the writer can check the shutdown signal —
431    // no socket-level timeout is set.
432    let mut writer = noise.try_clone().map_err(ClientError::Io)?;
433    let writer_handle = thread::spawn(move || {
434        loop {
435            match from_ui.recv_timeout(SHUTDOWN_POLL_INTERVAL) {
436                Ok(msg) => {
437                    if let Err(e) = writer.send_client_message(&msg) {
438                        warn!("writer thread error: {e}");
439                        break;
440                    }
441                }
442                Err(mpsc::RecvTimeoutError::Timeout) => {
443                    // Check for shutdown signal so we don't hang on recv.
444                    if writer_shutdown_rx.try_recv().is_ok() {
445                        break;
446                    }
447                }
448                Err(mpsc::RecvTimeoutError::Disconnected) => break,
449            }
450        }
451    });
452
453    // Optional shutdown signal: shuts down the TCP connection when triggered.
454    if let Some(shutdown_rx) = shutdown_rx {
455        let stream_ref = noise.get_ref().try_clone().map_err(ClientError::Io)?;
456        thread::spawn(move || {
457            let _ = shutdown_rx.recv();
458            let _ = stream_ref.shutdown(std::net::Shutdown::Both);
459        });
460    }
461
462    // Reader loop: blocks on noise.recv_daemon_message() (no read timeout).
463    loop {
464        match noise.recv_daemon_message() {
465            Ok(message) => {
466                handle_daemon_message(message);
467            }
468            Err(TransportError::ConnectionClosed) => {
469                info!("daemon closed Noise IK connection");
470                break;
471            }
472            // I/O errors from the underlying stream after shutdown:
473            // treat them the same as ConnectionClosed.
474            Err(TransportError::Io(ref e))
475                if matches!(
476                    e.kind(),
477                    std::io::ErrorKind::UnexpectedEof
478                        | std::io::ErrorKind::ConnectionReset
479                        | std::io::ErrorKind::ConnectionAborted
480                        | std::io::ErrorKind::BrokenPipe
481                ) =>
482            {
483                info!("daemon connection closed: {e}");
484                break;
485            }
486            Err(e) => {
487                error!(error = %e, "daemon reader error");
488                break;
489            }
490        }
491    }
492
493    // Signal writer to stop and wait for it.
494    let _ = writer_shutdown_tx.send(());
495    let _ = writer_handle.join();
496    info!("daemon reader loop ended normally");
497    Ok(())
498}
499
500/// Probe a daemon's static public key without establishing a session.
501///
502/// Performs the XX first-contact handshake ONLY: connect, preamble, the
503/// three handshake messages, extract the learned server key, and DROP the
504/// stream — no data-plane message is ever sent or received. This is the
505/// building block UIs use to implement the trust flow synchronously (in a
506/// normal-mode terminal, before any TUI/GUI starts): learn the key, show
507/// the fingerprint, get the human's confirmation, pin, and only then open
508/// the real connection with [`ConnectionMode::Tcp`] / IK.
509///
510/// The probe itself authenticates NOTHING about the server (there is no pin
511/// yet — that is the point), but it does authenticate the probe TO the
512/// daemon, so the daemon's ACL applies: probing requires the client's key
513/// to be enrolled. The daemon ACL check completes inside the XX handshake
514/// (after message 3), so a not-yet-enrolled client gets `Err` here too.
515///
516/// # Errors
517///
518/// Returns [`ClientError`] on connect/handshake/I/O failures while probing.
519pub fn probe_server_key(addr: &str) -> Result<[u8; 32], ClientError> {
520    info!("probing server key at {addr} (XX first contact)");
521
522    let (client_sk, _client_pk) =
523        ensure_transport_keypair().map_err(|e| ClientError::Io(std::io::Error::other(e)))?;
524
525    let mut tcp = std::net::TcpStream::connect(addr).map_err(ClientError::Io)?;
526    // Same preamble contract as the session-opening XX path.
527    tcp.write_all(&[PREAMBLE_XX]).map_err(ClientError::Io)?;
528    let (noise, server_pk) = handshake_initiator_xx(tcp, client_sk.as_bytes()).map_err(|e| {
529        ClientError::Io(std::io::Error::new(
530            std::io::ErrorKind::ConnectionRefused,
531            e,
532        ))
533    })?;
534
535    // Drop the established transport immediately: the probe never speaks
536    // the protocol. Closing here is also what keeps the daemon's connection
537    // slot usage bounded — the real connection opens fresh afterwards.
538    drop(noise);
539    debug!(addr, "server key probe complete; transport dropped");
540    Ok(server_pk)
541}
542
543/// Why a preflight authorization check failed (see
544/// [`verify_daemon_authorization`]). The two cases need DIFFERENT
545/// remediation — "start the daemon / check the network" vs "get your key
546/// enrolled" — so they are distinguished at the type level instead of by
547/// string-matching an `io::ErrorKind` (a dial refusal and a handshake
548/// rejection both involve connection-level I/O and must not be conflated).
549#[derive(Debug, thiserror::Error)]
550pub enum PreflightError {
551    /// The daemon could not be reached at all (dial failure).
552    #[error("cannot reach the daemon: {0}")]
553    Unreachable(#[source] std::io::Error),
554    /// The daemon was reached but the Noise IK handshake failed: the
555    /// daemon either does not hold the expected server key, or its ACL
556    /// did not admit this client's transport key. (An IK handshake is the
557    /// ONLY probe that can detect the ACL rejection — the daemon aborts
558    /// the handshake before message 2 — which is why this preflight runs
559    /// IK even on a first-contact flow that already probed with XX.)
560    #[error("the daemon rejected the connection handshake: {0}")]
561    Rejected(#[source] TransportError),
562}
563
564/// The client's OWN Noise transport public key (generating the on-disk
565/// keypair first if absent). This is the identity the daemon's ACL judges:
566/// UIs embed it (and its fingerprint) in enrollment-remediation messages so
567/// a TUI-only user never needs the daemon binary just to read out their key.
568///
569/// # Errors
570///
571/// Returns [`ClientError`] if the local transport keypair cannot be loaded
572/// or generated.
573pub fn own_transport_pubkey() -> Result<[u8; 32], ClientError> {
574    let (_sk, pk) =
575        ensure_transport_keypair().map_err(|e| ClientError::Io(std::io::Error::other(e)))?;
576    Ok(pk)
577}
578
579/// Verify that the daemon at `addr` will actually admit this client BEFORE
580/// the caller commits to a full session (the TUI runs this before starting
581/// any UI).
582///
583/// Dials `addr`, runs the complete Noise IK handshake against `server_pk`,
584/// and immediately drops the established transport — no protocol message is
585/// ever sent. A successful IK handshake proves BOTH properties at once:
586/// the daemon holds the expected static key (the handshake authenticates
587/// it) AND the daemon's ACL admitted this client's key (the responder
588/// checks the ACL mid-handshake and closes the connection before completing
589/// it when the client is not enrolled). No other check can detect the
590/// enrollment case: the XX first-contact probe completes client-side before
591/// the daemon's ACL check runs, so it always "succeeds" for un-enrolled
592/// clients.
593///
594/// The cost is one extra handshake per connect — negligible against the
595/// session it gates, and it converts "TUI starts, then dies with a cryptic
596/// I/O error on first use" into a clear refusal before any UI exists.
597///
598/// # Errors
599///
600/// Returns [`PreflightError`] if the recorded key is missing, unreadable,
601/// corrupt, or does not match `server_pk`.
602pub fn verify_daemon_authorization(addr: &str, server_pk: &[u8; 32]) -> Result<(), PreflightError> {
603    info!(
604        addr,
605        "authorization preflight: probing daemon with IK handshake"
606    );
607
608    let (client_sk, _client_pk) = ensure_transport_keypair()
609        .map_err(|e| PreflightError::Unreachable(std::io::Error::other(e)))?;
610
611    // Dial failure = the daemon is down / unreachable — reported as-is so
612    // the caller's message can point at the network, not at enrollment.
613    let tcp = std::net::TcpStream::connect(addr).map_err(PreflightError::Unreachable)?;
614
615    // Handshake failure = rejection (wrong server key OR this client not
616    // enrolled in the daemon's ACL). Classified as `Rejected`; the caller
617    // renders the remediation.
618    let noise =
619        ik_handshake_raw(tcp, client_sk.as_bytes(), server_pk).map_err(PreflightError::Rejected)?;
620
621    // The handshake succeeded — the daemon will admit us. Drop the
622    // transport; the real session opens fresh (the daemon cleans the
623    // preflight connection up through its normal disconnect path).
624    drop(noise);
625    debug!(addr, "authorization preflight passed");
626    Ok(())
627}
628
629/// Connect via Noise IK against the key PINNED in `known_servers.toml` for
630/// `addr` (the [`ConnectionMode::TcpPinned`] path).
631///
632/// The whole point of this wrapper is the failure UX: a HANDSHAKE failure
633/// against the pinned key carries the pinned fingerprint and the explicit
634/// re-pair instructions, so a server key change is a loud, actionable
635/// message rather than an opaque error (the `known_hosts` behavior). The
636/// dial is performed HERE, as a separate step, so a network-down daemon is
637/// reported as a plain connect error WITHOUT the re-pair guidance — the
638/// remediation advice is reserved for the one failure it actually applies
639/// to (the server's key changed).
640///
641/// Errors if no pin exists for `addr` — callers must resolve first contact
642/// (probe + confirm + [`KnownServers::pin`]) before using this mode.
643///
644/// # Errors
645///
646/// Returns [`ClientError`] if dialing or the pinned-key IK handshake fails,
647/// or an I/O error kills the connection.
648pub fn run_daemon_tcp_connection_pinned(
649    addr: &str,
650    handle_daemon_message: impl FnMut(DaemonMessage),
651    from_ui: mpsc::Receiver<ClientMessage>,
652    shutdown_rx: Option<mpsc::Receiver<()>>,
653) -> Result<(), ClientError> {
654    let known = crate::known_servers::KnownServers::load()?;
655    let pinned = known
656        .lookup(addr)?
657        .ok_or_else(|| {
658            ClientError::Io(std::io::Error::other(format!(
659                "no pinned server key for {addr}: complete first contact (probe + fingerprint confirmation) before using the pinned mode"
660            )))
661        })?;
662
663    info!(
664        addr,
665        fingerprint = %choreo_transport::key::fingerprint(&pinned),
666        "connecting with pinned server key"
667    );
668
669    // Dial as its own step (plain error, no trust guidance — the daemon
670    // being down has nothing to do with the pin).
671    let tcp = std::net::TcpStream::connect(addr).map_err(ClientError::Io)?;
672
673    let (client_sk, _client_pk) =
674        ensure_transport_keypair().map_err(|e| ClientError::Io(std::io::Error::other(e)))?;
675
676    // The dial already succeeded, so a `ConnectionRefused` coming back from
677    // `ik_handshake_and_serve` can only be its handshake-failure wrapper —
678    // exactly the case where the re-pair guidance belongs. Later failures
679    // (a mid-session disconnect) still pass through untouched: a broken
680    // pipe has nothing to do with the pin and must not suggest re-pairing.
681    ik_handshake_and_serve(
682        tcp,
683        client_sk.as_bytes(),
684        &pinned,
685        handle_daemon_message,
686        from_ui,
687        shutdown_rx,
688    )
689    .map_err(|e| {
690        match &e {
691            ClientError::Io(io)
692                if io.kind() == std::io::ErrorKind::ConnectionRefused =>
693            {
694                // Multi-line guidance: the fingerprint is what the user
695                // compares against the daemon operator's out-of-band
696                // readout; the two follow-up lines separate the expected
697                // remediation from the warning when the change was NOT
698                // expected.
699                let msg = format!(
700                    "handshake with the pinned server key failed: {e}\n\
701                     pinned fingerprint for {addr}: {}\n\
702                     if the server's key legitimately changed, remove the entry for {addr} from known_servers.toml and reconnect to re-confirm the new fingerprint;\n\
703                     if you did NOT expect a change, do not reconnect — investigate the network first",
704                    choreo_transport::key::fingerprint(&pinned)
705                );
706                ClientError::Io(std::io::Error::other(msg))
707            }
708            _ => e,
709        }
710    })
711}
712
713/// Serve a connection over an in-process channel pair (the
714/// [`ConnectionMode::InProcess`] mode).
715///
716/// Structure mirrors `serve_noise_connection` EXACTLY, with channels
717/// replacing the transport:
718/// - Reader (the CALLING thread — callers already run this on a spawned
719///   thread): `for message in daemon_rx` forwards each value straight to
720///   `handle_daemon_message`; the loop ends when the daemon drops its sender
721///   (channel close IS the EOF — mapped to the same clean `Ok(())` the unix
722///   path returns on EOF, so `UiEvent::ReaderClosed` semantics are identical).
723/// - Writer: a dedicated thread draining `from_ui` (std mpsc) into
724///   `daemon_tx` — the same `recv_timeout` + shutdown-flag structure the
725///   socket modes use. Dropping `from_ui` ends the writer, which drops the
726///   last client-side `daemon_tx` end — the daemon's embedded connection sees
727///   channel close (= EOF) and runs its normal cleanup.
728///
729/// Shutdown is COOPERATIVE in-process (a deliberate difference from the TCP
730/// path, where `Shutdown::Both` force-kills the socket): an external
731/// `shutdown_rx` signal stops the writer thread via the internal
732/// writer-shutdown channel, but the daemon-side channel can never be
733/// force-closed from here — the reader ends when the EMBEDDED DAEMON closes
734/// its end (its `EmbeddedDaemon::shutdown()` delivers `ShuttingDown` as a
735/// value, then closes the channel, which unblocks this reader exactly the
736/// way a daemon EOF unblocks the socket reader).
737fn run_daemon_connection_in_process(
738    daemon_tx: CrossbeamSender<ClientMessage>,
739    daemon_rx: CrossbeamReceiver<DaemonMessage>,
740    mut handle_daemon_message: impl FnMut(DaemonMessage),
741    from_ui: mpsc::Receiver<ClientMessage>,
742    shutdown_rx: Option<mpsc::Receiver<()>>,
743) {
744    info!("serving in-process (embedded daemon) connection");
745
746    // Internal writer-shutdown channel — same shape as the socket modes: the
747    // reader signals it when it finishes, and the optional external
748    // shutdown signal fans into it too (cooperative stop for the writer
749    // only; see the function doc for why the reader cannot be force-closed).
750    // crossbeam per the workspace's channel-selection convention (this is
751    // new code in choreo-client-core): a one-shot flag channel needs no
752    // payload or backpressure, but new channels default to crossbeam here.
753    // The `from_ui` parameter itself stays std `mpsc`: its type is the
754    // pre-existing public signature shared with the socket modes.
755    let (writer_shutdown_tx, writer_shutdown_rx) = crossbeam_channel::bounded::<()>(0);
756
757    // Writer thread: drains `from_ui` into `daemon_tx` — the identical
758    // recv_timeout + shutdown-check loop the socket writer threads run; a
759    // crossbeam send of a value replaces the socket write, and a failed send
760    // (all daemon-side receivers dropped) is the broken-pipe analogue.
761    let writer_handle = thread::spawn(move || {
762        loop {
763            match from_ui.recv_timeout(SHUTDOWN_POLL_INTERVAL) {
764                Ok(msg) => {
765                    if daemon_tx.send(msg).is_err() {
766                        warn!("writer thread: daemon receiver gone (embedded connection closed)");
767                        break;
768                    }
769                }
770                Err(mpsc::RecvTimeoutError::Timeout) => {
771                    // Poll the shutdown signal periodically so we don't hang
772                    // indefinitely on recv() when the daemon disconnects.
773                    // `try_recv` on a zero-capacity channel is the rendezvous-
774                    // free obvious check; a success (the reader's send landed)
775                    // means stop. `Empty` is the normal in-service case.
776                    match writer_shutdown_rx.try_recv() {
777                        Ok(()) | Err(crossbeam_channel::TryRecvError::Disconnected) => break,
778                        Err(crossbeam_channel::TryRecvError::Empty) => {}
779                    }
780                }
781                // `from_ui` closed: the UI is done sending. Dropping
782                // `daemon_tx` (owned by this thread) is what delivers EOF to
783                // the daemon's embedded connection.
784                Err(mpsc::RecvTimeoutError::Disconnected) => break,
785            }
786        }
787    });
788
789    // Optional external shutdown: no socket exists to `Shutdown::Both`, so
790    // the signal only stops the WRITER (cooperative; documented above).
791    if let Some(shutdown_rx) = shutdown_rx {
792        // Clone the sender BEFORE the closure: the reader tail below keeps
793        // its own end to stop the writer when the loop ends.
794        let writer_tx = writer_shutdown_tx.clone();
795        thread::spawn(move || {
796            // A send failure here is benign: the reader finished first and
797            // dropped the writer-shutdown sender side.
798            let _ = shutdown_rx.recv().map(|()| writer_tx.send(()));
799        });
800    }
801
802    // Reader loop: the calling thread. Channel close = clean EOF, exactly the
803    // `Ok(())` the unix path returns on `UnexpectedEof`.
804    for message in daemon_rx {
805        handle_daemon_message(message);
806    }
807    info!("daemon reader loop ended normally (embedded daemon closed its channel)");
808
809    // Signal the writer to stop and wait for it (same tail as the unix path).
810    let _ = writer_shutdown_tx.send(());
811    let _ = writer_handle.join();
812}
813
814/// Connect to a daemon using the given connection mode.
815/// Dispatches to the appropriate connection function.
816///
817/// # Errors
818///
819/// Returns [`ClientError`] as raised by the connection mode actually used
820/// (unix, TCP variants, first-contact preflight, or in-process).
821pub fn run_daemon_connection_with_mode(
822    mode: ConnectionMode,
823    handle_daemon_message: impl FnMut(DaemonMessage),
824    from_ui: mpsc::Receiver<ClientMessage>,
825    shutdown_rx: Option<mpsc::Receiver<()>>,
826) -> Result<(), ClientError> {
827    match mode {
828        ConnectionMode::UnixSocket(path) => {
829            run_daemon_connection(&path, handle_daemon_message, from_ui, shutdown_rx)
830        }
831        ConnectionMode::Tcp { addr, server_pk } => run_daemon_tcp_connection(
832            &addr,
833            &server_pk,
834            handle_daemon_message,
835            from_ui,
836            shutdown_rx,
837        ),
838        ConnectionMode::TcpPinned(addr) => {
839            run_daemon_tcp_connection_pinned(&addr, handle_daemon_message, from_ui, shutdown_rx)
840        }
841        ConnectionMode::InProcess {
842            daemon_tx,
843            daemon_rx,
844        } => {
845            run_daemon_connection_in_process(
846                daemon_tx,
847                daemon_rx,
848                handle_daemon_message,
849                from_ui,
850                shutdown_rx,
851            );
852            Ok(())
853        }
854    }
855}
856
857#[cfg(test)]
858mod in_process_tests {
859    use super::*;
860    use choreo_proto::SessionSummary;
861
862    /// Bare in-process link — two crossbeam channels, NO real daemon. The
863    /// fake-daemon threads below play the daemon side of an `EmbeddedLink`:
864    /// they receive forwarded `ClientMessage`s from `client_rx`, reply
865    /// through their `daemon_tx` end (the daemon→client channel), and
866    /// DROPPING that end is the EOF the reader observes — exactly how the
867    /// real embedded daemon signals disconnect.
868    fn make_link() -> (
869        ConnectionMode,
870        mpsc::Sender<ClientMessage>,
871        mpsc::Receiver<ClientMessage>,
872        crossbeam_channel::Receiver<ClientMessage>,
873        crossbeam_channel::Sender<DaemonMessage>,
874    ) {
875        let (client_tx, client_rx) = crossbeam_channel::unbounded::<ClientMessage>();
876        let (daemon_tx, daemon_rx) = crossbeam_channel::unbounded::<DaemonMessage>();
877        let (from_ui_tx, from_ui_rx) = mpsc::channel::<ClientMessage>();
878        let mode = ConnectionMode::InProcess {
879            daemon_tx: client_tx,
880            daemon_rx,
881        };
882        (mode, from_ui_tx, from_ui_rx, client_rx, daemon_tx)
883    }
884
885    /// Run the connection on a dedicated thread (mirroring production: the
886    /// pump's READER is its calling thread) and return its result plus a
887    /// receiver of every `DaemonMessage` it handled, in order.
888    fn spawn_connection(
889        mode: ConnectionMode,
890        from_ui: mpsc::Receiver<ClientMessage>,
891        handle: impl FnMut(DaemonMessage) + Send + 'static,
892    ) -> thread::JoinHandle<(
893        Result<(), ClientError>,
894        crossbeam_channel::Receiver<DaemonMessage>,
895    )> {
896        // Bounded crossbeam channel between the handler closure and the test:
897        // join-driven, no sleeps anywhere. Capacity is generous enough that
898        // the reader never blocks on it before the daemon closes its end.
899        let (seen_tx, seen_rx) = crossbeam_channel::unbounded::<DaemonMessage>();
900        thread::spawn(move || {
901            let mut handle = handle;
902            let result = run_daemon_connection_with_mode(
903                mode,
904                |message| {
905                    let _ = seen_tx.send(message.clone());
906                    handle(message);
907                },
908                from_ui,
909                None,
910            );
911            (result, seen_rx)
912        })
913    }
914
915    /// One reply per request, in order: the fake daemon echoes a distinct
916    /// `DaemonMessage` per `ClientMessage` variant, then closes its channel
917    /// (EOF). The reader must receive every reply, in order, and end cleanly.
918    #[test]
919    fn in_process_replies_arrive_in_order_and_close_cleanly() {
920        let (mode, from_ui_tx, from_ui_rx, client_rx, daemon_tx) = make_link();
921        thread::spawn(move || {
922            for msg in client_rx {
923                let reply = match msg {
924                    ClientMessage::Ping => DaemonMessage::Pong,
925                    ClientMessage::ListModels => DaemonMessage::Models {
926                        models: vec!["m1".to_string()],
927                        selected_model: None,
928                    },
929                    ClientMessage::Lock => DaemonMessage::Locked,
930                    _ => continue,
931                };
932                if daemon_tx.send(reply).is_err() {
933                    break;
934                }
935            }
936            // Dropping `daemon_tx` (and the writer's client-side end) is the
937            // daemon-side EOF.
938        });
939
940        let requests = [
941            ClientMessage::Ping,
942            ClientMessage::ListModels,
943            ClientMessage::Lock,
944        ];
945        for request in &requests {
946            from_ui_tx.send(request.clone()).expect("from_ui open");
947        }
948        // Close `from_ui`: the writer drains everything, then exits, which
949        // closes the client→daemon channel and lets the daemon-side thread
950        // end and close the daemon→client channel.
951        drop(from_ui_tx);
952
953        let (result, seen_rx) = spawn_connection(mode, from_ui_rx, |_| {})
954            .join()
955            .expect("join");
956        result.expect("in-process connection must end cleanly on channel close");
957
958        // Every reply arrived, in order, before the clean EOF.
959        let replies: Vec<DaemonMessage> = seen_rx.into_iter().collect();
960        assert_eq!(
961            replies,
962            vec![
963                DaemonMessage::Pong,
964                DaemonMessage::Models {
965                    models: vec!["m1".to_string()],
966                    selected_model: None,
967                },
968                DaemonMessage::Locked,
969            ]
970        );
971    }
972
973    /// The writer forwards EVERY `from_ui` message — verified from the
974    /// daemon side: the fake daemon counts what it receives and reports the
975    /// sequence back as `Sessions` replies before closing.
976    #[test]
977    fn in_process_writer_forwards_every_message() {
978        let (mode, from_ui_tx, from_ui_rx, client_rx, daemon_tx) = make_link();
979        thread::spawn(move || {
980            let mut pings = 0usize;
981            for msg in client_rx {
982                if matches!(msg, ClientMessage::Ping) {
983                    pings += 1;
984                    let _ = daemon_tx.send(DaemonMessage::Sessions {
985                        sessions: vec![SessionSummary {
986                            session_id: pings as u64,
987                            title: None,
988                            selected_model: None,
989                            parent_session_id: None,
990                            working_dir: None,
991                            created_at: 0,
992                            last_modified: 0,
993                            turn_count: 0,
994                            status: choreo_proto::SessionStatus::Inactive,
995                            active_tool_groups: vec![],
996                            account_name: None,
997                            reasoning_effort: None,
998                            token_usage: None,
999                            context_window: None,
1000                            last_prompt_tokens: None,
1001                            pinned: false,
1002                            archived_at: None,
1003                        }],
1004                    });
1005                }
1006            }
1007        });
1008
1009        // Multiple messages through the writer while it is alive.
1010        for _ in 0..5 {
1011            from_ui_tx.send(ClientMessage::Ping).expect("from_ui open");
1012        }
1013        drop(from_ui_tx);
1014
1015        let (result, seen_rx) = spawn_connection(mode, from_ui_rx, |_| {})
1016            .join()
1017            .expect("join");
1018        result.expect("clean end after from_ui close");
1019        let replies: Vec<u64> = seen_rx
1020            .into_iter()
1021            .map(|m| match m {
1022                DaemonMessage::Sessions { sessions } => sessions[0].session_id,
1023                other => panic!("unexpected message: {other:?}"),
1024            })
1025            .collect();
1026        assert_eq!(
1027            replies,
1028            vec![1, 2, 3, 4, 5],
1029            "every forwarded Ping must be answered, in order"
1030        );
1031    }
1032
1033    /// Daemon-side close ends the connection cleanly even while the UI is
1034    /// still open: the reader ends on `daemon_rx` exhaustion (the EOF), the
1035    /// writer is stopped through the internal shutdown channel and joined.
1036    #[test]
1037    fn in_process_daemon_close_ends_connection_while_ui_open() {
1038        let (mode, from_ui_tx, from_ui_rx, client_rx, daemon_tx) = make_link();
1039        thread::spawn(move || {
1040            // One advisory message, then the daemon drops its end: the
1041            // channel-close EOF. `client_rx` is dropped un-received — the
1042            // daemon may vanish while client messages are still in flight.
1043            let _ = client_rx;
1044            let _ = daemon_tx.send(DaemonMessage::ShuttingDown);
1045            drop(daemon_tx);
1046        });
1047
1048        // The UI sender stays OPEN for the whole test: the connection must
1049        // still end because the daemon side is gone.
1050        from_ui_tx.send(ClientMessage::Ping).expect("from_ui open");
1051
1052        let (result, seen_rx) = spawn_connection(mode, from_ui_rx, |_| {})
1053            .join()
1054            .expect("join");
1055        result.expect("daemon-side close must be a clean EOF");
1056        let replies: Vec<DaemonMessage> = seen_rx.into_iter().collect();
1057        assert_eq!(replies, vec![DaemonMessage::ShuttingDown]);
1058        // The pump must NOT have closed `from_ui` itself (it only ever
1059        // drains it): a late send is delivered into the channel — it simply
1060        // has no consumer. It must not panic or error.
1061        let _ = from_ui_tx.send(ClientMessage::Ping);
1062    }
1063
1064    /// The manual `Debug` impl must keep the derived shapes for the socket
1065    /// variants (log/error messages rely on them) and render the embedded
1066    /// link as an opaque placeholder.
1067    #[test]
1068    fn debug_output_matches_previous_derive_shapes() {
1069        assert_eq!(
1070            format!("{:?}", ConnectionMode::UnixSocket("/tmp/sock".to_string())),
1071            r#"UnixSocket("/tmp/sock")"#
1072        );
1073        let mut pk = [0u8; 32];
1074        pk[0] = 1;
1075        pk[31] = 255;
1076        assert_eq!(
1077            format!(
1078                "{:?}",
1079                ConnectionMode::Tcp {
1080                    addr: "127.0.0.1:9443".to_string(),
1081                    server_pk: pk
1082                }
1083            ),
1084            format!("Tcp {{ addr: \"127.0.0.1:9443\", server_pk: {:?} }}", pk)
1085        );
1086        assert_eq!(
1087            format!("{:?}", ConnectionMode::TcpPinned("a:1".to_string())),
1088            r#"TcpPinned("a:1")"#
1089        );
1090        let (mode, _from_ui_tx, _from_ui_rx, _client_rx, daemon_tx) = make_link();
1091        assert_eq!(format!("{mode:?}"), "InProcess(<embedded link>)");
1092        // The ends themselves are opaque and dropped with the mode.
1093        drop(daemon_tx);
1094    }
1095
1096    /// The autostart hook must fire ONLY when the dial finds nothing
1097    /// listening (`NotFound` here: no socket file at all). The hook deliberately
1098    /// fails, so a successful run would be impossible — the pinned outcome is
1099    /// exactly one hook invocation and the hook's error surfacing as the
1100    /// connection result. (The live-listener half of this contract binds real
1101    /// sockets, so it lives in `tests/it/connection_autostart.rs` — no filesystem
1102    /// or IPC boundary in unit tests.)
1103    #[test]
1104    fn autostart_hook_invoked_when_nothing_listens() {
1105        let path = std::env::temp_dir().join(format!(
1106            "choreo-core-autostart-absent-{}-{}",
1107            std::process::id(),
1108            format!("{:?}", std::thread::current().id()).as_str()
1109        ));
1110        let _ = std::fs::remove_file(&path); // absent by construction
1111        let path = path.to_string_lossy().into_owned();
1112
1113        let mut hook_calls = 0;
1114        let mut ensure_daemon = || {
1115            hook_calls += 1;
1116            Err(ClientError::DaemonStart("test: no daemon".to_string()))
1117        };
1118        let (from_ui_tx, from_ui_rx) = mpsc::channel::<ClientMessage>();
1119        drop(from_ui_tx); // the pump's writer thread ends immediately
1120
1121        let error = run_daemon_connection_with_autostart(
1122            &path,
1123            &mut ensure_daemon,
1124            |_| {},
1125            from_ui_rx,
1126            None,
1127        )
1128        .expect_err("the failing hook must fail the connection");
1129
1130        assert!(matches!(error, ClientError::DaemonStart(_)));
1131        assert_eq!(hook_calls, 1, "the hook must run exactly once");
1132        let _ = std::fs::remove_file(&path);
1133    }
1134}