@tishlang/tish-format 2.37.3 → 2.39.0

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.
package/bin/tish-format CHANGED
Binary file
@@ -1702,6 +1702,7 @@ impl Codegen {
1702
1702
  "Server" => Some("Value::native(|args: &[Value]| tish_ws_server_construct(args))"),
1703
1703
  "wsSend" => Some("Value::native(|args: &[Value]| Value::Bool(tishlang_runtime::ws_send_native(args.first().unwrap_or(&Value::Null), &args.get(1).map(|v| v.to_display_string()).unwrap_or_default())))"),
1704
1704
  "wsBroadcast" => Some("Value::native(|args: &[Value]| tishlang_runtime::ws_broadcast_native(args))"),
1705
+ "wsAccept" => Some("Value::native(|args: &[Value]| tishlang_runtime::ws_serve_accept(args))"),
1705
1706
  _ => None,
1706
1707
  },
1707
1708
  "tish:tty" if self.has_feature("tty") => match export_name {
@@ -1714,6 +1715,15 @@ impl Codegen {
1714
1715
  "readLine" => Some("Value::native(|args: &[Value]| tishlang_runtime::tty_read_line(args))"),
1715
1716
  _ => None,
1716
1717
  },
1718
+ "tish:pty" if self.has_feature("pty") => match export_name {
1719
+ "spawn" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_spawn(args))"),
1720
+ "read" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_read(args))"),
1721
+ "write" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_write(args))"),
1722
+ "resize" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_resize(args))"),
1723
+ "kill" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_kill(args))"),
1724
+ "pid" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_pid(args))"),
1725
+ _ => None,
1726
+ },
1717
1727
  _ => return None,
1718
1728
  };
1719
1729
  init.map(String::from)
@@ -1723,7 +1733,7 @@ impl Codegen {
1723
1733
  if self.features.contains("full") {
1724
1734
  matches!(
1725
1735
  name,
1726
- "http" | "timers" | "fs" | "process" | "regex" | "ws" | "tty"
1736
+ "http" | "timers" | "fs" | "process" | "regex" | "ws" | "tty" | "pty"
1727
1737
  )
1728
1738
  } else {
1729
1739
  self.features.contains(name)
@@ -2259,6 +2269,10 @@ impl Codegen {
2259
2269
  self.write("use tishlang_runtime::register_static_route as tish_register_static_route;\n");
2260
2270
  if self.is_async {
2261
2271
  self.write("use tishlang_runtime::{fetch_promise as tish_fetch_promise, fetch_all_promise as tish_fetch_all_promise, http_serve as tish_http_serve, promise_object as tish_promise_object, await_promise as tish_await_promise, await_promise_throw as tish_await_promise_throw};\n");
2272
+ } else if self.has_feature("ws") {
2273
+ // ws programs get `Promise.spawn` (send-values OS-thread spawn) even when not async,
2274
+ // so a server can run a background accept/pump loop alongside the blocking `serve()`.
2275
+ self.write("use tishlang_runtime::{fetch_promise as tish_fetch_promise, fetch_all_promise as tish_fetch_all_promise, http_serve as tish_http_serve, promise_object as tish_promise_object};\n");
2262
2276
  } else {
2263
2277
  self.write("use tishlang_runtime::{fetch_promise as tish_fetch_promise, fetch_all_promise as tish_fetch_all_promise, http_serve as tish_http_serve};\n");
2264
2278
  }
@@ -2684,7 +2698,7 @@ impl Codegen {
2684
2698
  "let fetch = Value::native(|args: &[Value]| tish_fetch_promise(args.to_vec()));",
2685
2699
  );
2686
2700
  self.writeln("let fetchAll = Value::native(|args: &[Value]| tish_fetch_all_promise(args.to_vec()));");
2687
- if self.is_async {
2701
+ if self.is_async || self.has_feature("ws") {
2688
2702
  self.writeln("let Promise = tish_promise_object();");
2689
2703
  }
2690
2704
  // `serve` supports two shapes:
@@ -59,6 +59,7 @@ const BUILTIN_ALIASES: &[(&str, &str)] = &[
59
59
  ("process", "tish:process"),
60
60
  ("ws", "tish:ws"),
61
61
  ("tty", "tish:tty"),
62
+ ("pty", "tish:pty"),
62
63
  ];
63
64
 
64
65
  /// Normalize built-in spec to canonical form. Handles the Node `node:` prefix
@@ -87,9 +88,10 @@ pub fn is_builtin_native_spec(spec: &str) -> bool {
87
88
  | "tish:process"
88
89
  | "tish:ws"
89
90
  | "tish:tty"
91
+ | "tish:pty"
90
92
  ) || matches!(
91
93
  spec,
92
- "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty"
94
+ "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "pty"
93
95
  )
94
96
  }
95
97
 
@@ -453,9 +453,13 @@ impl RustType {
453
453
  )
454
454
  }
455
455
  RustType::Option(inner) => {
456
- let inner_to_value = inner.to_value_expr("v");
456
+ // Box by REFERENCE so the source Option is NOT moved — it may be a local captured by
457
+ // an FnMut closure (a `serve()` handler, called per request) or used again after this
458
+ // conversion. Mirrors the Vec arm's `.iter()` (which likewise avoids moving). `(*v)`
459
+ // derefs the `&inner` the ref-match binds, so the inner's own clone/copy still applies.
460
+ let inner_to_value = inner.to_value_expr("(*v)");
457
461
  format!(
458
- "match {} {{ Some(v) => {}, None => Value::Null }}",
462
+ "match &({}) {{ Some(v) => {}, None => Value::Null }}",
459
463
  native_expr, inner_to_value
460
464
  )
461
465
  }
@@ -0,0 +1,22 @@
1
+ //! tishlang/tish — a typed Option (`string?`) captured by a long-lived FnMut closure (a `serve()`
2
+ //! handler, called per request) must not be MOVED when boxed to a Value for a `=== null` comparison.
3
+ //! `to_value_expr(Option)` must match by REFERENCE (`match &(…)`), like the Vec arm's `.iter()`.
4
+ use std::path::PathBuf;
5
+ use tishlang_compile::compile_project_full;
6
+ fn compile(rel: &str) -> String {
7
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
8
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
9
+ compile_project_full(&path, path.parent(), &[], true).unwrap().0
10
+ }
11
+ #[test]
12
+ fn option_boxing_borrows_not_moves() {
13
+ let rust = compile("tests/regression/serve_closure_captures_typed.tish");
14
+ assert!(
15
+ rust.contains("match &(sToken)"),
16
+ "Option→Value boxing must match by reference so the source isn't moved"
17
+ );
18
+ assert!(
19
+ !rust.contains("match sToken { Some(v)"),
20
+ "Option→Value boxing must not match by value (moves the closure-captured Option)"
21
+ );
22
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_fmt"
3
- version = "2.37.3"
3
+ version = "2.39.0"
4
4
  edition = "2021"
5
5
  description = "Opinionated formatter for Tish source (parse → pretty-print)"
6
6
  license-file = { workspace = true }
@@ -17,6 +17,7 @@ const RUNTIME_CARGO_FEATURES: &[&str] = &[
17
17
  "regex",
18
18
  "ws",
19
19
  "tty",
20
+ "pty",
20
21
  ];
21
22
 
22
23
  /// Map CLI/compile features to flags passed to `tishlang_runtime` in the temp crate's Cargo.toml.
@@ -58,6 +58,9 @@ fs = ["promise", "dep:rand"]
58
58
  process = []
59
59
  # Interactive terminal I/O (issue #101): raw mode, key/resize events, size, alt screen.
60
60
  tty = ["dep:crossterm"]
61
+ # Pseudoterminal hosting (issue #493): spawn a shell/program on a real PTY, read/write/resize/kill —
62
+ # so an interactive terminal emulator on the other end of a socket behaves like a real TTY.
63
+ pty = ["dep:portable-pty", "dep:lazy_static"]
61
64
  regex = ["tishlang_core/regex"]
62
65
  # WebSocket (JS-like API). Modular: import { WebSocket, Server } from 'tish:ws'.
63
66
  # Requires `send-values` because the WS server keeps an `Arc<dyn Fn + Send +
@@ -90,6 +93,8 @@ libc = { version = "0.2", optional = true }
90
93
  arc-swap = { version = "1", optional = true }
91
94
  # Interactive terminal I/O (feature = tty): cross-platform raw mode, key/resize events, size.
92
95
  crossterm = { version = "0.28", optional = true }
96
+ # Pseudoterminal hosting (feature = pty): cross-platform openpty + shell spawn (same crate Dune uses).
97
+ portable-pty = { version = "0.9", optional = true }
93
98
  # hyper backend (feature = http-hyper)
94
99
  hyper = { version = "1", default-features = false, features = ["server", "http1", "http2"], optional = true }
95
100
  hyper-util = { version = "0.1", features = ["server", "tokio"], optional = true }
@@ -289,6 +289,9 @@ fn worker_thread(
289
289
  // headers slower than this instead of pinning the worker task.
290
290
  .header_read_timeout(std::time::Duration::from_secs(30))
291
291
  .serve_connection(io, svc)
292
+ // Required for `hyper::upgrade::on` to hand off the socket after a 101 — without
293
+ // this, a WebSocket upgrade sends the 101 but the upgrade future never resolves.
294
+ .with_upgrades()
292
295
  .await
293
296
  {
294
297
  // Chatty connection errors (resets, client timeouts) are
@@ -314,6 +317,13 @@ async fn handle_request(
314
317
  return Ok(static_route_response(route));
315
318
  }
316
319
 
320
+ // WebSocket upgrade: complete the handshake here and hand the socket to `ws.rs` — the VM handler
321
+ // never sees it. The program picks the connection up via `wsAccept`. (Single-port WS, issue #495.)
322
+ #[cfg(feature = "ws")]
323
+ if is_ws_upgrade(&req) {
324
+ return Ok(handle_ws_upgrade(req));
325
+ }
326
+
317
327
  // Slow path: cross the mpsc to the VM thread.
318
328
  let (method, url, path, query, headers, body_bytes) = extract_request(req).await;
319
329
  // Lossy UTF-8 decode to match the tiny_http path (`http.rs`): a body with invalid UTF-8 becomes
@@ -455,6 +465,66 @@ fn simple_error_response(status: StatusCode, msg: &str) -> Response<Full<Bytes>>
455
465
  .unwrap()
456
466
  }
457
467
 
468
+ /// A well-formed WebSocket upgrade handshake request (RFC 6455): `Upgrade: websocket`,
469
+ /// `Connection: Upgrade`, and a `Sec-WebSocket-Key`.
470
+ #[cfg(feature = "ws")]
471
+ fn is_ws_upgrade(req: &Request<hyper::body::Incoming>) -> bool {
472
+ let h = req.headers();
473
+ let upgrade_ws = h
474
+ .get(hyper::header::UPGRADE)
475
+ .and_then(|v| v.to_str().ok())
476
+ .map(|s| s.eq_ignore_ascii_case("websocket"))
477
+ .unwrap_or(false);
478
+ let conn_upgrade = h
479
+ .get(hyper::header::CONNECTION)
480
+ .and_then(|v| v.to_str().ok())
481
+ .map(|s| s.to_ascii_lowercase().split(',').any(|p| p.trim() == "upgrade"))
482
+ .unwrap_or(false);
483
+ upgrade_ws && conn_upgrade && h.contains_key(hyper::header::SEC_WEBSOCKET_KEY)
484
+ }
485
+
486
+ /// Complete a WebSocket handshake on the HTTP port: reply `101` with the derived accept key, then
487
+ /// (after the response flushes) take over the connection, wrap it as a server-role WebSocket, and
488
+ /// register it with `ws.rs` so the program can pick it up via `wsAccept`.
489
+ #[cfg(feature = "ws")]
490
+ fn handle_ws_upgrade(mut req: Request<hyper::body::Incoming>) -> Response<Full<Bytes>> {
491
+ use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
492
+ use tokio_tungstenite::tungstenite::protocol::Role;
493
+
494
+ let accept = match req.headers().get(hyper::header::SEC_WEBSOCKET_KEY) {
495
+ Some(k) => derive_accept_key(k.as_bytes()),
496
+ None => return simple_error_response(StatusCode::BAD_REQUEST, "missing Sec-WebSocket-Key"),
497
+ };
498
+
499
+ let on_upgrade = hyper::upgrade::on(&mut req);
500
+ tokio::spawn(async move {
501
+ match on_upgrade.await {
502
+ Ok(upgraded) => {
503
+ let io = TokioIo::new(upgraded);
504
+ let ws = tokio_tungstenite::WebSocketStream::from_raw_socket(io, Role::Server, None)
505
+ .await;
506
+ let id = crate::ws::register_ws_stream(ws);
507
+ crate::ws::enqueue_upgraded_conn(id);
508
+ }
509
+ Err(e) => {
510
+ if std::env::var_os("TISH_HTTP_DEBUG").is_some() {
511
+ eprintln!("[tish http/hyper] ws upgrade failed: {}", e);
512
+ }
513
+ }
514
+ }
515
+ });
516
+
517
+ Response::builder()
518
+ .status(StatusCode::SWITCHING_PROTOCOLS)
519
+ .header(hyper::header::UPGRADE, "websocket")
520
+ .header(hyper::header::CONNECTION, "Upgrade")
521
+ .header(hyper::header::SEC_WEBSOCKET_ACCEPT, accept)
522
+ .body(Full::new(Bytes::new()))
523
+ .unwrap_or_else(|_| {
524
+ simple_error_response(StatusCode::INTERNAL_SERVER_ERROR, "ws upgrade build failed")
525
+ })
526
+ }
527
+
458
528
  /// Marker used by external callers (like the bench) to gate the backend
459
529
  /// choice. Returns true when `TISH_HTTP_BACKEND=hyper` is in the env.
460
530
  pub fn is_enabled_via_env() -> bool {
@@ -1727,10 +1727,15 @@ pub use tty::{
1727
1727
  tty_enter_alt_screen, tty_is_tty, tty_leave_alt_screen, tty_read, tty_read_line, tty_set_raw_mode, tty_size,
1728
1728
  };
1729
1729
 
1730
+ #[cfg(feature = "pty")]
1731
+ pub mod pty;
1732
+ #[cfg(feature = "pty")]
1733
+ pub use pty::{pty_kill, pty_pid, pty_read, pty_resize, pty_spawn, pty_write};
1734
+
1730
1735
  #[cfg(feature = "ws")]
1731
1736
  pub use ws::{
1732
1737
  web_socket_client, web_socket_server_accept, web_socket_server_construct,
1733
- web_socket_server_listen, ws_broadcast_native, ws_send_native,
1738
+ web_socket_server_listen, ws_broadcast_native, ws_send_native, ws_serve_accept,
1734
1739
  };
1735
1740
 
1736
1741
  #[cfg(feature = "http")]
@@ -0,0 +1,451 @@
1
+ //! Pseudoterminal (PTY) module for Tish (`tish:pty`), behind the `pty` feature.
2
+ //!
3
+ //! Spawns a real OS pseudoterminal with a live shell/program attached — so an interactive
4
+ //! terminal emulator (xterm.js) on the other end of a socket behaves like a real TTY:
5
+ //! `isatty()` passes, so line-editing and curses apps (vim, top, ssh, tab-completion, and
6
+ //! `SIGWINCH` resize) all work. This is what `tish:process` (run-to-completion capture) and
7
+ //! `tish:tty` (this process's OWN controlling terminal) cannot do. Imported as
8
+ //! `import { spawn, read, write, resize, kill } from 'tish:pty'`.
9
+ //!
10
+ //! Polling model (transport-agnostic — pairs with a `tish:ws` pump or an HTTP long-poll):
11
+ //! - `spawn({ program?, cwd?, cols?, rows?, env? }) -> id | null`
12
+ //! - `read(id, timeoutMs?) -> string | null` (`""` = live but no output yet; `null` = EOF/unknown)
13
+ //! - `write(id, data) -> bool`
14
+ //! - `resize(id, cols, rows) -> bool`
15
+ //! - `kill(id) -> bool`
16
+ //! - `pid(id) -> number | null`
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
20
+ //! `Mutex<HashMap<id, PtySession>>` registry mirrors `ws.rs`'s `CONNS`. Errors surface as
21
+ //! `null`/`false` rather than panicking.
22
+
23
+ use std::collections::HashMap;
24
+ use std::io::{Read, Write};
25
+ use std::sync::atomic::{AtomicU64, Ordering};
26
+ use std::sync::{Arc, Condvar, Mutex};
27
+ use std::time::Duration;
28
+
29
+ use lazy_static::lazy_static;
30
+ use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
31
+ use tishlang_core::Value;
32
+
33
+ /// Output accumulated by the reader thread, drained by `read`.
34
+ struct PtyBuf {
35
+ data: Vec<u8>,
36
+ eof: bool,
37
+ }
38
+
39
+ struct PtySession {
40
+ child: Mutex<Box<dyn Child + Send + Sync>>,
41
+ master: Mutex<Box<dyn MasterPty + Send>>,
42
+ writer: Mutex<Box<dyn Write + Send>>,
43
+ buf: Arc<(Mutex<PtyBuf>, Condvar)>,
44
+ pid: Option<u32>,
45
+ }
46
+
47
+ lazy_static! {
48
+ static ref SESSIONS: Mutex<HashMap<u64, PtySession>> = Mutex::new(HashMap::new());
49
+ }
50
+
51
+ static NEXT_ID: AtomicU64 = AtomicU64::new(1);
52
+
53
+ // --- Value helpers (mirror the ws.rs idioms) ---
54
+
55
+ fn arg_u64(args: &[Value], i: usize) -> Option<u64> {
56
+ match args.get(i) {
57
+ Some(Value::Number(n)) if n.is_finite() && *n >= 0.0 => Some(*n as u64),
58
+ _ => None,
59
+ }
60
+ }
61
+
62
+ fn arg_u16(args: &[Value], i: usize) -> Option<u16> {
63
+ match args.get(i) {
64
+ Some(Value::Number(n)) if n.is_finite() && *n >= 0.0 => Some(*n as u16),
65
+ _ => None,
66
+ }
67
+ }
68
+
69
+ /// Clone a field out of an options object (`{ key: value }`), or `None` if the arg isn't an object.
70
+ fn obj_field(o: &Value, key: &str) -> Option<Value> {
71
+ if let Value::Object(m) = o {
72
+ return m.borrow().strings.get(key).cloned();
73
+ }
74
+ None
75
+ }
76
+
77
+ fn obj_str(o: &Value, key: &str) -> Option<String> {
78
+ match obj_field(o, key) {
79
+ Some(Value::String(s)) => Some(s.to_string()),
80
+ Some(Value::Null) | None => None,
81
+ Some(v) => Some(v.to_display_string()),
82
+ }
83
+ }
84
+
85
+ fn obj_num(o: &Value, key: &str) -> Option<f64> {
86
+ match obj_field(o, key) {
87
+ Some(Value::Number(n)) if n.is_finite() => Some(n),
88
+ _ => None,
89
+ }
90
+ }
91
+
92
+ /// `spawn({ program?, cwd?, cols?, rows?, env? })` → a stable id for read/write/resize/kill,
93
+ /// or `null` on failure.
94
+ pub fn pty_spawn(args: &[Value]) -> Value {
95
+ let null = Value::Null;
96
+ let opts = args.first().unwrap_or(&null);
97
+
98
+ let cols = obj_num(opts, "cols").filter(|n| *n > 0.0).unwrap_or(80.0) as u16;
99
+ let rows = obj_num(opts, "rows").filter(|n| *n > 0.0).unwrap_or(24.0) as u16;
100
+
101
+ let pty_system = native_pty_system();
102
+ let pair = match pty_system.openpty(PtySize {
103
+ rows,
104
+ cols,
105
+ pixel_width: 0,
106
+ pixel_height: 0,
107
+ }) {
108
+ Ok(p) => p,
109
+ Err(_) => return Value::Null,
110
+ };
111
+
112
+ let mut cmd = if let Some(prog) = obj_str(opts, "program") {
113
+ CommandBuilder::new(prog)
114
+ } else if cfg!(windows) {
115
+ CommandBuilder::new("powershell.exe")
116
+ } else {
117
+ let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
118
+ CommandBuilder::new(shell)
119
+ };
120
+
121
+ if let Some(cwd) = obj_str(opts, "cwd") {
122
+ cmd.cwd(cwd);
123
+ }
124
+
125
+ // Caller-supplied env overrides inherit the parent environment (portable_pty default); we
126
+ // only add. Ensure TERM is set so curses apps negotiate correctly unless overridden.
127
+ let mut has_term = false;
128
+ if let Some(Value::Object(em)) = obj_field(opts, "env") {
129
+ for (k, v) in em.borrow().strings.iter() {
130
+ if k.as_ref() == "TERM" {
131
+ has_term = true;
132
+ }
133
+ cmd.env(k.as_ref(), v.to_display_string());
134
+ }
135
+ }
136
+ if !has_term {
137
+ cmd.env("TERM", "xterm-256color");
138
+ }
139
+
140
+ let child = match pair.slave.spawn_command(cmd) {
141
+ Ok(c) => c,
142
+ Err(_) => return Value::Null,
143
+ };
144
+ let reader = match pair.master.try_clone_reader() {
145
+ Ok(r) => r,
146
+ Err(_) => return Value::Null,
147
+ };
148
+ let writer = match pair.master.take_writer() {
149
+ Ok(w) => w,
150
+ Err(_) => return Value::Null,
151
+ };
152
+ let pid = child.process_id();
153
+
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
+ }
198
+
199
+ let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
200
+ let session = PtySession {
201
+ child: Mutex::new(child),
202
+ master: Mutex::new(pair.master),
203
+ writer: Mutex::new(writer),
204
+ buf,
205
+ pid,
206
+ };
207
+ match SESSIONS.lock() {
208
+ Ok(mut g) => {
209
+ g.insert(id, session);
210
+ }
211
+ Err(_) => return Value::Null,
212
+ }
213
+ Value::Number(id as f64)
214
+ }
215
+
216
+ /// `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.
219
+ pub fn pty_read(args: &[Value]) -> Value {
220
+ let id = match arg_u64(args, 0) {
221
+ Some(x) => x,
222
+ None => return Value::Null,
223
+ };
224
+ let timeout_ms = match args.get(1) {
225
+ Some(Value::Number(n)) if n.is_finite() && *n >= 0.0 => *n as u64,
226
+ _ => 0,
227
+ };
228
+
229
+ let buf = {
230
+ let g = match SESSIONS.lock() {
231
+ Ok(g) => g,
232
+ Err(_) => return Value::Null,
233
+ };
234
+ match g.get(&id) {
235
+ Some(s) => s.buf.clone(),
236
+ None => return Value::Null,
237
+ }
238
+ };
239
+
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());
276
+ }
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())
280
+ }
281
+
282
+ /// `write(id, data)` → feed input bytes to the PTY. Returns whether the write succeeded.
283
+ pub fn pty_write(args: &[Value]) -> Value {
284
+ let id = match arg_u64(args, 0) {
285
+ Some(x) => x,
286
+ None => return Value::Bool(false),
287
+ };
288
+ let data = match args.get(1) {
289
+ Some(Value::String(s)) => s.to_string(),
290
+ Some(v) => v.to_display_string(),
291
+ None => return Value::Bool(false),
292
+ };
293
+ let g = match SESSIONS.lock() {
294
+ Ok(g) => g,
295
+ Err(_) => return Value::Bool(false),
296
+ };
297
+ let s = match g.get(&id) {
298
+ Some(s) => s,
299
+ None => return Value::Bool(false),
300
+ };
301
+ let mut w = match s.writer.lock() {
302
+ Ok(w) => w,
303
+ Err(_) => return Value::Bool(false),
304
+ };
305
+ match w.write_all(data.as_bytes()).and_then(|_| w.flush()) {
306
+ Ok(_) => Value::Bool(true),
307
+ Err(_) => Value::Bool(false),
308
+ }
309
+ }
310
+
311
+ /// `resize(id, cols, rows)` → tell the PTY its new window size (fires `SIGWINCH` in the child).
312
+ pub fn pty_resize(args: &[Value]) -> Value {
313
+ let id = match arg_u64(args, 0) {
314
+ Some(x) => x,
315
+ None => return Value::Bool(false),
316
+ };
317
+ let cols = match arg_u16(args, 1) {
318
+ Some(c) if c > 0 => c,
319
+ _ => return Value::Bool(false),
320
+ };
321
+ let rows = match arg_u16(args, 2) {
322
+ Some(r) if r > 0 => r,
323
+ _ => return Value::Bool(false),
324
+ };
325
+ let g = match SESSIONS.lock() {
326
+ Ok(g) => g,
327
+ Err(_) => return Value::Bool(false),
328
+ };
329
+ let s = match g.get(&id) {
330
+ Some(s) => s,
331
+ None => return Value::Bool(false),
332
+ };
333
+ let m = match s.master.lock() {
334
+ Ok(m) => m,
335
+ Err(_) => return Value::Bool(false),
336
+ };
337
+ match m.resize(PtySize {
338
+ rows,
339
+ cols,
340
+ pixel_width: 0,
341
+ pixel_height: 0,
342
+ }) {
343
+ Ok(_) => Value::Bool(true),
344
+ Err(_) => Value::Bool(false),
345
+ }
346
+ }
347
+
348
+ /// `kill(id)` → terminate the child and drop the session. Returns whether the id was live.
349
+ pub fn pty_kill(args: &[Value]) -> Value {
350
+ let id = match arg_u64(args, 0) {
351
+ Some(x) => x,
352
+ None => return Value::Bool(false),
353
+ };
354
+ let sess = {
355
+ let mut g = match SESSIONS.lock() {
356
+ Ok(g) => g,
357
+ Err(_) => return Value::Bool(false),
358
+ };
359
+ g.remove(&id)
360
+ };
361
+ match sess {
362
+ Some(s) => {
363
+ if let Ok(mut c) = s.child.lock() {
364
+ let _ = c.kill();
365
+ let _ = c.wait();
366
+ }
367
+ Value::Bool(true)
368
+ }
369
+ None => Value::Bool(false),
370
+ }
371
+ }
372
+
373
+ /// `pid(id)` → the child process id, or `null` for an unknown id / no pid.
374
+ pub fn pty_pid(args: &[Value]) -> Value {
375
+ let id = match arg_u64(args, 0) {
376
+ Some(x) => x,
377
+ None => return Value::Null,
378
+ };
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,
386
+ }
387
+ }
388
+
389
+ #[cfg(all(test, unix))]
390
+ mod tests {
391
+ use super::*;
392
+
393
+ /// Proves a LIVE interactive PTY (persistent shell on a pseudoterminal), not run-to-completion:
394
+ /// spawn a shell, write a command, and read its executed output back.
395
+ #[test]
396
+ fn spawn_write_read_roundtrip() {
397
+ let id = match pty_spawn(&[]) {
398
+ Value::Number(n) => n,
399
+ other => panic!("spawn failed: {:?}", other),
400
+ };
401
+ // Drain the shell's startup banner/prompt.
402
+ let _ = pty_read(&[Value::Number(id), Value::Number(300.0)]);
403
+ assert!(matches!(
404
+ pty_write(&[Value::Number(id), Value::String("echo pty_ok_123\n".into())]),
405
+ Value::Bool(true)
406
+ ));
407
+ let mut acc = String::new();
408
+ for _ in 0..100 {
409
+ match pty_read(&[Value::Number(id), Value::Number(50.0)]) {
410
+ Value::String(s) => acc.push_str(&s),
411
+ Value::Null => break,
412
+ _ => {}
413
+ }
414
+ if acc.contains("pty_ok_123") {
415
+ break;
416
+ }
417
+ }
418
+ let _ = pty_kill(&[Value::Number(id)]);
419
+ assert!(acc.contains("pty_ok_123"), "pty output missing echo: {:?}", acc);
420
+ }
421
+
422
+ #[test]
423
+ fn resize_live_session_ok() {
424
+ let id = match pty_spawn(&[]) {
425
+ Value::Number(n) => n,
426
+ other => panic!("spawn failed: {:?}", other),
427
+ };
428
+ assert!(matches!(
429
+ pty_resize(&[Value::Number(id), Value::Number(120.0), Value::Number(40.0)]),
430
+ Value::Bool(true)
431
+ ));
432
+ assert!(matches!(pty_pid(&[Value::Number(id)]), Value::Number(_)));
433
+ assert!(matches!(pty_kill(&[Value::Number(id)]), Value::Bool(true)));
434
+ }
435
+
436
+ #[test]
437
+ fn unknown_id_surfaces_null_or_false() {
438
+ let bad = 9_999_999.0;
439
+ assert!(matches!(pty_read(&[Value::Number(bad)]), Value::Null));
440
+ assert!(matches!(
441
+ pty_write(&[Value::Number(bad), Value::String("x".into())]),
442
+ Value::Bool(false)
443
+ ));
444
+ assert!(matches!(
445
+ pty_resize(&[Value::Number(bad), Value::Number(80.0), Value::Number(24.0)]),
446
+ Value::Bool(false)
447
+ ));
448
+ assert!(matches!(pty_kill(&[Value::Number(bad)]), Value::Bool(false)));
449
+ assert!(matches!(pty_pid(&[Value::Number(bad)]), Value::Null));
450
+ }
451
+ }
@@ -82,6 +82,83 @@ fn unregister(id: u32) {
82
82
  CONNS.lock().unwrap().remove(&id);
83
83
  }
84
84
 
85
+ /// Bridge an already-handshaked WebSocket stream into the `CONNS` registry — spawning the read and
86
+ /// write pump tasks — and return its connection id. Shared by the `tish:ws` server-accept loop and
87
+ /// the `serve()` HTTP→WS upgrade path (see `http_hyper.rs`). Must be called inside a tokio runtime.
88
+ pub(crate) fn register_ws_stream<S>(ws_stream: tokio_tungstenite::WebSocketStream<S>) -> u32
89
+ where
90
+ S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
91
+ {
92
+ let (send_tx, mut send_rx) = tokio_mpsc::unbounded_channel::<String>();
93
+ let (recv_tx, recv_rx) = mpsc::sync_channel::<String>(64);
94
+ let id = register(send_tx, recv_rx);
95
+ let recv_tx_task = Arc::new(recv_tx);
96
+ let (mut write, mut read) = ws_stream.split();
97
+ tokio::spawn(async move {
98
+ while let Some(Ok(msg)) = read.next().await {
99
+ if let tokio_tungstenite::tungstenite::Message::Text(t) = msg {
100
+ let _ = recv_tx_task.send(t.to_string());
101
+ }
102
+ }
103
+ unregister(id);
104
+ });
105
+ tokio::spawn(async move {
106
+ while let Some(text) = send_rx.recv().await {
107
+ let _ = write
108
+ .send(tokio_tungstenite::tungstenite::Message::Text(text.into()))
109
+ .await;
110
+ }
111
+ });
112
+ id
113
+ }
114
+
115
+ lazy_static! {
116
+ /// Connections upgraded from HTTP by `serve()` (http_hyper), waiting for the program to pick them
117
+ /// up via `wsAccept`. Per-process: an upgraded socket stays in whatever SO_REUSEPORT worker
118
+ /// accepted it, so each worker drains its own queue and there's no cross-process routing problem.
119
+ static ref UPGRADE_QUEUE: (Mutex<std::collections::VecDeque<u32>>, std::sync::Condvar) =
120
+ (Mutex::new(std::collections::VecDeque::new()), std::sync::Condvar::new());
121
+ }
122
+
123
+ /// Enqueue a `serve()`-upgraded connection id for `wsAccept` to hand to the program.
124
+ pub(crate) fn enqueue_upgraded_conn(id: u32) {
125
+ let (m, cv) = &*UPGRADE_QUEUE;
126
+ if let Ok(mut q) = m.lock() {
127
+ q.push_back(id);
128
+ }
129
+ cv.notify_all();
130
+ }
131
+
132
+ /// `wsAccept(timeoutMs?)` — return the next connection upgraded on the HTTP server's port (see the
133
+ /// `serve()` HTTP→WS upgrade path) as a normal `tish:ws` connection object, or `Null` if none arrives
134
+ /// within the timeout (default 0 = non-blocking).
135
+ pub fn ws_serve_accept(args: &[Value]) -> Value {
136
+ let timeout_ms = match args.first() {
137
+ Some(Value::Number(n)) if n.is_finite() && *n >= 0.0 => (*n as u64).min(3_600_000),
138
+ _ => 0,
139
+ };
140
+ let (m, cv) = &*UPGRADE_QUEUE;
141
+ let mut q = match m.lock() {
142
+ Ok(q) => q,
143
+ Err(_) => return Value::Null,
144
+ };
145
+ if q.is_empty() && timeout_ms > 0 {
146
+ let res = cv.wait_timeout_while(
147
+ q,
148
+ std::time::Duration::from_millis(timeout_ms),
149
+ |q| q.is_empty(),
150
+ );
151
+ q = match res {
152
+ Ok((g, _)) => g,
153
+ Err(e) => e.into_inner().0,
154
+ };
155
+ }
156
+ match q.pop_front() {
157
+ Some(id) => conn_object(id),
158
+ None => Value::Null,
159
+ }
160
+ }
161
+
85
162
  /// Max simultaneous WebSocket connections. Each accepted conn registers in `CONNS` and
86
163
  /// spawns tasks + channels, freed only on close — unbounded accepts exhaust tasks/memory.
87
164
  /// Override with `TISH_WS_MAX_CONNS`; default 10000.
@@ -399,27 +476,7 @@ pub fn web_socket_server_listen(args: &[Value]) -> Value {
399
476
  continue;
400
477
  }
401
478
  };
402
- let (send_tx, mut send_rx) = tokio_mpsc::unbounded_channel::<String>();
403
- let (recv_tx, recv_rx) = mpsc::sync_channel::<String>(64);
404
- let id = register(send_tx, recv_rx);
405
- let recv_tx = Arc::new(recv_tx);
406
- let recv_tx_task = Arc::clone(&recv_tx);
407
- let (mut write, mut read) = ws_stream.split();
408
- tokio::spawn(async move {
409
- while let Some(Ok(msg)) = read.next().await {
410
- if let tokio_tungstenite::tungstenite::Message::Text(t) = msg {
411
- let _ = recv_tx_task.send(t.to_string());
412
- }
413
- }
414
- unregister(id);
415
- });
416
- tokio::spawn(async move {
417
- while let Some(text) = send_rx.recv().await {
418
- let _ = write
419
- .send(tokio_tungstenite::tungstenite::Message::Text(text.into()))
420
- .await;
421
- }
422
- });
479
+ let id = register_ws_stream(ws_stream);
423
480
  if conn_tx.send(id).is_err() {
424
481
  break;
425
482
  }
@@ -578,6 +635,22 @@ mod tests {
578
635
  use std::thread;
579
636
  use std::time::Duration;
580
637
 
638
+ /// The serve() HTTP→WS upgrade queue: `wsAccept` hands out enqueued conns FIFO and returns Null
639
+ /// when empty. (End-to-end upgrade over a live hyper port is covered by the native smoke test.)
640
+ #[test]
641
+ fn upgrade_queue_drains_then_empty() {
642
+ assert!(matches!(ws_serve_accept(&[Value::Number(0.0)]), Value::Null));
643
+ enqueue_upgraded_conn(4242);
644
+ match ws_serve_accept(&[Value::Number(0.0)]) {
645
+ Value::Object(o) => {
646
+ let b = o.borrow();
647
+ assert!(matches!(b.strings.get("_id"), Some(Value::Number(n)) if *n == 4242.0));
648
+ }
649
+ other => panic!("expected a conn object, got {:?}", other),
650
+ }
651
+ assert!(matches!(ws_serve_accept(&[Value::Number(0.0)]), Value::Null));
652
+ }
653
+
581
654
  #[test]
582
655
  fn ws_echo_roundtrip() {
583
656
  let port: u16 = 18_742;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish-format",
3
- "version": "2.37.3",
3
+ "version": "2.39.0",
4
4
  "description": "Tish code formatter",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file