Skip to main content

termwright_protocol/
publication_queue.rs

1//! Ordered, bounded semantic publication off a framework render thread.
2
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4use std::sync::{mpsc, Arc, Mutex};
5
6use serde_json::Value;
7
8use crate::client::Client;
9use crate::error::Error;
10use crate::evidence::Lease as EvidenceProviderLease;
11use crate::framing::encode_frame;
12use crate::limits::Limits;
13use crate::marker::encode_marker;
14use crate::messages::{RevisionCommit, SemanticFullMessage};
15use crate::tree::Snapshot;
16use crate::validate::validate_snapshot;
17
18struct Publication {
19    revision: i64,
20    tree_frame: Option<Vec<u8>>,
21    commit_frame: Vec<u8>,
22}
23
24/// A bounded FIFO whose render-side operation performs no transport I/O.
25///
26/// A marker is returned only after the complete validated snapshot+commit
27/// pair has been encoded and admitted atomically. Full queues consume no
28/// revision. A transport failure permanently closes admission.
29pub struct PublicationQueue {
30    sender: Option<mpsc::SyncSender<Publication>>,
31    token: String,
32    session_id: String,
33    limits: Limits,
34    marker_enabled: bool,
35    revision: i64,
36    evidence_lease: Option<EvidenceProviderLease>,
37    failed: Arc<AtomicBool>,
38    dropped: AtomicU64,
39    fatal: Arc<Mutex<Option<(String, String)>>>,
40    worker: Option<std::thread::JoinHandle<()>>,
41    #[cfg(test)]
42    done: Mutex<mpsc::Receiver<()>>,
43}
44
45impl PublicationQueue {
46    /// Move a connected client into a dedicated writer thread.
47    ///
48    /// Construct this only after the handshake; dormant applications never
49    /// create a queue or thread. Capacity must be positive.
50    pub fn new(client: Client, capacity: usize) -> Result<Self, Error> {
51        Self::new_inner(client, capacity, None)
52    }
53
54    fn new_inner(
55        mut client: Client,
56        capacity: usize,
57        #[cfg(test)] gate: Option<Arc<TestWorkerGate>>,
58        #[cfg(not(test))] _gate: Option<()>,
59    ) -> Result<Self, Error> {
60        if capacity == 0 || !client.connected() {
61            return Err(Error::PublicationWorkerFailed);
62        }
63        let (token, session_id, limits, marker_enabled, revision) = client
64            .publication_config()
65            .ok_or(Error::PublicationWorkerFailed)?;
66        let evidence_lease = client.take_evidence_lease();
67        let (sender, receiver) = mpsc::sync_channel::<Publication>(capacity);
68        #[cfg(test)]
69        let (done_sender, done_receiver) = mpsc::sync_channel(1);
70        let failed = Arc::new(AtomicBool::new(false));
71        let worker_failed = failed.clone();
72        let fatal: Arc<Mutex<Option<(String, String)>>> = Arc::new(Mutex::new(None));
73        let worker_fatal = fatal.clone();
74        let worker = std::thread::Builder::new()
75            .name("termwright-semantic-publication".into())
76            .spawn(move || {
77                while let Ok(publication) = receiver.recv() {
78                    #[cfg(test)]
79                    if let Some(gate) = gate.as_ref() {
80                        gate.enter();
81                    }
82                    let result = (|| {
83                        if let Some(frame) = publication.tree_frame.as_ref() {
84                            client.write_frame(frame)?;
85                        }
86                        client.write_frame(&publication.commit_frame)?;
87                        client.accept_queued_publication(
88                            publication.revision,
89                            publication.tree_frame.is_some(),
90                        );
91                        Ok::<(), Error>(())
92                    })();
93                    if result.is_err() {
94                        worker_failed.store(true, Ordering::Release);
95                        client.close();
96                        #[cfg(test)]
97                        let _ = done_sender.send(());
98                        return;
99                    }
100                }
101                let fatal = worker_fatal.lock().ok().and_then(|mut value| value.take());
102                if let Some((code, message)) = fatal {
103                    let _ = client.fail(&code, message);
104                } else {
105                    client.close();
106                }
107                #[cfg(test)]
108                let _ = done_sender.send(());
109            })
110            .map_err(Error::Io)?;
111        Ok(Self {
112            sender: Some(sender),
113            token,
114            session_id,
115            limits,
116            marker_enabled,
117            revision,
118            evidence_lease,
119            failed,
120            dropped: AtomicU64::new(0),
121            fatal,
122            worker: Some(worker),
123            #[cfg(test)]
124            done: Mutex::new(done_receiver),
125        })
126    }
127
128    /// Negotiated snapshot limits used by a framework tree builder.
129    pub fn limits(&self) -> &Limits {
130        &self.limits
131    }
132
133    /// Validate, encode and non-blockingly admit one complete revision.
134    pub fn publish(&mut self, snapshot: &mut Snapshot) -> Result<Option<String>, Error> {
135        if self.failed.load(Ordering::Acquire) {
136            return Err(Error::PublicationWorkerFailed);
137        }
138        let revision = self.revision + 1;
139        snapshot.v = 3;
140        snapshot.session_id = self.session_id.clone();
141        snapshot.revision = revision;
142        if let Some(lease) = self.evidence_lease.as_ref() {
143            snapshot.provider_evidence =
144                lease.collect(&self.session_id, revision, snapshot.columns, snapshot.rows);
145        }
146        let body = serde_json::to_string(&snapshot).map_err(|_| {
147            Error::Protocol(crate::error::Violation::new(
148                "frame-malformed",
149                "snapshot is not JSON-serialisable",
150            ))
151        })?;
152        let parsed: Value = serde_json::from_str(&body).expect("just serialised");
153        validate_snapshot(&parsed, &self.limits)?;
154        let marker = if self.marker_enabled {
155            Some(encode_marker(&self.token, &self.session_id, revision)?)
156        } else {
157            None
158        };
159        let tree_frame = Some(encode_frame(
160            &SemanticFullMessage::new(snapshot),
161            self.limits.max_frame_bytes,
162        )?);
163        let commit_frame =
164            encode_frame(&RevisionCommit::new(revision), self.limits.max_frame_bytes)?;
165        let publication = Publication {
166            revision,
167            tree_frame,
168            commit_frame,
169        };
170        let Some(sender) = self.sender.as_ref() else {
171            self.failed.store(true, Ordering::Release);
172            return Err(Error::PublicationWorkerFailed);
173        };
174        match sender.try_send(publication) {
175            Ok(()) => {
176                self.revision = revision;
177                Ok(marker)
178            }
179            Err(mpsc::TrySendError::Full(_)) => {
180                self.dropped.fetch_add(1, Ordering::Relaxed);
181                Err(Error::PublicationQueueFull)
182            }
183            Err(mpsc::TrySendError::Disconnected(_)) => {
184                self.failed.store(true, Ordering::Release);
185                Err(Error::PublicationWorkerFailed)
186            }
187        }
188    }
189
190    /// Revisions dropped before admission because the FIFO was full.
191    pub fn dropped(&self) -> u64 {
192        self.dropped.load(Ordering::Relaxed)
193    }
194
195    /// Whether transport failed and no later marker may be issued.
196    pub fn failed(&self) -> bool {
197        self.failed.load(Ordering::Acquire)
198    }
199
200    /// Fail closed without writing from the caller's thread. Already-admitted
201    /// revisions drain in order, then the worker sends the typed fatal.
202    pub fn fail(&mut self, code: impl Into<String>, message: impl Into<String>) {
203        self.failed.store(true, Ordering::Release);
204        if let Ok(mut fatal) = self.fatal.lock() {
205            *fatal = Some((code.into(), message.into()));
206        }
207        // Closing admission lets the worker drain everything already accepted,
208        // then send the fatal in the same ordered transport thread.
209        self.sender.take();
210    }
211
212    /// Close admission, drain the ordered worker and wait for its causal
213    /// completion. Framework lifecycle code calls this outside render hooks so
214    /// a short-lived one-frame process cannot exit ahead of its semantic data.
215    /// Returns whether the worker completed without a transport failure.
216    pub fn shutdown(mut self) -> bool {
217        self.sender.take();
218        if let Some(worker) = self.worker.take() {
219            let _ = worker.join();
220        }
221        !self.failed.load(Ordering::Acquire)
222    }
223}
224
225#[cfg(test)]
226struct TestWorkerGate {
227    entered: mpsc::SyncSender<()>,
228    release: Mutex<mpsc::Receiver<()>>,
229}
230
231#[cfg(test)]
232impl TestWorkerGate {
233    fn enter(&self) {
234        let _ = self.entered.send(());
235        let _ = self.release.lock().expect("gate lock").recv();
236    }
237}
238
239#[cfg(all(test, unix))]
240mod tests {
241    use super::*;
242    use std::io::Read;
243    use std::os::unix::net::UnixStream;
244
245    use crate::FrameDecoder;
246    use crate::{Node, Role};
247
248    fn snapshot() -> Snapshot {
249        let mut snapshot = Snapshot::new(80, 24);
250        snapshot.push(Node::new("root", Role::Application, "fixture"));
251        snapshot
252    }
253
254    fn gated_queue(
255        capacity: usize,
256    ) -> (
257        PublicationQueue,
258        UnixStream,
259        mpsc::Receiver<()>,
260        mpsc::SyncSender<()>,
261    ) {
262        let (client_stream, server_stream) = UnixStream::pair().expect("socket pair");
263        let client = Client::test_connected(client_stream);
264        let (entered_tx, entered_rx) = mpsc::sync_channel(1);
265        let (release_tx, release_rx) = mpsc::sync_channel(1);
266        let gate = Arc::new(TestWorkerGate {
267            entered: entered_tx,
268            release: Mutex::new(release_rx),
269        });
270        let queue = PublicationQueue::new_inner(client, capacity, Some(gate)).expect("queue");
271        (queue, server_stream, entered_rx, release_tx)
272    }
273
274    #[test]
275    fn full_queue_drops_without_revision_gap_or_marker() {
276        let (mut queue, mut server, entered, release) = gated_queue(1);
277        assert!(queue.publish(&mut snapshot()).unwrap().is_some());
278        entered.recv().expect("worker entered first job");
279        assert!(queue.publish(&mut snapshot()).unwrap().is_some());
280        assert!(matches!(
281            queue.publish(&mut snapshot()),
282            Err(Error::PublicationQueueFull)
283        ));
284        assert_eq!(queue.revision, 2);
285        assert_eq!(queue.dropped(), 1);
286        release.send(()).unwrap();
287        entered.recv().expect("worker entered second job");
288        release.send(()).unwrap();
289        queue.fail("test-complete", "close");
290        queue.done.lock().unwrap().recv().expect("worker stopped");
291        let mut bytes = Vec::new();
292        let _ = server.read_to_end(&mut bytes);
293        let mut decoder = FrameDecoder::new(
294            crate::DEFAULT_LIMITS.max_frame_bytes,
295            crate::DEFAULT_LIMITS.max_depth,
296        );
297        let frames = decoder.push(&bytes).expect("ordered frames decode");
298        let names: Vec<_> = frames
299            .iter()
300            .take(4)
301            .map(|frame| frame.value["type"].as_str())
302            .collect();
303        assert_eq!(
304            names,
305            [
306                Some("semantic-full"),
307                Some("revision-commit"),
308                Some("semantic-full"),
309                Some("revision-commit")
310            ]
311        );
312    }
313
314    #[test]
315    fn worker_failure_permanently_refuses_later_markers() {
316        let (mut queue, server, entered, release) = gated_queue(1);
317        assert!(queue.publish(&mut snapshot()).unwrap().is_some());
318        entered.recv().expect("worker entered");
319        drop(server);
320        release.send(()).unwrap();
321        // The disconnected sender is the causal failure signal; no polling or
322        // quiet window participates in correctness.
323        queue.done.lock().unwrap().recv().expect("worker stopped");
324        assert!(matches!(
325            queue.publish(&mut snapshot()),
326            Err(Error::PublicationWorkerFailed)
327        ));
328    }
329
330    #[test]
331    fn shutdown_drains_an_admitted_one_frame_process_before_returning() {
332        let (client_stream, mut server) = UnixStream::pair().expect("socket pair");
333        let client = Client::test_connected(client_stream);
334        let mut queue = PublicationQueue::new(client, 1).expect("queue");
335        assert!(queue.publish(&mut snapshot()).unwrap().is_some());
336
337        assert!(queue.shutdown(), "clean drain reported worker failure");
338
339        let mut bytes = Vec::new();
340        server
341            .read_to_end(&mut bytes)
342            .expect("worker closed socket");
343        let mut decoder = FrameDecoder::new(
344            crate::DEFAULT_LIMITS.max_frame_bytes,
345            crate::DEFAULT_LIMITS.max_depth,
346        );
347        let frames = decoder.push(&bytes).expect("ordered frames decode");
348        assert_eq!(frames.len(), 2);
349        assert_eq!(frames[0].value["type"], "semantic-full");
350        assert_eq!(frames[1].value["type"], "revision-commit");
351    }
352}