@tishlang/tish-format 3.10.4 → 3.10.11

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.
Files changed (44) hide show
  1. package/bin/tish-format +0 -0
  2. package/crates/tish/src/cargo_native_registry.rs +4 -0
  3. package/crates/tish/src/main.rs +65 -25
  4. package/crates/tish/tests/integration_test.rs +3 -0
  5. package/crates/tish_builtins/src/globals.rs +3 -3
  6. package/crates/tish_builtins/src/symbol.rs +86 -4
  7. package/crates/tish_bytecode/src/compiler.rs +65 -25
  8. package/crates/tish_bytecode/tests/captured_param_slot_binding.rs +131 -0
  9. package/crates/tish_bytecode/tests/fundecl_slot_binding.rs +115 -0
  10. package/crates/tish_bytecode/tests/param_defaults_slot_binding.rs +86 -0
  11. package/crates/tish_core/src/shape.rs +55 -2
  12. package/crates/tish_core/src/value.rs +229 -13
  13. package/crates/tish_eval/src/timers.rs +66 -2
  14. package/crates/tish_ffi/src/lib.rs +74 -12
  15. package/crates/tish_ffi/tests/loader.rs +35 -0
  16. package/crates/tish_fmt/Cargo.toml +1 -1
  17. package/crates/tish_lsp/src/main.rs +91 -2
  18. package/crates/tish_pg/src/lib.rs +59 -8
  19. package/crates/tish_pg/src/statement_registry.rs +250 -0
  20. package/crates/tish_runtime/src/http.rs +19 -0
  21. package/crates/tish_runtime/src/http_fetch.rs +159 -65
  22. package/crates/tish_runtime/src/http_hyper.rs +18 -2
  23. package/crates/tish_runtime/src/lib.rs +4 -0
  24. package/crates/tish_runtime/src/net.rs +125 -97
  25. package/crates/tish_runtime/src/process_spawn.rs +326 -113
  26. package/crates/tish_runtime/src/promise.rs +521 -90
  27. package/crates/tish_runtime/src/promise_io.rs +29 -6
  28. package/crates/tish_runtime/src/pty.rs +161 -107
  29. package/crates/tish_runtime/src/stream_buf.rs +419 -0
  30. package/crates/tish_runtime/src/ws.rs +431 -52
  31. package/crates/tish_vm/src/jit.rs +473 -67
  32. package/crates/tish_vm/src/lib.rs +6 -0
  33. package/crates/tish_vm/src/vm.rs +8 -5
  34. package/crates/tish_vm/tests/captured_param_slot_mode.rs +210 -0
  35. package/crates/tish_vm/tests/captured_param_vm_slots_off.rs +64 -0
  36. package/crates/tish_vm/tests/fundecl_slot_no_frame_cycle.rs +152 -0
  37. package/crates/tish_vm/tests/param_defaults_slot_mode.rs +126 -0
  38. package/crates/tish_vm/tests/param_defaults_vm_slots_off.rs +44 -0
  39. package/package.json +1 -1
  40. package/platform/darwin-arm64/tish-fmt +0 -0
  41. package/platform/darwin-x64/tish-fmt +0 -0
  42. package/platform/linux-arm64/tish-fmt +0 -0
  43. package/platform/linux-x64/tish-fmt +0 -0
  44. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -15,20 +15,43 @@ pub struct StringResultPromise {
15
15
  pub(crate) rx: Mutex<Option<oneshot::Receiver<Result<String, String>>>>,
16
16
  }
17
17
 
18
+ impl StringResultPromise {
19
+ fn convert(
20
+ r: Result<Result<String, String>, oneshot::error::RecvError>,
21
+ ) -> std::result::Result<Value, Value> {
22
+ match r {
23
+ Ok(Ok(s)) => Ok(Value::String(s.into())),
24
+ Ok(Err(e)) => Err(error_value(e)),
25
+ Err(_) => Err(Value::String("Promise dropped".into())),
26
+ }
27
+ }
28
+ }
29
+
18
30
  impl TishPromise for StringResultPromise {
19
31
  fn block_until_settled(&self) -> std::result::Result<Value, Value> {
20
32
  let rx = self.rx.lock().unwrap().take();
21
33
  if let Some(rx) = rx {
22
- let result = crate::http::block_on_http(rx);
23
- match result {
24
- Ok(Ok(s)) => Ok(Value::String(s.into())),
25
- Ok(Err(e)) => Err(error_value(e)),
26
- Err(_) => Err(Value::String("Promise dropped".into())),
27
- }
34
+ Self::convert(crate::http::block_on_http(rx))
28
35
  } else {
29
36
  Err(Value::String("Promise already consumed".into()))
30
37
  }
31
38
  }
39
+
40
+ /// Await the oneshot on the shared subscriber runtime instead of parking a
41
+ /// blocking thread (plus a per-call tokio runtime) per raced input (issue #702).
42
+ fn subscribe(
43
+ self: Arc<Self>,
44
+ on_settled: Box<dyn FnOnce(std::result::Result<Value, Value>) + Send>,
45
+ ) {
46
+ match self.rx.lock().unwrap().take() {
47
+ Some(rx) => {
48
+ crate::http::subscriber_runtime().spawn(async move {
49
+ on_settled(Self::convert(rx.await));
50
+ });
51
+ }
52
+ None => on_settled(Err(Value::String("Promise already consumed".into()))),
53
+ }
54
+ }
32
55
  }
33
56
 
34
57
  pub fn string_result_promise(rx: oneshot::Receiver<Result<String, String>>) -> Value {
@@ -15,37 +15,100 @@
15
15
  //! - `kill(id) -> bool`
16
16
  //! - `pid(id) -> number | null`
17
17
  //!
18
- //! A per-session reader thread fills a byte buffer that `read` drains at a UTF-8 boundary
19
- //! (incomplete trailing multibyte sequences are held for the next read — no mojibake). A global
18
+ //! A per-session reader thread fills a bounded byte buffer that `read` drains at a UTF-8
19
+ //! boundary (see `stream_buf.rs` for the cap/backpressure and invalid-byte semantics). A global
20
20
  //! `Mutex<HashMap<id, PtySession>>` registry mirrors `ws.rs`'s `CONNS`. Errors surface as
21
21
  //! `null`/`false` rather than panicking.
22
+ //!
23
+ //! Session lifecycle mirrors `process_spawn.rs`: an entry lives until `kill(id)` or until it is
24
+ //! reaped — child exited AND the buffer fully drained — by an opportunistic sweep on each
25
+ //! `spawn` (plus a check when `read` returns `null` at EOF). The pid is tombstoned (bounded) so
26
+ //! a late `pid(id)` still answers, and `kill(id)` on a reaped id still reports `true` once.
22
27
 
23
- use std::collections::HashMap;
24
- use std::io::{Read, Write};
28
+ use std::collections::{BTreeMap, HashMap};
29
+ use std::io::Write;
25
30
  use std::sync::atomic::{AtomicU64, Ordering};
26
- use std::sync::{Arc, Condvar, Mutex};
27
- use std::time::Duration;
31
+ use std::sync::Mutex;
28
32
 
29
33
  use lazy_static::lazy_static;
30
34
  use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
31
35
  use tishlang_core::Value;
32
36
 
33
- /// Output accumulated by the reader thread, drained by `read`.
34
- struct PtyBuf {
35
- data: Vec<u8>,
36
- eof: bool,
37
- }
37
+ use crate::stream_buf::{drain_stream, is_drained, new_buf, retire_buf, spawn_reader, SharedBuf};
38
38
 
39
39
  struct PtySession {
40
40
  child: Mutex<Box<dyn Child + Send + Sync>>,
41
41
  master: Mutex<Box<dyn MasterPty + Send>>,
42
42
  writer: Mutex<Box<dyn Write + Send>>,
43
- buf: Arc<(Mutex<PtyBuf>, Condvar)>,
43
+ buf: SharedBuf,
44
44
  pid: Option<u32>,
45
45
  }
46
46
 
47
47
  lazy_static! {
48
48
  static ref SESSIONS: Mutex<HashMap<u64, PtySession>> = Mutex::new(HashMap::new());
49
+ /// Pids of reaped sessions, so a late `pid(id)` still answers (bounded: ids are
50
+ /// monotonic, so evicting the smallest key evicts the oldest record).
51
+ static ref TOMBSTONES: Mutex<BTreeMap<u64, Option<u32>>> = Mutex::new(BTreeMap::new());
52
+ }
53
+
54
+ const MAX_TOMBSTONES: usize = 1024;
55
+
56
+ fn remember_tombstone(id: u64, pid: Option<u32>) {
57
+ if let Ok(mut m) = TOMBSTONES.lock() {
58
+ m.insert(id, pid);
59
+ while m.len() > MAX_TOMBSTONES {
60
+ m.pop_first();
61
+ }
62
+ }
63
+ }
64
+
65
+ /// Reap every session whose child has exited AND whose buffer is fully drained
66
+ /// (`try_wait` also reaps the OS zombie).
67
+ fn sweep_sessions() {
68
+ let mut reaped: Vec<(u64, Option<u32>)> = Vec::new();
69
+ {
70
+ let mut g = match SESSIONS.lock() {
71
+ Ok(g) => g,
72
+ Err(_) => return,
73
+ };
74
+ g.retain(|id, s| {
75
+ let exited = match s.child.lock() {
76
+ Ok(mut c) => c.try_wait().ok().flatten().is_some(),
77
+ Err(_) => false,
78
+ };
79
+ if exited && is_drained(&s.buf) {
80
+ reaped.push((*id, s.pid));
81
+ false
82
+ } else {
83
+ true
84
+ }
85
+ });
86
+ }
87
+ for (id, pid) in reaped {
88
+ remember_tombstone(id, pid);
89
+ }
90
+ }
91
+
92
+ /// Reap one session if (and only if) its child has exited and its buffer is drained.
93
+ fn try_reap(id: u64) {
94
+ let pid = {
95
+ let mut g = match SESSIONS.lock() {
96
+ Ok(g) => g,
97
+ Err(_) => return,
98
+ };
99
+ let Some(s) = g.get(&id) else { return };
100
+ let exited = match s.child.lock() {
101
+ Ok(mut c) => c.try_wait().ok().flatten().is_some(),
102
+ Err(_) => false,
103
+ };
104
+ if !(exited && is_drained(&s.buf)) {
105
+ return;
106
+ }
107
+ let pid = s.pid;
108
+ g.remove(&id);
109
+ pid
110
+ };
111
+ remember_tombstone(id, pid);
49
112
  }
50
113
 
51
114
  static NEXT_ID: AtomicU64 = AtomicU64::new(1);
@@ -92,6 +155,10 @@ fn obj_num(o: &Value, key: &str) -> Option<f64> {
92
155
  /// `spawn({ program?, cwd?, cols?, rows?, env? })` → a stable id for read/write/resize/kill,
93
156
  /// or `null` on failure.
94
157
  pub fn pty_spawn(args: &[Value]) -> Value {
158
+ // Opportunistic reap of exited-and-drained sessions (a shell the user `exit`ed and whose
159
+ // output was fully read), so un-killed self-exiting ptys cannot accumulate without bound.
160
+ sweep_sessions();
161
+
95
162
  let null = Value::Null;
96
163
  let opts = args.first().unwrap_or(&null);
97
164
 
@@ -151,50 +218,10 @@ pub fn pty_spawn(args: &[Value]) -> Value {
151
218
  };
152
219
  let pid = child.process_id();
153
220
 
154
- let buf = Arc::new((
155
- Mutex::new(PtyBuf {
156
- data: Vec::new(),
157
- eof: false,
158
- }),
159
- Condvar::new(),
160
- ));
161
-
162
- // Reader thread: block on the master, append bytes, wake any waiting `read`. On EOF/error,
163
- // mark eof so `read` can return null once the buffer drains.
164
- {
165
- let buf = buf.clone();
166
- let mut reader = reader;
167
- std::thread::spawn(move || {
168
- let mut tmp = [0u8; 8192];
169
- loop {
170
- match reader.read(&mut tmp) {
171
- Ok(0) => {
172
- let (lock, cv) = &*buf;
173
- if let Ok(mut b) = lock.lock() {
174
- b.eof = true;
175
- }
176
- cv.notify_all();
177
- break;
178
- }
179
- Ok(n) => {
180
- let (lock, cv) = &*buf;
181
- if let Ok(mut b) = lock.lock() {
182
- b.data.extend_from_slice(&tmp[..n]);
183
- }
184
- cv.notify_all();
185
- }
186
- Err(_) => {
187
- let (lock, cv) = &*buf;
188
- if let Ok(mut b) = lock.lock() {
189
- b.eof = true;
190
- }
191
- cv.notify_all();
192
- break;
193
- }
194
- }
195
- }
196
- });
197
- }
221
+ // Reader thread: block on the master, append bytes (bounded, see stream_buf.rs), wake any
222
+ // waiting `read`. On EOF/error, mark eof so `read` can return null once the buffer drains.
223
+ let buf = new_buf();
224
+ spawn_reader(reader, buf.clone());
198
225
 
199
226
  let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
200
227
  let session = PtySession {
@@ -214,8 +241,8 @@ pub fn pty_spawn(args: &[Value]) -> Value {
214
241
  }
215
242
 
216
243
  /// `read(id, timeoutMs?)` → available output as a string (possibly `""` if none arrived within the
217
- /// timeout), or `null` at EOF / for an unknown id. Drains only a valid UTF-8 prefix, holding any
218
- /// incomplete trailing multibyte bytes for the next call.
244
+ /// timeout), or `null` at EOF / for an unknown id. Drains only complete UTF-8, holding an
245
+ /// incomplete trailing multibyte sequence for the next call (invalid bytes become U+FFFD).
219
246
  pub fn pty_read(args: &[Value]) -> Value {
220
247
  let id = match arg_u64(args, 0) {
221
248
  Some(x) => x,
@@ -237,46 +264,13 @@ pub fn pty_read(args: &[Value]) -> Value {
237
264
  }
238
265
  };
239
266
 
240
- let (lock, cv) = &*buf;
241
- let mut b = match lock.lock() {
242
- Ok(b) => b,
243
- Err(_) => return Value::Null,
244
- };
245
- if b.data.is_empty() && !b.eof && timeout_ms > 0 {
246
- let res = cv.wait_timeout_while(b, Duration::from_millis(timeout_ms), |b| {
247
- b.data.is_empty() && !b.eof
248
- });
249
- b = match res {
250
- Ok((g, _)) => g,
251
- Err(e) => e.into_inner().0,
252
- };
253
- }
254
-
255
- if b.data.is_empty() {
256
- if b.eof {
257
- return Value::Null;
258
- }
259
- return Value::String("".into());
260
- }
261
-
262
- // Drain up to the last complete UTF-8 code point; keep the incomplete tail for next time.
263
- let valid = match std::str::from_utf8(&b.data) {
264
- Ok(_) => b.data.len(),
265
- Err(e) => e.valid_up_to(),
266
- };
267
- if valid == 0 {
268
- // A leading incomplete multibyte sequence. If the stream is done, flush it lossily so
269
- // nothing is stranded; otherwise wait for the rest.
270
- if b.eof {
271
- let s = String::from_utf8_lossy(&b.data).into_owned();
272
- b.data.clear();
273
- return Value::String(s.into());
274
- }
275
- return Value::String("".into());
267
+ let out = drain_stream(&buf, timeout_ms);
268
+ if matches!(out, Value::Null) {
269
+ // EOF fully drained: if the child has also exited, the session is fully consumed —
270
+ // reap it (a pid tombstone keeps `pid(id)` answering).
271
+ try_reap(id);
276
272
  }
277
- let out: Vec<u8> = b.data.drain(..valid).collect();
278
- let s = String::from_utf8(out).unwrap_or_default();
279
- Value::String(s.into())
273
+ out
280
274
  }
281
275
 
282
276
  /// `write(id, data)` → feed input bytes to the PTY. Returns whether the write succeeded.
@@ -345,7 +339,9 @@ pub fn pty_resize(args: &[Value]) -> Value {
345
339
  }
346
340
  }
347
341
 
348
- /// `kill(id)` → terminate the child and drop the session. Returns whether the id was live.
342
+ /// `kill(id)` → terminate the child and drop the session. Returns whether the id was live
343
+ /// (including a reaped id, whose child already exited — reported `true` once, like before
344
+ /// reaping existed).
349
345
  pub fn pty_kill(args: &[Value]) -> Value {
350
346
  let id = match arg_u64(args, 0) {
351
347
  Some(x) => x,
@@ -364,25 +360,46 @@ pub fn pty_kill(args: &[Value]) -> Value {
364
360
  let _ = c.kill();
365
361
  let _ = c.wait();
366
362
  }
363
+ // Wake a reader parked at the buffer cap so its thread exits (a read-blocked
364
+ // reader exits on its own once the killed child's pty closes).
365
+ retire_buf(&s.buf);
367
366
  Value::Bool(true)
368
367
  }
369
- None => Value::Bool(false),
368
+ None => {
369
+ // A reaped session was live from the caller's perspective; consume its tombstone.
370
+ match TOMBSTONES.lock() {
371
+ Ok(mut t) => Value::Bool(t.remove(&id).is_some()),
372
+ Err(_) => Value::Bool(false),
373
+ }
374
+ }
370
375
  }
371
376
  }
372
377
 
373
- /// `pid(id)` → the child process id, or `null` for an unknown id / no pid.
378
+ /// `pid(id)` → the child process id, or `null` for an unknown id / no pid. Still answers after
379
+ /// the session was reaped (the pid is tombstoned).
374
380
  pub fn pty_pid(args: &[Value]) -> Value {
375
381
  let id = match arg_u64(args, 0) {
376
382
  Some(x) => x,
377
383
  None => return Value::Null,
378
384
  };
379
- let g = match SESSIONS.lock() {
380
- Ok(g) => g,
381
- Err(_) => return Value::Null,
382
- };
383
- match g.get(&id).and_then(|s| s.pid) {
384
- Some(p) => Value::Number(p as f64),
385
- None => Value::Null,
385
+ {
386
+ let g = match SESSIONS.lock() {
387
+ Ok(g) => g,
388
+ Err(_) => return Value::Null,
389
+ };
390
+ if let Some(s) = g.get(&id) {
391
+ return match s.pid {
392
+ Some(p) => Value::Number(p as f64),
393
+ None => Value::Null,
394
+ };
395
+ }
396
+ }
397
+ match TOMBSTONES.lock() {
398
+ Ok(t) => match t.get(&id).copied().flatten() {
399
+ Some(p) => Value::Number(p as f64),
400
+ None => Value::Null,
401
+ },
402
+ Err(_) => Value::Null,
386
403
  }
387
404
  }
388
405
 
@@ -433,6 +450,43 @@ mod tests {
433
450
  assert!(matches!(pty_kill(&[Value::Number(id)]), Value::Bool(true)));
434
451
  }
435
452
 
453
+ #[test]
454
+ fn self_exited_session_is_reaped_after_drain_with_pid_tombstoned() {
455
+ // `true` exits immediately on its own — the un-killed lifecycle that used to strand
456
+ // the session (2 fds + an unreaped child) forever.
457
+ let mut m = tishlang_core::ObjectMap::default();
458
+ m.insert(
459
+ std::sync::Arc::from("program"),
460
+ Value::String("true".into()),
461
+ );
462
+ let id = match pty_spawn(&[Value::object(m)]) {
463
+ Value::Number(n) => n,
464
+ other => panic!("spawn failed: {:?}", other),
465
+ };
466
+ // Drain to EOF; the null read reaps the exited session.
467
+ let mut saw_eof = false;
468
+ for _ in 0..200 {
469
+ if matches!(
470
+ pty_read(&[Value::Number(id), Value::Number(50.0)]),
471
+ Value::Null
472
+ ) {
473
+ saw_eof = true;
474
+ break;
475
+ }
476
+ }
477
+ assert!(saw_eof, "pty never reached EOF");
478
+ assert!(
479
+ !SESSIONS.lock().unwrap().contains_key(&(id as u64)),
480
+ "session not reaped after exit + full drain"
481
+ );
482
+ // The pid still answers from the tombstone; kill reports the id as (once) live;
483
+ // reads keep returning null (the read-to-EOF-then-read-again contract).
484
+ assert!(matches!(pty_read(&[Value::Number(id)]), Value::Null));
485
+ assert!(matches!(pty_pid(&[Value::Number(id)]), Value::Number(_)));
486
+ assert!(matches!(pty_kill(&[Value::Number(id)]), Value::Bool(true)));
487
+ assert!(matches!(pty_kill(&[Value::Number(id)]), Value::Bool(false)));
488
+ }
489
+
436
490
  #[test]
437
491
  fn unknown_id_surfaces_null_or_false() {
438
492
  let bad = 9_999_999.0;