@tishlang/tish-lsp 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-lsp +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_lsp/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-lsp +0 -0
  41. package/platform/darwin-x64/tish-lsp +0 -0
  42. package/platform/linux-arm64/tish-lsp +0 -0
  43. package/platform/linux-x64/tish-lsp +0 -0
  44. package/platform/win32-x64/tish-lsp.exe +0 -0
@@ -1,13 +1,16 @@
1
1
  //! WebSocket module for Tish (tish:ws).
2
2
  //!
3
3
  //! Node.js `ws`-compatible API:
4
- //! - **Server**: `Server({ port })` — has `clients` (array), `on('connection', fn)`, `listen()`, `acceptTimeout(server, ms)`
5
- //! - **Connection**: `send(data)`, `close()`, `readyState` (1=OPEN), `receive()` / `receiveTimeout(ms)`
4
+ //! - **Server**: `Server({ port })` — has `clients` (array of LIVE conns; closed ones are pruned
5
+ //! lazily), `on('connection', fn)`, `listen()`, `acceptTimeout(server, ms)`, `close()`
6
+ //! - **Connection**: `send(data)` (returns `false` when the conn is gone OR its bounded outbound
7
+ //! queue is full — see `TISH_WS_SEND_BUF_BYTES`), `close()`, `readyState` (1=OPEN),
8
+ //! `receive()` / `receiveTimeout(ms)`
6
9
  //! - **Broadcast** (Node pattern): `server.clients.forEach(ws => ws.send(data))` or iterate room conns and `wsSend(ws, data)` (same as `ws.send(data)`)
7
10
 
8
11
  use tishlang_core::VmRef;
9
12
  use std::collections::HashMap;
10
- use std::sync::atomic::{AtomicU32, Ordering};
13
+ use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
11
14
  use std::sync::mpsc;
12
15
  use std::sync::{Arc, Mutex};
13
16
  use std::time::{Duration, Instant};
@@ -80,32 +83,74 @@ fn ws_debug() -> bool {
80
83
  }
81
84
 
82
85
  struct ConnState {
83
- send_tx: tokio_mpsc::UnboundedSender<String>,
86
+ send_tx: tokio_mpsc::Sender<String>,
84
87
  recv_rx: mpsc::Receiver<String>,
88
+ /// Bytes accepted by `conn_send` and not yet written to the socket. Shared with the write task,
89
+ /// which decrements as each write completes; `conn_send` refuses (returns false) once
90
+ /// `ws_send_buf_bytes()` is exceeded so a peer that stops reading can't grow the queue forever.
91
+ queued_bytes: Arc<AtomicUsize>,
92
+ /// Abort handle for the read pump task. `unregister` aborts it so an explicit `close()` frees
93
+ /// the task, socket fd and read buffer instead of leaving the task parked on `read.next()`
94
+ /// until the REMOTE peer disconnects.
95
+ read_abort: Option<tokio::task::AbortHandle>,
85
96
  #[allow(dead_code)]
86
97
  open: bool,
87
98
  }
88
99
 
100
+ /// A listening `tish:ws` server: the queue of accepted connection ids plus the accept loop's
101
+ /// shutdown signal (see `server_close`).
102
+ struct ServerState {
103
+ conn_rx: mpsc::Receiver<u32>,
104
+ shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
105
+ }
106
+
89
107
  lazy_static! {
90
108
  static ref CONNS: Mutex<HashMap<u32, ConnState>> = Mutex::new(HashMap::new());
91
- static ref SERVER_RECV: Mutex<HashMap<u32, mpsc::Receiver<u32>>> = Mutex::new(HashMap::new());
109
+ static ref SERVER_RECV: Mutex<HashMap<u32, ServerState>> = Mutex::new(HashMap::new());
92
110
  }
93
111
 
94
- fn register(send_tx: tokio_mpsc::UnboundedSender<String>, recv_rx: mpsc::Receiver<String>) -> u32 {
112
+ fn register(
113
+ send_tx: tokio_mpsc::Sender<String>,
114
+ recv_rx: mpsc::Receiver<String>,
115
+ queued_bytes: Arc<AtomicUsize>,
116
+ ) -> u32 {
95
117
  let id = next_conn_id();
96
118
  CONNS.lock().unwrap().insert(
97
119
  id,
98
120
  ConnState {
99
121
  send_tx,
100
122
  recv_rx,
123
+ queued_bytes,
124
+ read_abort: None,
101
125
  open: true,
102
126
  },
103
127
  );
104
128
  id
105
129
  }
106
130
 
131
+ /// Store the read pump's abort handle on an already-registered connection so `unregister` can
132
+ /// cancel it. If the connection has vanished in the meantime (instant disconnect), abort right away.
133
+ fn attach_read_task(id: u32, task: &tokio::task::JoinHandle<()>) {
134
+ let handle = task.abort_handle();
135
+ if let Ok(mut guard) = CONNS.lock() {
136
+ match guard.get_mut(&id) {
137
+ Some(state) => state.read_abort = Some(handle),
138
+ None => handle.abort(),
139
+ }
140
+ }
141
+ }
142
+
107
143
  fn unregister(id: u32) {
108
- CONNS.lock().unwrap().remove(&id);
144
+ let state = CONNS.lock().ok().and_then(|mut g| g.remove(&id));
145
+ if let Some(state) = state {
146
+ // Cancel the read pump. Without this an explicit close() left the task parked on
147
+ // `read.next().await` forever, pinning the socket fd and the tungstenite read buffer until
148
+ // the remote peer disconnected. Dropping `state` also drops `send_tx`, which ends the write
149
+ // task — and the write task drives a Close frame + socket shutdown on its way out.
150
+ if let Some(handle) = state.read_abort {
151
+ handle.abort();
152
+ }
153
+ }
109
154
  }
110
155
 
111
156
  /// Bridge an already-handshaked WebSocket stream into the `CONNS` registry — spawning the read and
@@ -115,34 +160,50 @@ pub(crate) fn register_ws_stream<S>(ws_stream: tokio_tungstenite::WebSocketStrea
115
160
  where
116
161
  S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
117
162
  {
118
- let (send_tx, mut send_rx) = tokio_mpsc::unbounded_channel::<String>();
163
+ let (send_tx, mut send_rx) = tokio_mpsc::channel::<String>(SEND_QUEUE_MAX_MSGS);
119
164
  let (recv_tx, recv_rx) = mpsc::sync_channel::<String>(64);
120
- let id = register(send_tx, recv_rx);
165
+ let queued_bytes = Arc::new(AtomicUsize::new(0));
166
+ let id = register(send_tx, recv_rx, Arc::clone(&queued_bytes));
121
167
  let recv_tx_task = Arc::new(recv_tx);
122
168
  let (mut write, mut read) = ws_stream.split();
123
- tokio::spawn(async move {
169
+ let read_task = tokio::spawn(async move {
124
170
  while let Some(Ok(msg)) = read.next().await {
125
171
  match msg {
126
172
  tokio_tungstenite::tungstenite::Message::Text(t) => {
127
- let _ = recv_tx_task.send(t.to_string());
173
+ // A dropped receiver means the connection was closed and nobody will ever read
174
+ // these frames — stop pumping instead of discarding them forever.
175
+ if recv_tx_task.send(t.to_string()).is_err() {
176
+ break;
177
+ }
128
178
  }
129
179
  // Binary frames are delivered too (utf8-lossy) — remote pty streams raw bytes and
130
180
  // some servers frame as Binary; dropping them silently loses output. Ping/Pong/Close
131
181
  // are handled by the stream (the loop ends when it yields None on close).
132
182
  tokio_tungstenite::tungstenite::Message::Binary(b) => {
133
- let _ = recv_tx_task.send(String::from_utf8_lossy(&b).into_owned());
183
+ if recv_tx_task
184
+ .send(String::from_utf8_lossy(&b).into_owned())
185
+ .is_err()
186
+ {
187
+ break;
188
+ }
134
189
  }
135
190
  _ => {}
136
191
  }
137
192
  }
138
193
  unregister(id);
139
194
  });
195
+ attach_read_task(id, &read_task);
140
196
  tokio::spawn(async move {
141
197
  while let Some(text) = send_rx.recv().await {
198
+ let n = text.len();
142
199
  let _ = write
143
200
  .send(tokio_tungstenite::tungstenite::Message::Text(text.into()))
144
201
  .await;
202
+ queued_bytes.fetch_sub(n, Ordering::AcqRel);
145
203
  }
204
+ // Channel closed: the connection was unregistered (explicit close() or remote disconnect).
205
+ // Drive the closing handshake so the socket actually shuts down instead of leaking the fd.
206
+ let _ = write.close().await;
146
207
  });
147
208
  id
148
209
  }
@@ -208,6 +269,51 @@ fn max_ws_connections() -> usize {
208
269
  })
209
270
  }
210
271
 
272
+ /// Can another connection register under `max_ws_connections()`? Shared accounting point for the
273
+ /// `tish:ws` listener AND the `serve()` HTTP→WS upgrade path (http_hyper), which previously
274
+ /// registered pre-auth connections with no cap at all.
275
+ pub(crate) fn has_ws_capacity() -> bool {
276
+ CONNS
277
+ .lock()
278
+ .map(|c| c.len() < max_ws_connections())
279
+ .unwrap_or(false)
280
+ }
281
+
282
+ /// Slot count of the bounded per-connection send queue. The byte budget (`ws_send_buf_bytes`) is
283
+ /// the primary limit; this bounds per-message channel overhead when frames are tiny.
284
+ const SEND_QUEUE_MAX_MSGS: usize = 1024;
285
+
286
+ /// Per-connection outbound byte budget. `conn_send` refuses (returns false) once this many bytes
287
+ /// sit queued waiting on the socket, so a peer that stops reading gets backpressure instead of
288
+ /// unbounded queue growth. A single message is always admitted into an EMPTY queue even when it
289
+ /// exceeds the budget (so a legitimately large frame can still be sent); the queue is therefore
290
+ /// bounded by `budget + one message`. Override with `TISH_WS_SEND_BUF_BYTES`; default 4 MiB.
291
+ fn ws_send_buf_bytes() -> usize {
292
+ use std::sync::OnceLock;
293
+ static MAX: OnceLock<usize> = OnceLock::new();
294
+ *MAX.get_or_init(|| {
295
+ std::env::var("TISH_WS_SEND_BUF_BYTES")
296
+ .ok()
297
+ .and_then(|v| v.parse().ok())
298
+ .unwrap_or(4 * 1024 * 1024)
299
+ })
300
+ }
301
+
302
+ /// Shared tungstenite config for every connection (server-accept, `serve()` upgrade, client):
303
+ /// 16 KiB read/write buffers instead of the 128 KiB defaults — the read buffer is allocated eagerly
304
+ /// per connection, so the defaults authorize ~1.3 GB across the 10k-conn cap before any traffic —
305
+ /// and a finite `max_write_buffer_size` so a failing socket can't grow the write buffer without
306
+ /// bound.
307
+ pub(crate) fn ws_config() -> tokio_tungstenite::tungstenite::protocol::WebSocketConfig {
308
+ tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
309
+ .read_buffer_size(16 * 1024)
310
+ .write_buffer_size(16 * 1024)
311
+ .max_write_buffer_size(ws_send_buf_bytes().saturating_mul(2).max(1 << 20))
312
+ }
313
+
314
+ /// Queue `data` for the connection's write task. Returns `false` when the connection is gone OR
315
+ /// when the outbound queue is full (byte budget `ws_send_buf_bytes()` / `SEND_QUEUE_MAX_MSGS`
316
+ /// slots) — callers already treat `false` as "not delivered". Never blocks the caller.
211
317
  fn conn_send(id: u32, data: String) -> bool {
212
318
  let guard = match CONNS.lock() {
213
319
  Ok(g) => g,
@@ -217,7 +323,21 @@ fn conn_send(id: u32, data: String) -> bool {
217
323
  Some(s) if s.open => s,
218
324
  _ => return false,
219
325
  };
220
- state.send_tx.send(data).is_ok()
326
+ let len = data.len();
327
+ let queued = state.queued_bytes.fetch_add(len, Ordering::AcqRel);
328
+ // Refuse over-budget sends, but always admit one message into an empty queue (see
329
+ // `ws_send_buf_bytes`) — mirrors tungstenite's own "buffer + one message" convention.
330
+ if queued > 0 && queued.saturating_add(len) > ws_send_buf_bytes() {
331
+ state.queued_bytes.fetch_sub(len, Ordering::AcqRel);
332
+ return false;
333
+ }
334
+ match state.send_tx.try_send(data) {
335
+ Ok(()) => true,
336
+ Err(_) => {
337
+ state.queued_bytes.fetch_sub(len, Ordering::AcqRel);
338
+ false
339
+ }
340
+ }
221
341
  }
222
342
 
223
343
  /// Default timeout for receive() so the main thread blocks and keeps the process/runtime alive.
@@ -242,7 +362,13 @@ fn conn_receive_timeout(id: u32, timeout_ms: u64) -> Option<String> {
242
362
  };
243
363
  if !guard.contains_key(&id) {
244
364
  drop(guard);
245
- std::thread::sleep(Duration::from_millis(50));
365
+ // Dead/unknown id: return promptly, but pace tight caller loops that poll a closed
366
+ // conn — sleeping never past the caller's own deadline. `receiveTimeout(0)` must
367
+ // cost 0ms, not a flat 50ms.
368
+ let remaining = deadline.saturating_duration_since(Instant::now());
369
+ if !remaining.is_zero() {
370
+ std::thread::sleep(remaining.min(poll_interval));
371
+ }
246
372
  return None;
247
373
  }
248
374
  guard.get(&id).unwrap().recv_rx.try_recv()
@@ -290,10 +416,30 @@ fn conn_id_from_value(v: &Value) -> Option<u32> {
290
416
  }
291
417
  }
292
418
 
419
+ /// Drop conn-array entries whose connection is no longer registered (closed or disconnected).
420
+ /// Called lazily from the tish thread — at the `server.clients` push sites and before broadcasts —
421
+ /// and NEVER from the tokio read task: `VmRef` is `Rc` in single-threaded builds, so cross-thread
422
+ /// pruning would be unsound. Keeps `server.clients` O(live) instead of O(ever-accepted) and matches
423
+ /// Node `ws` semantics, where closed sockets leave `clients` (#698).
424
+ fn prune_closed_conns(list: &mut Vec<Value>) {
425
+ let Ok(guard) = CONNS.lock() else { return };
426
+ list.retain(|c| {
427
+ conn_id_from_value(c)
428
+ .map(|id| guard.contains_key(&id))
429
+ .unwrap_or(false)
430
+ });
431
+ }
432
+
293
433
  /// Native broadcast: send data to all conns in array except `except`. Avoids Tish-side method calls.
294
434
  pub fn ws_broadcast_native(args: &[Value]) -> Value {
295
435
  let conns = match args.first() {
296
- Some(Value::Array(a)) => a.borrow().clone(),
436
+ Some(Value::Array(a)) => {
437
+ // Prune first so broadcast cost stays proportional to LIVE connections, not to every
438
+ // connection ever accepted (#698). Sequential borrows — the mutable one ends before
439
+ // the clone's shared borrow starts.
440
+ prune_closed_conns(&mut a.borrow_mut());
441
+ a.borrow().clone()
442
+ }
297
443
  _ => return Value::Null,
298
444
  };
299
445
  let except = args.get(1).cloned().unwrap_or(Value::Null);
@@ -417,13 +563,15 @@ pub fn web_socket_client(args: &[Value]) -> Value {
417
563
  }
418
564
  // wss:// needs rustls' process-default crypto provider installed first.
419
565
  ensure_crypto_provider();
420
- let (send_tx, mut send_rx) = tokio_mpsc::unbounded_channel::<String>();
566
+ let (send_tx, mut send_rx) = tokio_mpsc::channel::<String>(SEND_QUEUE_MAX_MSGS);
421
567
  let (recv_tx, recv_rx) = mpsc::sync_channel::<String>(64);
422
568
  let recv_tx = Arc::new(recv_tx);
569
+ let queued_bytes = Arc::new(AtomicUsize::new(0));
423
570
 
424
571
  let id = with_ws_client_rt(|rt| {
425
572
  rt.block_on(async move {
426
- let connect = tokio_tungstenite::connect_async(&url);
573
+ let connect =
574
+ tokio_tungstenite::connect_async_with_config(&url, Some(ws_config()), false);
427
575
  let connected = if connect_timeout_ms > 0 {
428
576
  match tokio::time::timeout(Duration::from_millis(connect_timeout_ms), connect).await {
429
577
  Ok(r) => r,
@@ -456,18 +604,22 @@ pub fn web_socket_client(args: &[Value]) -> Value {
456
604
  return None;
457
605
  }
458
606
  };
459
- let id = register(send_tx, recv_rx);
607
+ let id = register(send_tx, recv_rx, Arc::clone(&queued_bytes));
460
608
  let (mut write, mut read) = ws_stream.split();
461
609
  let recv_tx = Arc::clone(&recv_tx);
462
610
  let url_closed = url.clone();
463
- tokio::spawn(async move {
611
+ let read_task = tokio::spawn(async move {
464
612
  while let Some(Ok(msg)) = read.next().await {
465
613
  match msg {
466
614
  tokio_tungstenite::tungstenite::Message::Text(t) => {
467
- let _ = recv_tx.send(t.to_string());
615
+ if recv_tx.send(t.to_string()).is_err() {
616
+ break;
617
+ }
468
618
  }
469
619
  tokio_tungstenite::tungstenite::Message::Binary(b) => {
470
- let _ = recv_tx.send(String::from_utf8_lossy(&b).into_owned());
620
+ if recv_tx.send(String::from_utf8_lossy(&b).into_owned()).is_err() {
621
+ break;
622
+ }
471
623
  }
472
624
  _ => {}
473
625
  }
@@ -477,12 +629,17 @@ pub fn web_socket_client(args: &[Value]) -> Value {
477
629
  }
478
630
  unregister(id);
479
631
  });
632
+ attach_read_task(id, &read_task);
480
633
  tokio::spawn(async move {
481
634
  while let Some(text) = send_rx.recv().await {
635
+ let n = text.len();
482
636
  let _ = write
483
637
  .send(tokio_tungstenite::tungstenite::Message::Text(text.into()))
484
638
  .await;
639
+ queued_bytes.fetch_sub(n, Ordering::AcqRel);
485
640
  }
641
+ // See `register_ws_stream`: drive the closing handshake so close() frees the fd.
642
+ let _ = write.close().await;
486
643
  });
487
644
  Some(id)
488
645
  })
@@ -504,11 +661,18 @@ pub fn web_socket_server_listen(args: &[Value]) -> Value {
504
661
 
505
662
  let (bind_tx, bind_rx) = mpsc::sync_channel::<bool>(1);
506
663
  let (conn_tx, conn_rx) = mpsc::channel::<u32>();
664
+ let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>();
507
665
  let handle = next_server_handle();
508
666
 
509
667
  {
510
668
  let mut map = SERVER_RECV.lock().unwrap();
511
- map.insert(handle, conn_rx);
669
+ map.insert(
670
+ handle,
671
+ ServerState {
672
+ conn_rx,
673
+ shutdown_tx: Some(shutdown_tx),
674
+ },
675
+ );
512
676
  }
513
677
 
514
678
  std::thread::spawn(move || {
@@ -534,35 +698,45 @@ pub fn web_socket_server_listen(args: &[Value]) -> Value {
534
698
  println!("WebSocket server listening on ws://0.0.0.0:{}", port);
535
699
 
536
700
  loop {
537
- let (stream, _) = match listener.accept().await {
701
+ // `server.close()` fires (or drops) the shutdown signal — exit the loop so the
702
+ // listener, this runtime and its thread are actually freed (`TcpListener::accept`
703
+ // is cancel-safe).
704
+ let accepted = tokio::select! {
705
+ _ = &mut shutdown_rx => break,
706
+ r = listener.accept() => r,
707
+ };
708
+ let (stream, _) = match accepted {
538
709
  Ok(s) => s,
539
710
  Err(_) => break,
540
711
  };
541
712
  // Cap total connections — unbounded accepts would exhaust tasks/memory.
542
- if CONNS.lock().map(|c| c.len()).unwrap_or(0) >= max_ws_connections() {
713
+ if !has_ws_capacity() {
543
714
  drop(stream);
544
715
  continue;
545
716
  }
546
- let ws_stream = match tokio_tungstenite::accept_async(stream).await {
547
- Ok(ws) => {
548
- if ws_debug() {
549
- eprintln!(
550
- "[tish ws] server accepted connection (handshake OK): port {}",
551
- port
552
- );
717
+ let ws_stream =
718
+ match tokio_tungstenite::accept_async_with_config(stream, Some(ws_config()))
719
+ .await
720
+ {
721
+ Ok(ws) => {
722
+ if ws_debug() {
723
+ eprintln!(
724
+ "[tish ws] server accepted connection (handshake OK): port {}",
725
+ port
726
+ );
727
+ }
728
+ ws
553
729
  }
554
- ws
555
- }
556
- Err(e) => {
557
- if ws_debug() {
558
- eprintln!(
559
- "[tish ws] server accept_async failed: {} (port {})",
560
- e, port
561
- );
730
+ Err(e) => {
731
+ if ws_debug() {
732
+ eprintln!(
733
+ "[tish ws] server accept_async failed: {} (port {})",
734
+ e, port
735
+ );
736
+ }
737
+ continue;
562
738
  }
563
- continue;
564
- }
565
- };
739
+ };
566
740
  let id = register_ws_stream(ws_stream);
567
741
  if conn_tx.send(id).is_err() {
568
742
  break;
@@ -590,11 +764,11 @@ pub fn web_socket_server_accept(args: &[Value]) -> Value {
590
764
  Ok(g) => g,
591
765
  Err(_) => return Value::Null,
592
766
  };
593
- let rx = match map.get_mut(&handle) {
594
- Some(r) => r,
767
+ let st = match map.get_mut(&handle) {
768
+ Some(s) => s,
595
769
  None => return Value::Null,
596
770
  };
597
- match rx.recv() {
771
+ match st.conn_rx.recv() {
598
772
  Ok(id) => conn_object(id),
599
773
  Err(_) => Value::Null,
600
774
  }
@@ -614,16 +788,36 @@ pub fn web_socket_server_accept_timeout(args: &[Value]) -> Value {
614
788
  Ok(g) => g,
615
789
  Err(_) => return Value::Null,
616
790
  };
617
- let rx = match map.get_mut(&handle) {
618
- Some(r) => r,
791
+ let st = match map.get_mut(&handle) {
792
+ Some(s) => s,
619
793
  None => return Value::Null,
620
794
  };
621
- match rx.recv_timeout(std::time::Duration::from_millis(timeout_ms)) {
795
+ match st
796
+ .conn_rx
797
+ .recv_timeout(std::time::Duration::from_millis(timeout_ms))
798
+ {
622
799
  Ok(id) => conn_object(id),
623
800
  Err(_) => Value::Null,
624
801
  }
625
802
  }
626
803
 
804
+ /// Tear down a listening server: stop the accept loop (freeing the `TcpListener`, its dedicated
805
+ /// thread and tokio runtime — previously they lived for the whole process, #698), and unregister
806
+ /// any accepted-but-unclaimed connections still sitting in the queue.
807
+ fn server_close(handle: u32) {
808
+ let state = SERVER_RECV.lock().ok().and_then(|mut m| m.remove(&handle));
809
+ if let Some(mut st) = state {
810
+ if let Some(tx) = st.shutdown_tx.take() {
811
+ let _ = tx.send(());
812
+ }
813
+ // Connections the accept loop registered but the program never picked up would otherwise
814
+ // stay in CONNS (holding their tasks + fd) until the remote peer disconnects.
815
+ while let Ok(id) = st.conn_rx.try_recv() {
816
+ unregister(id);
817
+ }
818
+ }
819
+ }
820
+
627
821
  /// `Server(options)` — object with `_handle`, `_onConnection`, `on`, `listen`, `clients` (Node.js-compatible).
628
822
  pub fn web_socket_server_construct(args: &[Value]) -> Value {
629
823
  let handle_val = web_socket_server_listen(args);
@@ -679,7 +873,14 @@ pub fn web_socket_server_construct(args: &[Value]) -> Value {
679
873
  if matches!(ws, Value::Null) {
680
874
  break;
681
875
  }
682
- clients_listen.borrow_mut().push(ws.clone());
876
+ {
877
+ // Lazy prune on the tish thread: closed conns leave `clients` before each push, so
878
+ // the array tracks LIVE connections (Node `ws` semantics) instead of growing with
879
+ // every connection ever accepted (#698).
880
+ let mut list = clients_listen.borrow_mut();
881
+ prune_closed_conns(&mut list);
882
+ list.push(ws.clone());
883
+ }
683
884
  if let Value::Function(f) = cb {
684
885
  let _ = f.call(&[ws]);
685
886
  }
@@ -701,11 +902,32 @@ pub fn web_socket_server_construct(args: &[Value]) -> Value {
701
902
  let timeout_ms = args.get(1).cloned().unwrap_or(Value::Number(100.0));
702
903
  let ws = web_socket_server_accept_timeout(&[handle_n, timeout_ms]);
703
904
  if !matches!(ws, Value::Null) {
704
- clients_accept.borrow_mut().push(ws.clone());
905
+ // Same lazy prune as the listen() push site (#698).
906
+ let mut list = clients_accept.borrow_mut();
907
+ prune_closed_conns(&mut list);
908
+ list.push(ws.clone());
705
909
  }
706
910
  ws
707
911
  });
708
912
 
913
+ let handle_u32 = match &handle_val {
914
+ Value::Number(n) if n.is_finite() && *n >= 0.0 => *n as u32,
915
+ _ => 0,
916
+ };
917
+ let clients_close = clients.clone();
918
+ let close_fn = Value::native(move |args: &[Value]| {
919
+ server_close(handle_u32);
920
+ // Null the handle so a listen() loop exits on its next iteration, and clear `clients`
921
+ // (the accept loop is stopped, so nothing repopulates it).
922
+ if let Some(Value::Object(so)) = args.first() {
923
+ so.borrow_mut()
924
+ .strings
925
+ .insert(Arc::from("_handle"), Value::Null);
926
+ }
927
+ clients_close.borrow_mut().clear();
928
+ Value::Null
929
+ });
930
+
709
931
  let mut m: ObjectMap = ObjectMap::default();
710
932
  m.insert(Arc::from("_handle"), handle_val);
711
933
  m.insert(Arc::from("_onConnection"), Value::Null);
@@ -713,6 +935,7 @@ pub fn web_socket_server_construct(args: &[Value]) -> Value {
713
935
  m.insert(Arc::from("on"), on_fn);
714
936
  m.insert(Arc::from("listen"), listen_fn);
715
937
  m.insert(Arc::from("acceptTimeout"), accept_timeout_fn);
938
+ m.insert(Arc::from("close"), close_fn);
716
939
  Value::object(m)
717
940
  }
718
941
 
@@ -826,14 +1049,170 @@ mod tests {
826
1049
  /// socket and emit EOF exactly once).
827
1050
  #[test]
828
1051
  fn conn_is_open_reflects_registry() {
829
- let (tx, _rx) = tokio_mpsc::unbounded_channel::<String>();
1052
+ let (tx, _rx) = tokio_mpsc::channel::<String>(4);
830
1053
  let (_stx, srx) = mpsc::sync_channel::<String>(1);
831
- let id = register(tx, srx);
1054
+ let id = register(tx, srx, Arc::new(AtomicUsize::new(0)));
832
1055
  assert!(conn_is_open(id), "just-registered conn should be open");
833
1056
  unregister(id);
834
1057
  assert!(!conn_is_open(id), "unregistered conn should be closed");
835
1058
  }
836
1059
 
1060
+ /// The bounded send queue must refuse (return false) once the byte budget is reached instead of
1061
+ /// queueing forever — the backpressure contract callers rely on (`sent === false` → drop the
1062
+ /// peer). Nothing drains the channel here, simulating a peer that stopped reading.
1063
+ #[test]
1064
+ fn conn_send_refuses_when_queue_full() {
1065
+ let (tx, _rx) = tokio_mpsc::channel::<String>(SEND_QUEUE_MAX_MSGS);
1066
+ let (_stx, srx) = mpsc::sync_channel::<String>(1);
1067
+ let id = register(tx, srx, Arc::new(AtomicUsize::new(0)));
1068
+
1069
+ let chunk = "x".repeat(64 * 1024);
1070
+ let budget = ws_send_buf_bytes();
1071
+ let mut accepted = 0usize;
1072
+ while conn_send(id, chunk.clone()) {
1073
+ accepted += 1;
1074
+ assert!(
1075
+ accepted * chunk.len() <= budget + chunk.len(),
1076
+ "queue admitted {} bytes — budget {} never enforced",
1077
+ accepted * chunk.len(),
1078
+ budget
1079
+ );
1080
+ }
1081
+ assert!(
1082
+ accepted > 0,
1083
+ "an empty queue must admit at least one message"
1084
+ );
1085
+ assert!(
1086
+ (accepted + 1) * chunk.len() > budget,
1087
+ "refused after only {} bytes (budget {})",
1088
+ accepted * chunk.len(),
1089
+ budget
1090
+ );
1091
+ // Once full, even a tiny send is refused (only an EMPTY queue admits unconditionally).
1092
+ assert!(
1093
+ !conn_send(id, "y".into()),
1094
+ "full queue must refuse further sends"
1095
+ );
1096
+ unregister(id);
1097
+ assert!(!conn_send(id, "z".into()), "closed conn must refuse sends");
1098
+ }
1099
+
1100
+ /// `server.clients` housekeeping: `prune_closed_conns` drops entries whose connection is no
1101
+ /// longer registered (and non-conn junk), keeping only live conns — Node `ws` semantics (#698).
1102
+ #[test]
1103
+ fn prune_closed_conns_drops_dead_entries() {
1104
+ let (tx, _rx) = tokio_mpsc::channel::<String>(4);
1105
+ let (_stx, srx) = mpsc::sync_channel::<String>(1);
1106
+ let live = register(tx, srx, Arc::new(AtomicUsize::new(0)));
1107
+ let (tx2, _rx2) = tokio_mpsc::channel::<String>(4);
1108
+ let (_stx2, srx2) = mpsc::sync_channel::<String>(1);
1109
+ let dead = register(tx2, srx2, Arc::new(AtomicUsize::new(0)));
1110
+ unregister(dead);
1111
+
1112
+ let mut list = vec![conn_object(live), conn_object(dead), Value::Null];
1113
+ prune_closed_conns(&mut list);
1114
+ assert_eq!(list.len(), 1, "only the live conn should remain");
1115
+ assert_eq!(conn_id_from_value(&list[0]), Some(live));
1116
+ unregister(live);
1117
+ }
1118
+
1119
+ /// `receiveTimeout` on a dead/unknown id must honor the caller's timeout — it used to sleep a
1120
+ /// flat 50ms, so a pump loop polling N zombie conns paid N x 50ms per tick.
1121
+ #[test]
1122
+ fn receive_timeout_dead_id_honors_caller_timeout() {
1123
+ let t0 = Instant::now();
1124
+ assert!(conn_receive_timeout(u32::MAX, 0).is_none());
1125
+ assert!(
1126
+ t0.elapsed() < Duration::from_millis(40),
1127
+ "receiveTimeout(0) on a dead id must return immediately, took {:?}",
1128
+ t0.elapsed()
1129
+ );
1130
+ }
1131
+
1132
+ /// close() must actually close the socket: the SERVER side must observe the stream ending and
1133
+ /// unregister its conn. Before the fix, close() only removed the local registry entry — the
1134
+ /// read task stayed parked on the open socket forever (fd + task + buffers leaked per close).
1135
+ #[test]
1136
+ fn client_close_shuts_down_the_socket() {
1137
+ let port: u16 = 18_745;
1138
+ let opts = {
1139
+ let mut m: ObjectMap = ObjectMap::default();
1140
+ m.insert(Arc::from("port"), Value::Number(port as f64));
1141
+ Value::object(m)
1142
+ };
1143
+ let handle = match web_socket_server_listen(std::slice::from_ref(&opts)) {
1144
+ Value::Number(h) => h as u32,
1145
+ _ => panic!("listen failed"),
1146
+ };
1147
+ let (id_tx, id_rx) = mpsc::channel::<u32>();
1148
+ let server = thread::spawn(move || {
1149
+ let ws = web_socket_server_accept(&[Value::Number(handle as f64)]);
1150
+ let _ = id_tx.send(conn_id_from_value(&ws).expect("server conn id"));
1151
+ });
1152
+
1153
+ thread::sleep(Duration::from_millis(100));
1154
+ let url = format!("ws://127.0.0.1:{}/", port);
1155
+ let client = web_socket_client(&[Value::String(url.into())]);
1156
+ let Value::Object(co) = client else {
1157
+ panic!("client connect failed");
1158
+ };
1159
+ let server_id = id_rx
1160
+ .recv_timeout(Duration::from_secs(5))
1161
+ .expect("server never accepted");
1162
+ assert!(conn_is_open(server_id), "server conn should start open");
1163
+
1164
+ let Some(Value::Function(close_f)) = co.borrow().strings.get("close").cloned() else {
1165
+ panic!("no close");
1166
+ };
1167
+ let _ = close_f.call(&[]);
1168
+
1169
+ let deadline = Instant::now() + Duration::from_secs(5);
1170
+ while conn_is_open(server_id) && Instant::now() < deadline {
1171
+ thread::sleep(Duration::from_millis(25));
1172
+ }
1173
+ assert!(
1174
+ !conn_is_open(server_id),
1175
+ "server still sees the conn open after the client's close() — the socket was not shut down"
1176
+ );
1177
+ let _ = server.join();
1178
+ }
1179
+
1180
+ /// server.close() must stop the accept loop and free the TcpListener (previously a bound
1181
+ /// server's listener, thread and runtime lived for the whole process — #698).
1182
+ #[test]
1183
+ fn server_close_frees_the_listener() {
1184
+ let port: u16 = 18_746;
1185
+ let opts = {
1186
+ let mut m: ObjectMap = ObjectMap::default();
1187
+ m.insert(Arc::from("port"), Value::Number(port as f64));
1188
+ Value::object(m)
1189
+ };
1190
+ let srv = web_socket_server_construct(std::slice::from_ref(&opts));
1191
+ let Value::Object(so) = &srv else {
1192
+ panic!("server construct failed");
1193
+ };
1194
+ let Some(Value::Function(close_f)) = so.borrow().strings.get("close").cloned() else {
1195
+ panic!("no close method on server object");
1196
+ };
1197
+ let _ = close_f.call(std::slice::from_ref(&srv));
1198
+
1199
+ let deadline = Instant::now() + Duration::from_secs(3);
1200
+ let mut rebound = false;
1201
+ while Instant::now() < deadline {
1202
+ if std::net::TcpListener::bind(("0.0.0.0", port)).is_ok() {
1203
+ rebound = true;
1204
+ break;
1205
+ }
1206
+ thread::sleep(Duration::from_millis(50));
1207
+ }
1208
+ assert!(rebound, "port still held after server.close()");
1209
+ // And the handle is gone: a subsequent accept returns Null instead of blocking.
1210
+ assert!(matches!(
1211
+ web_socket_server_accept_timeout(&[Value::Number(0.0), Value::Number(0.0)]),
1212
+ Value::Null
1213
+ ));
1214
+ }
1215
+
837
1216
  #[test]
838
1217
  fn ws_echo_roundtrip() {
839
1218
  let port: u16 = 18_742;