@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 +0 -0
- package/crates/tish_compile/src/codegen.rs +16 -2
- package/crates/tish_compile/src/resolve.rs +3 -1
- package/crates/tish_compile/src/types.rs +6 -2
- package/crates/tish_compile/tests/regr_serve_closure.rs +22 -0
- package/crates/tish_fmt/Cargo.toml +1 -1
- package/crates/tish_native/src/build.rs +1 -0
- package/crates/tish_runtime/Cargo.toml +5 -0
- package/crates/tish_runtime/src/http_hyper.rs +70 -0
- package/crates/tish_runtime/src/lib.rs +6 -1
- package/crates/tish_runtime/src/pty.rs +451 -0
- package/crates/tish_runtime/src/ws.rs +94 -21
- package/package.json +1 -1
- package/platform/darwin-arm64/tish-fmt +0 -0
- package/platform/darwin-x64/tish-fmt +0 -0
- package/platform/linux-arm64/tish-fmt +0 -0
- package/platform/linux-x64/tish-fmt +0 -0
- package/platform/win32-x64/tish-fmt.exe +0 -0
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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|