@tishlang/tish-lsp 2.38.0 → 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-lsp 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 {
@@ -2268,6 +2269,10 @@ impl Codegen {
2268
2269
  self.write("use tishlang_runtime::register_static_route as tish_register_static_route;\n");
2269
2270
  if self.is_async {
2270
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");
2271
2276
  } else {
2272
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");
2273
2278
  }
@@ -2693,7 +2698,7 @@ impl Codegen {
2693
2698
  "let fetch = Value::native(|args: &[Value]| tish_fetch_promise(args.to_vec()));",
2694
2699
  );
2695
2700
  self.writeln("let fetchAll = Value::native(|args: &[Value]| tish_fetch_all_promise(args.to_vec()));");
2696
- if self.is_async {
2701
+ if self.is_async || self.has_feature("ws") {
2697
2702
  self.writeln("let Promise = tish_promise_object();");
2698
2703
  }
2699
2704
  // `serve` supports two shapes:
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_lsp"
3
- version = "2.38.0"
3
+ version = "2.39.0"
4
4
  edition = "2021"
5
5
  description = "Language Server Protocol implementation for Tish"
6
6
  license-file = { workspace = 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 {
@@ -1735,7 +1735,7 @@ pub use pty::{pty_kill, pty_pid, pty_read, pty_resize, pty_spawn, pty_write};
1735
1735
  #[cfg(feature = "ws")]
1736
1736
  pub use ws::{
1737
1737
  web_socket_client, web_socket_server_accept, web_socket_server_construct,
1738
- web_socket_server_listen, ws_broadcast_native, ws_send_native,
1738
+ web_socket_server_listen, ws_broadcast_native, ws_send_native, ws_serve_accept,
1739
1739
  };
1740
1740
 
1741
1741
  #[cfg(feature = "http")]
@@ -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-lsp",
3
- "version": "2.38.0",
3
+ "version": "2.39.0",
4
4
  "description": "Tish language server — diagnostics, completion, formatting, hover, go-to-definition (LSP over stdio)",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file