Skip to main content

strop_ui_protocol/
driver.rs

1//! The Rust-first stdio driver (0056 AR10): spawns `strop --ui-stdio`
2//! over pipes and exposes deterministic schedules — every operation is a
3//! message barrier (acknowledgement, generation, predicate), never an
4//! arbitrary sleep. The driver holds no editing model; all view truth
5//! lives in [`crate::client::Client`].
6//!
7//! Determinism contract: a budget is a hang canary, not timing evidence
8//! (the headless driver's `jobs_budget` precedent). The happy path
9//! returns the moment the barrier lands; expiry is always a failure.
10
11use std::io;
12use std::path::Path;
13use std::process::{Child, Command, ExitStatus, Stdio};
14use std::sync::mpsc::{Receiver, SyncSender};
15use std::time::{Duration, Instant};
16
17use crate::client::{Client, ClientEvent};
18use crate::frame::{self, FrameDecoder};
19use crate::message::{
20    AckOutcome, AdmittedAction, ClientCapabilities, ClientInfo, ClientMessage, EffectOutcome,
21    EffectRequest, ProtocolError, Refusal, ServerMessage, ShutdownReason, ViewSnapshot,
22    PROTOCOL_VERSION,
23};
24use crate::{ClientError, MAX_PENDING_REQUESTS};
25
26/// Default barrier budget: generous like the headless hang canary — the
27/// bound exists only to fail deadlocks.
28pub const DEFAULT_BUDGET: Duration = Duration::from_secs(120);
29
30/// How the driver failed. Refusals and protocol errors are typed data,
31/// never strings parsed out of a stream.
32#[derive(Debug, thiserror::Error)]
33pub enum DriverError {
34    #[error("io: {0}")]
35    Io(#[from] io::Error),
36    #[error("refused: {0}")]
37    Refused(Refusal),
38    #[error("protocol error: {0}")]
39    Protocol(ProtocolError),
40    #[error("the backend closed ({reason:?}) while waiting for {waiting}")]
41    BackendClosed {
42        reason: Option<ShutdownReason>,
43        waiting: &'static str,
44    },
45    #[error("timed out waiting for {waiting} (last applied generation {last_generation}, state {last_state})")]
46    Timeout {
47        waiting: &'static str,
48        last_generation: u64,
49        /// The cached semantic state at expiry — a barrier failure must
50        /// show what the backend actually published.
51        last_state: String,
52    },
53    #[error("client state: {0}")]
54    Client(#[from] ClientError),
55    #[error("the backend sent undecodable bytes: {0}")]
56    Transport(String),
57}
58
59/// What the backend's reader thread delivered.
60enum Transport {
61    Message(ServerMessage),
62    Failed(String),
63    Closed,
64}
65
66/// A control message a waiter may be looking for; view/effect traffic
67/// is consumed inside the pump.
68enum Pumped {
69    Ack(u64, AckOutcome),
70    Error(ProtocolError),
71    Closed(ShutdownReason),
72}
73
74/// A live `strop --ui-stdio` backend and the protocol client state.
75pub struct Driver {
76    child: Child,
77    stdin: io::BufWriter<std::process::ChildStdin>,
78    rx: Receiver<Transport>,
79    stderr: std::sync::Arc<std::sync::Mutex<String>>,
80    client: Client,
81    seq: u64,
82    budget: Duration,
83    closed: Option<ShutdownReason>,
84    effects: Vec<(u64, EffectRequest)>,
85}
86
87impl Driver {
88    /// Spawn the backend and complete the handshake barrier: hello →
89    /// welcome → the initial snapshot. `env` entries overlay the
90    /// inherited environment (tests pin HOME/XDG to a tempdir; the real
91    /// $HOME is never consulted).
92    pub fn spawn(
93        backend: &Path,
94        cwd: &Path,
95        env: &[(&str, std::ffi::OsString)],
96    ) -> Result<Self, DriverError> {
97        Self::spawn_args(backend, &["--ui-stdio"], cwd, env)
98    }
99
100    /// As [`Driver::spawn`] with explicit argv (unicode/quoting smoke).
101    pub fn spawn_args(
102        backend: &Path,
103        args: &[&str],
104        cwd: &Path,
105        env: &[(&str, std::ffi::OsString)],
106    ) -> Result<Self, DriverError> {
107        let mut command = Command::new(backend);
108        command
109            .args(args)
110            .current_dir(cwd)
111            .stdin(Stdio::piped())
112            .stdout(Stdio::piped())
113            .stderr(Stdio::piped());
114        for (key, value) in env {
115            command.env(key, value);
116        }
117        let mut child = command.spawn()?;
118        let stdin = child
119            .stdin
120            .take()
121            .ok_or_else(|| io::Error::other("backend stdin was not piped"))?;
122        let stdout = child
123            .stdout
124            .take()
125            .ok_or_else(|| io::Error::other("backend stdout was not piped"))?;
126        let mut stderr_pipe = child
127            .stderr
128            .take()
129            .ok_or_else(|| io::Error::other("backend stderr was not piped"))?;
130
131        let (tx, rx) = std::sync::mpsc::sync_channel(MAX_PENDING_REQUESTS);
132        std::thread::spawn(move || read_backend(stdout, tx));
133        let stderr = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
134        std::thread::spawn({
135            let captured = stderr.clone();
136            move || {
137                // Bounded diagnostics capture: 64 KiB is ample for a
138                // failure report; the bound keeps a noisy backend from
139                // growing memory without limit.
140                let mut chunk = [0u8; 4096];
141                loop {
142                    match io::Read::read(&mut stderr_pipe, &mut chunk) {
143                        Ok(0) | Err(_) => return,
144                        Ok(n) => {
145                            if let Ok(mut captured) = captured.lock() {
146                                const CAP: usize = 64 * 1024;
147                                let room = CAP.saturating_sub(captured.len());
148                                let take = n.min(room);
149                                captured.push_str(&String::from_utf8_lossy(&chunk[..take]));
150                            }
151                        }
152                    }
153                }
154            }
155        });
156
157        // The client state needs the incarnation, known after Welcome.
158        let mut driver = Self {
159            child,
160            stdin: io::BufWriter::new(stdin),
161            rx,
162            stderr,
163            client: Client::new(&crate::message::BackendInfo {
164                name: String::new(),
165                version: String::new(),
166                build: None,
167                incarnation: 0,
168            }),
169            seq: 0,
170            budget: DEFAULT_BUDGET,
171            closed: None,
172            effects: Vec::new(),
173        };
174        driver.handshake()?;
175        Ok(driver)
176    }
177
178    fn handshake(&mut self) -> Result<(), DriverError> {
179        self.send(&ClientMessage::Hello {
180            protocol: PROTOCOL_VERSION,
181            client: ClientInfo {
182                name: "strop-ui-protocol-driver".into(),
183                version: env!("CARGO_PKG_VERSION").into(),
184            },
185            capabilities: ClientCapabilities {
186                clipboard_write: true,
187            },
188        })?;
189        let deadline = self.deadline();
190        // Barrier: welcome, then the initial snapshot — the client may
191        // not act before its first publication exists.
192        while self.client.view().is_none() {
193            if let Some(pumped) = self.pump("the handshake", deadline)? {
194                match pumped {
195                    Pumped::Error(error) => return Err(DriverError::Protocol(error)),
196                    Pumped::Closed(reason) => {
197                        return Err(DriverError::BackendClosed {
198                            reason: Some(reason),
199                            waiting: "the handshake",
200                        })
201                    }
202                    Pumped::Ack(..) => {}
203                }
204            }
205        }
206        Ok(())
207    }
208
209    /// Override the barrier budget (hang canary; not timing evidence).
210    pub fn set_budget(&mut self, budget: Duration) {
211        self.budget = budget;
212    }
213
214    /// The readonly view cache.
215    pub fn client(&self) -> &Client {
216        &self.client
217    }
218
219    /// Host effect requests observed so far (answered `Applied`).
220    pub fn effects(&self) -> &[(u64, EffectRequest)] {
221        &self.effects
222    }
223
224    /// Captured bounded stderr diagnostics.
225    pub fn stderr(&self) -> String {
226        self.stderr
227            .lock()
228            .map(|captured| captured.clone())
229            .unwrap_or_else(|_| String::new())
230    }
231
232    /// Admitted actions on the current base; the barrier is the
233    /// acknowledgement plus the view reaching the acknowledged
234    /// generation. A `stale_generation` refusal means a publication was
235    /// missed: resync explicitly, then retry — the driver never replays
236    /// optimistically.
237    pub fn act(&mut self, actions: Vec<AdmittedAction>) -> Result<u64, DriverError> {
238        let base = self.client.base_stamp()?;
239        let seq = self.next_seq();
240        self.send(&ClientMessage::Act { seq, base, actions })?;
241        let deadline = self.deadline();
242        let generation = match self.wait_ack(seq, "the action acknowledgement", deadline)? {
243            AckOutcome::Applied { generation, .. } => generation,
244            AckOutcome::Refused { refusal } => return Err(DriverError::Refused(refusal)),
245        };
246        self.wait_generation_atleast(generation, "the action's publication", deadline)?;
247        Ok(generation)
248    }
249
250    /// One scripted key sequence (`ihello<esc>`), decoded by the
251    /// engine's own script-token parser — the same tokens the headless
252    /// driver's `keys` directive consumes.
253    pub fn act_keys(&mut self, keys: &str) -> Result<u64, DriverError> {
254        let actions: Vec<AdmittedAction> = strop_core::frontend_input::notation::parse(keys)
255            .map(|key| AdmittedAction::Input(strop_core::frontend_input::Input::Key(key)))
256            .collect();
257        self.act(actions)
258    }
259
260    /// One committed-text input (IME-style), a single admitted action.
261    pub fn act_text(&mut self, text: &str) -> Result<u64, DriverError> {
262        self.act(vec![AdmittedAction::Input(
263            strop_core::frontend_input::Input::Text(text.to_string()),
264        )])
265    }
266
267    /// One bracketed paste, a single admitted action.
268    pub fn act_paste(&mut self, text: &str) -> Result<u64, DriverError> {
269        self.act(vec![AdmittedAction::Paste(text.to_string())])
270    }
271
272    /// Declare viewport interest; the engine sees the same resize a TUI
273    /// would deliver. Barriered like an action.
274    pub fn viewport(&mut self, columns: u16, rows: u16) -> Result<u64, DriverError> {
275        let seq = self.next_seq();
276        self.send(&ClientMessage::Viewport { seq, columns, rows })?;
277        let deadline = self.deadline();
278        let generation = match self.wait_ack(seq, "the viewport acknowledgement", deadline)? {
279            AckOutcome::Applied { generation, .. } => generation,
280            AckOutcome::Refused { refusal } => return Err(DriverError::Refused(refusal)),
281        };
282        self.wait_generation_atleast(generation, "the viewport's publication", deadline)?;
283        Ok(generation)
284    }
285
286    /// Explicit recovery from a dropped delta or a poisoned client:
287    /// request a complete current snapshot and barrier on it.
288    pub fn resync(&mut self) -> Result<u64, DriverError> {
289        let seq = self.next_seq();
290        self.send(&ClientMessage::Resync { seq })?;
291        let deadline = self.deadline();
292        let generation = match self.wait_ack(seq, "the resync acknowledgement", deadline)? {
293            AckOutcome::Applied { generation, .. } => generation,
294            AckOutcome::Refused { refusal } => return Err(DriverError::Refused(refusal)),
295        };
296        loop {
297            if self.client.poisoned().is_none() && self.client.generation() >= generation {
298                return Ok(self.client.generation());
299            }
300            if let Some(pumped) = self.pump("the resync snapshot", deadline)? {
301                match pumped {
302                    Pumped::Error(error) => return Err(DriverError::Protocol(error)),
303                    Pumped::Closed(reason) => {
304                        return Err(DriverError::BackendClosed {
305                            reason: Some(reason),
306                            waiting: "the resync snapshot",
307                        })
308                    }
309                    Pumped::Ack(..) => {}
310                }
311            }
312        }
313    }
314
315    /// Barrier: the applied view reaches `generation`.
316    pub fn wait_generation(&mut self, generation: u64) -> Result<u64, DriverError> {
317        let deadline = self.deadline();
318        self.wait_generation_atleast(generation, "a view generation", deadline)?;
319        Ok(self.client.generation())
320    }
321
322    /// Barrier: the cached view satisfies `predicate`. Returns the
323    /// matching snapshot's generation.
324    pub fn wait_view(
325        &mut self,
326        waiting: &'static str,
327        predicate: impl Fn(&ViewSnapshot) -> bool,
328    ) -> Result<u64, DriverError> {
329        let deadline = self.deadline();
330        loop {
331            if let Some(view) = self.client.view() {
332                if predicate(view) {
333                    return Ok(view.generation);
334                }
335            }
336            if let Some(pumped) = self.pump(waiting, deadline)? {
337                match pumped {
338                    Pumped::Error(error) => return Err(DriverError::Protocol(error)),
339                    Pumped::Closed(reason) => {
340                        return Err(DriverError::BackendClosed {
341                            reason: Some(reason),
342                            waiting,
343                        })
344                    }
345                    Pumped::Ack(..) => {}
346                }
347            }
348        }
349    }
350
351    /// Authorized orderly shutdown: `shutdown` → `bye` → backend exit.
352    /// Returns the backend's exit status.
353    pub fn shutdown(mut self) -> Result<ExitStatus, DriverError> {
354        let seq = self.next_seq();
355        let deadline = self.deadline();
356        self.send(&ClientMessage::Shutdown { seq })?;
357        loop {
358            match self.pump("bye", deadline)? {
359                Some(Pumped::Closed(_)) => break,
360                Some(Pumped::Error(error)) => return Err(DriverError::Protocol(error)),
361                Some(Pumped::Ack(..)) | None => {}
362            }
363            if self.closed.is_some() {
364                break;
365            }
366        }
367        self.reap(deadline)
368    }
369
370    /// The backend's exit status once the link closed on its own
371    /// (editor quit, EOF). Barriered like `shutdown`.
372    pub fn wait_exit(mut self) -> Result<ExitStatus, DriverError> {
373        let deadline = self.deadline();
374        while self.closed.is_none() {
375            let _ = self.pump("backend exit", deadline)?;
376        }
377        self.reap(deadline)
378    }
379
380    fn reap(&mut self, deadline: Instant) -> Result<ExitStatus, DriverError> {
381        loop {
382            if let Some(status) = self.child.try_wait()? {
383                return Ok(status);
384            }
385            if Instant::now() >= deadline {
386                let _ = self.child.kill();
387                return Err(self.timeout("backend exit"));
388            }
389            // Reap poll: process mechanics, not scheduling evidence.
390            std::thread::sleep(Duration::from_millis(5));
391        }
392    }
393
394    fn timeout(&self, waiting: &'static str) -> DriverError {
395        DriverError::Timeout {
396            waiting,
397            last_generation: self.client.generation(),
398            last_state: self
399                .client
400                .view()
401                .map(|view| view.state.to_string())
402                .unwrap_or_else(|| "<no view>".into()),
403        }
404    }
405
406    fn next_seq(&mut self) -> u64 {
407        self.seq += 1;
408        self.seq
409    }
410
411    fn deadline(&self) -> Instant {
412        Instant::now() + self.budget
413    }
414
415    fn send(&mut self, message: &ClientMessage) -> Result<(), DriverError> {
416        frame::write_message(&mut self.stdin, message).map_err(DriverError::Io)
417    }
418
419    fn wait_ack(
420        &mut self,
421        seq: u64,
422        waiting: &'static str,
423        deadline: Instant,
424    ) -> Result<AckOutcome, DriverError> {
425        loop {
426            match self.pump(waiting, deadline)? {
427                Some(Pumped::Ack(seen, outcome)) if seen == seq => return Ok(outcome),
428                Some(Pumped::Error(error)) => return Err(DriverError::Protocol(error)),
429                Some(Pumped::Closed(reason)) => {
430                    return Err(DriverError::BackendClosed {
431                        reason: Some(reason),
432                        waiting,
433                    })
434                }
435                _ => {}
436            }
437        }
438    }
439
440    fn wait_generation_atleast(
441        &mut self,
442        generation: u64,
443        waiting: &'static str,
444        deadline: Instant,
445    ) -> Result<(), DriverError> {
446        while self.client.generation() < generation {
447            if let Some(pumped) = self.pump(waiting, deadline)? {
448                match pumped {
449                    Pumped::Error(error) => return Err(DriverError::Protocol(error)),
450                    Pumped::Closed(reason) => {
451                        return Err(DriverError::BackendClosed {
452                            reason: Some(reason),
453                            waiting,
454                        })
455                    }
456                    Pumped::Ack(..) => {}
457                }
458            }
459        }
460        Ok(())
461    }
462
463    /// Receive one backend delivery within the deadline. View and
464    /// effect traffic is applied/answered inside; control messages are
465    /// returned for the waiters.
466    fn pump(
467        &mut self,
468        waiting: &'static str,
469        deadline: Instant,
470    ) -> Result<Option<Pumped>, DriverError> {
471        let remaining = deadline.saturating_duration_since(Instant::now());
472        if remaining.is_zero() {
473            return Err(self.timeout(waiting));
474        }
475        let transport = match self.rx.recv_timeout(remaining) {
476            Ok(transport) => transport,
477            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return Err(self.timeout(waiting)),
478            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
479                self.client.link_lost();
480                return Err(DriverError::BackendClosed {
481                    reason: self.closed,
482                    waiting,
483                });
484            }
485        };
486        let message = match transport {
487            Transport::Message(message) => message,
488            Transport::Failed(error) => {
489                self.client.link_lost();
490                return Err(DriverError::Transport(error));
491            }
492            Transport::Closed => {
493                self.client.link_lost();
494                return Err(DriverError::BackendClosed {
495                    reason: self.closed,
496                    waiting,
497                });
498            }
499        };
500        if let ServerMessage::Bye { reason } = &message {
501            self.closed = Some(*reason);
502            // The cache poisons (link lost) AND the waiters observe the
503            // close — both, never one without the other.
504            self.client.apply(&message);
505            return Ok(Some(Pumped::Closed(*reason)));
506        }
507        if let ServerMessage::Error { error, .. } = &message {
508            return Ok(Some(Pumped::Error(error.clone())));
509        }
510        if let ServerMessage::Ack { seq, outcome } = &message {
511            return Ok(Some(Pumped::Ack(*seq, outcome.clone())));
512        }
513        for event in self.client.apply(&message) {
514            if let ClientEvent::Effect { id, effect } = event {
515                self.effects.push((id, effect));
516                self.send(&ClientMessage::EffectResult {
517                    id,
518                    outcome: EffectOutcome::Applied,
519                })?;
520            }
521        }
522        Ok(None)
523    }
524}
525
526impl Drop for Driver {
527    fn drop(&mut self) {
528        if self.closed.is_none() {
529            let _ = self.child.kill();
530        }
531        let _ = self.child.wait();
532    }
533}
534
535/// The backend reader: frames → decoded messages, typed failures, or a
536/// clean close. Send failure means the driver went away; exit quietly.
537fn read_backend(mut stdout: std::process::ChildStdout, tx: SyncSender<Transport>) {
538    let mut decoder = FrameDecoder::new();
539    let mut chunk = [0u8; 8192];
540    loop {
541        match io::Read::read(&mut stdout, &mut chunk) {
542            Ok(0) | Err(_) => {
543                // A partial frame at EOF means the backend died
544                // mid-write — a transport failure, not a clean close.
545                let _ = tx.send(if decoder.is_empty() {
546                    Transport::Closed
547                } else {
548                    Transport::Failed("stream ended mid-frame".into())
549                });
550                return;
551            }
552            Ok(n) => {
553                if let Err(error) = decoder.accept(&chunk[..n]) {
554                    let _ = tx.send(Transport::Failed(error.to_string()));
555                    return;
556                }
557                loop {
558                    match decoder.next_frame() {
559                        Ok(Some(body)) => match serde_json::from_slice::<ServerMessage>(&body) {
560                            Ok(message) => {
561                                if tx.send(Transport::Message(message)).is_err() {
562                                    return;
563                                }
564                            }
565                            Err(error) => {
566                                let _ = tx.send(Transport::Failed(format!(
567                                    "undecodable server message: {error}"
568                                )));
569                                return;
570                            }
571                        },
572                        Ok(None) => break,
573                        Err(error) => {
574                            let _ = tx.send(Transport::Failed(error.to_string()));
575                            return;
576                        }
577                    }
578                }
579            }
580        }
581    }
582}