@tishlang/tish-format 3.10.4 → 3.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,419 @@
1
+ //! Shared reader-thread buffer + UTF-8-boundary drain for the three byte-stream modules
2
+ //! (`process_spawn`, `net`, `pty`). These used to carry three byte-identical copies of the
3
+ //! same append loop and drain guard; this module is the single implementation.
4
+ //!
5
+ //! Semantics (identical for every consumer):
6
+ //!
7
+ //! - **Bounded buffer with backpressure.** Each stream buffers at most `TISH_STREAM_MAX_BUF`
8
+ //! bytes (default 8 MiB; the env override mirrors `TISH_WS_MAX_CONNS` in `ws.rs`). When the
9
+ //! buffer is full the reader thread *parks* on the condvar instead of appending, so the OS
10
+ //! pipe/socket fills and the producer blocks on `write(2)` — restoring exactly the kernel
11
+ //! backpressure a greedy reader thread otherwise defeats. Draining (or retiring) the buffer
12
+ //! wakes the reader. The cap is approximate up to one read chunk (8 KiB).
13
+ //! - **UTF-8 drain that always makes progress.** `drain_stream` returns only complete UTF-8:
14
+ //! a genuinely *incomplete* trailing multi-byte sequence (`Utf8Error::error_len() == None`,
15
+ //! at most 3 bytes) is held for the next call, but *invalid* bytes
16
+ //! (`error_len() == Some(_)`) can never become valid no matter what arrives later — they are
17
+ //! drained and lossy-converted to U+FFFD so a single bad byte cannot wedge the stream.
18
+ //! - **Capacity release.** After a drain the Vec is shrunk when it retains far more capacity
19
+ //! than it holds, so a one-off burst does not pin its high-water allocation forever.
20
+ //! - **Retirement.** `retire_buf` marks the buffer dead and wakes a parked reader so the
21
+ //! thread exits (used by kill/close paths whose fd shutdown alone cannot wake a parked —
22
+ //! as opposed to read-blocked — reader).
23
+ //!
24
+ //! Contract surfaced to tish code (unchanged): `""` = live but nothing readable yet,
25
+ //! `null` = EOF (drained) or unknown id.
26
+
27
+ use std::io::Read;
28
+ use std::sync::{Arc, Condvar, Mutex, OnceLock};
29
+ use std::time::Duration;
30
+
31
+ use tishlang_core::Value;
32
+
33
+ /// Output accumulated by a reader thread, drained by the module's read fn.
34
+ pub(crate) struct StreamBuf {
35
+ pub(crate) data: Vec<u8>,
36
+ pub(crate) eof: bool,
37
+ /// Set by `retire_buf` when the owning session/connection is being torn down; a parked
38
+ /// reader wakes, sees it, and exits.
39
+ pub(crate) retired: bool,
40
+ }
41
+
42
+ pub(crate) type SharedBuf = Arc<(Mutex<StreamBuf>, Condvar)>;
43
+
44
+ pub(crate) fn new_buf() -> SharedBuf {
45
+ Arc::new((
46
+ Mutex::new(StreamBuf {
47
+ data: Vec::new(),
48
+ eof: false,
49
+ retired: false,
50
+ }),
51
+ Condvar::new(),
52
+ ))
53
+ }
54
+
55
+ /// Max bytes buffered per stream before the reader thread parks (drain to resume).
56
+ /// Override with `TISH_STREAM_MAX_BUF`; default 8 MiB.
57
+ pub(crate) fn max_stream_buf() -> usize {
58
+ static MAX: OnceLock<usize> = OnceLock::new();
59
+ *MAX.get_or_init(|| {
60
+ std::env::var("TISH_STREAM_MAX_BUF")
61
+ .ok()
62
+ .and_then(|v| v.parse().ok())
63
+ .filter(|&v: &usize| v > 0)
64
+ .unwrap_or(8 << 20)
65
+ })
66
+ }
67
+
68
+ /// What a reader thread does when its buffer hits the cap.
69
+ ///
70
+ /// - `Park`: wait for a drain — the OS pipe/socket fills and the PRODUCER blocks. Correct for
71
+ /// streams the caller consumes by definition (child stdout, sockets, pty output): the cap
72
+ /// becomes real end-to-end backpressure.
73
+ /// - `DropOldest`: discard the oldest buffered bytes to make room and keep reading. Correct
74
+ /// for SIDE channels the caller may legitimately never read (child stderr): parking there
75
+ /// would block the child's stderr write(2) once the pipe also fills, which freezes its
76
+ /// stdout too — turning the old unbounded-growth bug into a deadlock for any program that
77
+ /// ignores stderr. Dropping from the FRONT keeps the newest output (the part a late reader
78
+ /// actually wants); the mid-UTF-8 cut this can produce is exactly what the lossy invalid-
79
+ /// prefix path in `drain_stream` already handles.
80
+ #[derive(Clone, Copy, PartialEq)]
81
+ pub(crate) enum CapMode {
82
+ Park,
83
+ DropOldest,
84
+ }
85
+
86
+ /// Reader thread with `Park` semantics — for streams the caller consumes (stdout/net/pty).
87
+ pub(crate) fn spawn_reader<R: Read + Send + 'static>(r: R, buf: SharedBuf) {
88
+ let cap = max_stream_buf();
89
+ std::thread::spawn(move || reader_loop(r, buf, cap, CapMode::Park));
90
+ }
91
+
92
+ /// Reader thread with `DropOldest` semantics — for side channels (child stderr).
93
+ pub(crate) fn spawn_side_reader<R: Read + Send + 'static>(r: R, buf: SharedBuf) {
94
+ let cap = max_stream_buf();
95
+ std::thread::spawn(move || reader_loop(r, buf, cap, CapMode::DropOldest));
96
+ }
97
+
98
+ /// The reader-thread body, cap/mode-injectable for tests.
99
+ pub(crate) fn reader_loop<R: Read>(mut r: R, buf: SharedBuf, cap: usize, mode: CapMode) {
100
+ let mut tmp = [0u8; 8192];
101
+ loop {
102
+ // At capacity: park until a drain (or retirement) makes room — Park mode only.
103
+ if mode == CapMode::Park {
104
+ let (lock, cv) = &*buf;
105
+ let mut b = match lock.lock() {
106
+ Ok(b) => b,
107
+ Err(_) => return,
108
+ };
109
+ while b.data.len() >= cap && !b.retired {
110
+ b = match cv.wait(b) {
111
+ Ok(g) => g,
112
+ Err(_) => return,
113
+ };
114
+ }
115
+ if b.retired {
116
+ return;
117
+ }
118
+ }
119
+ match r.read(&mut tmp) {
120
+ Ok(0) => {
121
+ mark_eof(&buf);
122
+ return;
123
+ }
124
+ Ok(n) => {
125
+ let (lock, cv) = &*buf;
126
+ if let Ok(mut b) = lock.lock() {
127
+ if b.retired {
128
+ return;
129
+ }
130
+ b.data.extend_from_slice(&tmp[..n]);
131
+ if mode == CapMode::DropOldest && b.data.len() > cap {
132
+ let overflow = b.data.len() - cap;
133
+ b.data.drain(..overflow);
134
+ }
135
+ }
136
+ cv.notify_all();
137
+ }
138
+ Err(_) => {
139
+ mark_eof(&buf);
140
+ return;
141
+ }
142
+ }
143
+ }
144
+ }
145
+
146
+ fn mark_eof(buf: &SharedBuf) {
147
+ let (lock, cv) = &**buf;
148
+ if let Ok(mut b) = lock.lock() {
149
+ b.eof = true;
150
+ }
151
+ cv.notify_all();
152
+ }
153
+
154
+ /// Mark a buffer dead and wake its (possibly parked) reader thread so it exits. Call from
155
+ /// kill/close paths; harmless if the reader already exited.
156
+ pub(crate) fn retire_buf(buf: &SharedBuf) {
157
+ let (lock, cv) = &**buf;
158
+ if let Ok(mut b) = lock.lock() {
159
+ b.retired = true;
160
+ }
161
+ cv.notify_all();
162
+ }
163
+
164
+ /// Whether the stream is fully consumed: reader saw EOF and everything was drained.
165
+ /// (`eof` also proves the reader thread has exited.)
166
+ pub(crate) fn is_drained(buf: &SharedBuf) -> bool {
167
+ let (lock, _) = &**buf;
168
+ lock.lock()
169
+ .map(|b| b.eof && b.data.is_empty())
170
+ .unwrap_or(false)
171
+ }
172
+
173
+ /// How many leading bytes can be drained right now. Everything is drainable except a
174
+ /// genuinely incomplete trailing multi-byte sequence (`error_len() == None`), which is held
175
+ /// for the next read — unless the stream is at EOF (nothing more will arrive) or the held
176
+ /// tail is impossibly long for a UTF-8 prefix (>= 4 bytes; cannot happen per the std
177
+ /// contract, kept as a safety valve so the stream can never wedge).
178
+ fn drainable_len(data: &[u8], eof: bool) -> usize {
179
+ if eof {
180
+ return data.len();
181
+ }
182
+ let mut i = 0;
183
+ loop {
184
+ match std::str::from_utf8(&data[i..]) {
185
+ Ok(_) => return data.len(),
186
+ Err(e) => match e.error_len() {
187
+ // Invalid bytes: will never become valid — drainable (lossy-converted).
188
+ Some(n) => i += e.valid_up_to() + n,
189
+ // Incomplete tail: hold it (it is 1..=3 bytes) for the next read.
190
+ None => {
191
+ let held_from = i + e.valid_up_to();
192
+ if data.len() - held_from >= 4 {
193
+ return data.len();
194
+ }
195
+ return held_from;
196
+ }
197
+ },
198
+ }
199
+ }
200
+ }
201
+
202
+ /// After a drain, release a burst's high-water capacity: if the Vec retains more than 4x
203
+ /// what it now holds (beyond a small keep-floor), shrink it back down.
204
+ fn shrink_after_drain(data: &mut Vec<u8>) {
205
+ const KEEP: usize = 16 * 1024;
206
+ if data.capacity() > KEEP && data.capacity() > data.len().saturating_mul(4) {
207
+ data.shrink_to(data.len().max(KEEP));
208
+ }
209
+ }
210
+
211
+ /// Drain a stream buffer as a string (possibly `""` within the timeout), or `null` at EOF.
212
+ /// Invalid bytes are replaced with U+FFFD; only an incomplete trailing multi-byte sequence
213
+ /// is held for the next call (no mojibake, no permanent stall).
214
+ pub(crate) fn drain_stream(buf: &SharedBuf, timeout_ms: u64) -> Value {
215
+ let (lock, cv) = &**buf;
216
+ let mut b = match lock.lock() {
217
+ Ok(b) => b,
218
+ Err(_) => return Value::Null,
219
+ };
220
+ if b.data.is_empty() && !b.eof && timeout_ms > 0 {
221
+ let res = cv.wait_timeout_while(b, Duration::from_millis(timeout_ms), |b| {
222
+ b.data.is_empty() && !b.eof
223
+ });
224
+ b = match res {
225
+ Ok((g, _)) => g,
226
+ Err(e) => e.into_inner().0,
227
+ };
228
+ }
229
+ if b.data.is_empty() {
230
+ if b.eof {
231
+ return Value::Null;
232
+ }
233
+ return Value::String("".into());
234
+ }
235
+ let take = drainable_len(&b.data, b.eof);
236
+ if take == 0 {
237
+ // Only an incomplete multi-byte tail is buffered; wait for the rest.
238
+ return Value::String("".into());
239
+ }
240
+ let out: Vec<u8> = b.data.drain(..take).collect();
241
+ shrink_after_drain(&mut b.data);
242
+ // Wake a reader parked at the cap: there is room again.
243
+ cv.notify_all();
244
+ drop(b);
245
+ let s = match String::from_utf8(out) {
246
+ Ok(s) => s,
247
+ Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
248
+ };
249
+ Value::String(s.into())
250
+ }
251
+
252
+ #[cfg(test)]
253
+ mod tests {
254
+ use super::*;
255
+
256
+ fn buf_with(data: &[u8], eof: bool) -> SharedBuf {
257
+ let b = new_buf();
258
+ {
259
+ let mut g = b.0.lock().unwrap();
260
+ g.data.extend_from_slice(data);
261
+ g.eof = eof;
262
+ }
263
+ b
264
+ }
265
+
266
+ fn drain_str(buf: &SharedBuf) -> String {
267
+ match drain_stream(buf, 0) {
268
+ Value::String(s) => s.to_string(),
269
+ other => panic!("expected string, got {:?}", other),
270
+ }
271
+ }
272
+
273
+ #[test]
274
+ fn invalid_byte_mid_stream_is_replaced_not_wedged() {
275
+ // Pre-fix: valid_up_to()==0 for a leading invalid byte returned "" forever.
276
+ let b = buf_with(b"\xffhello", false);
277
+ assert_eq!(drain_str(&b), "\u{fffd}hello");
278
+ assert!(b.0.lock().unwrap().data.is_empty());
279
+ }
280
+
281
+ #[test]
282
+ fn invalid_bytes_between_valid_text_drain_through() {
283
+ let b = buf_with(b"ok\xff\xfemore", false);
284
+ assert_eq!(drain_str(&b), "ok\u{fffd}\u{fffd}more");
285
+ }
286
+
287
+ #[test]
288
+ fn incomplete_tail_is_held_then_completed() {
289
+ // "ab" + first 2 bytes of U+20AC (€ = E2 82 AC).
290
+ let b = buf_with(b"ab\xe2\x82", false);
291
+ assert_eq!(drain_str(&b), "ab");
292
+ assert_eq!(b.0.lock().unwrap().data.len(), 2);
293
+ // Tail alone: still held, drain returns "" (live).
294
+ assert_eq!(drain_str(&b), "");
295
+ // The final byte arrives: the whole code point drains.
296
+ b.0.lock().unwrap().data.push(0xac);
297
+ assert_eq!(drain_str(&b), "\u{20ac}");
298
+ }
299
+
300
+ #[test]
301
+ fn incomplete_tail_flushes_lossily_at_eof() {
302
+ let b = buf_with(b"\xe2\x82", true);
303
+ assert_eq!(drain_str(&b), "\u{fffd}");
304
+ assert!(matches!(drain_stream(&b, 0), Value::Null));
305
+ }
306
+
307
+ #[test]
308
+ fn empty_buf_semantics_preserved() {
309
+ let live = buf_with(b"", false);
310
+ assert_eq!(drain_str(&live), "");
311
+ let done = buf_with(b"", true);
312
+ assert!(matches!(drain_stream(&done, 0), Value::Null));
313
+ }
314
+
315
+ #[test]
316
+ fn reader_parks_at_cap_and_resumes_after_drain() {
317
+ // io::repeat never blocks, so an uncapped reader would grow without bound instantly.
318
+ const CAP: usize = 16 * 1024;
319
+ let buf = new_buf();
320
+ let b2 = buf.clone();
321
+ let handle =
322
+ std::thread::spawn(move || reader_loop(std::io::repeat(b'x'), b2, CAP, CapMode::Park));
323
+
324
+ // Fills to the cap, then parks. Cap is approximate up to one 8 KiB chunk.
325
+ let deadline = std::time::Instant::now() + Duration::from_secs(5);
326
+ loop {
327
+ let len = buf.0.lock().unwrap().data.len();
328
+ if len >= CAP {
329
+ break;
330
+ }
331
+ assert!(
332
+ std::time::Instant::now() < deadline,
333
+ "reader never reached cap"
334
+ );
335
+ std::thread::sleep(Duration::from_millis(5));
336
+ }
337
+ std::thread::sleep(Duration::from_millis(50));
338
+ let len = buf.0.lock().unwrap().data.len();
339
+ assert!(len <= CAP + 8192, "buffer exceeded cap: {}", len);
340
+
341
+ // Draining makes room; the reader wakes and refills — still bounded.
342
+ let drained = drain_str(&buf);
343
+ assert!(!drained.is_empty());
344
+ let deadline = std::time::Instant::now() + Duration::from_secs(5);
345
+ loop {
346
+ let len = buf.0.lock().unwrap().data.len();
347
+ if len >= CAP {
348
+ break;
349
+ }
350
+ assert!(
351
+ std::time::Instant::now() < deadline,
352
+ "reader did not resume after drain"
353
+ );
354
+ std::thread::sleep(Duration::from_millis(5));
355
+ }
356
+ let len = buf.0.lock().unwrap().data.len();
357
+ assert!(
358
+ len <= CAP + 8192,
359
+ "buffer exceeded cap after refill: {}",
360
+ len
361
+ );
362
+
363
+ // Retirement wakes the parked reader so the thread exits (kill/close path).
364
+ retire_buf(&buf);
365
+ handle
366
+ .join()
367
+ .expect("reader thread did not exit after retire");
368
+ }
369
+
370
+ #[test]
371
+ fn drain_shrinks_high_water_capacity() {
372
+ let b = new_buf();
373
+ {
374
+ let mut g = b.0.lock().unwrap();
375
+ g.data = Vec::with_capacity(4 << 20);
376
+ g.data.extend_from_slice(&vec![b'a'; 4 << 20]);
377
+ }
378
+ let s = drain_str(&b);
379
+ assert_eq!(s.len(), 4 << 20);
380
+ let g = b.0.lock().unwrap();
381
+ assert!(
382
+ g.data.capacity() <= 64 * 1024,
383
+ "capacity not released: {}",
384
+ g.data.capacity()
385
+ );
386
+ }
387
+
388
+ /// DropOldest (side channels): the reader never parks; at the cap the OLDEST bytes are
389
+ /// discarded so the newest survive — a child spewing stderr can neither grow the buffer
390
+ /// past the cap nor be write-blocked by us.
391
+ #[test]
392
+ fn side_reader_drops_oldest_and_never_parks() {
393
+ const CAP: usize = 32 * 1024;
394
+ let b = new_buf();
395
+ let b2 = b.clone();
396
+ // 1 MiB of 'a' then a tail of 'z': far past the cap; a parking reader would hang here.
397
+ let mut data = vec![b'a'; 1024 * 1024];
398
+ data.extend_from_slice(&[b'z'; 128]);
399
+ let handle = std::thread::spawn(move || {
400
+ reader_loop(std::io::Cursor::new(data), b2, CAP, CapMode::DropOldest)
401
+ });
402
+ handle
403
+ .join()
404
+ .expect("side reader must run to EOF without parking");
405
+ let (lock, _) = &*b;
406
+ let g = lock.lock().unwrap();
407
+ assert!(g.eof);
408
+ assert!(
409
+ g.data.len() <= CAP + 8192,
410
+ "cap not enforced: {}",
411
+ g.data.len()
412
+ );
413
+ assert_eq!(
414
+ &g.data[g.data.len() - 128..],
415
+ &[b'z'; 128][..],
416
+ "newest bytes must survive"
417
+ );
418
+ }
419
+ }