Skip to main content

lex_runtime/
ws.rs

1//! WebSocket server + chat-broadcast registry.
2//!
3//! `net.serve_ws(port, on_message)` blocks on a TCP listener, upgrades
4//! each incoming connection to WebSocket, and runs a per-connection
5//! worker thread that polls both inbound (calls Lex's `on_message`)
6//! and outbound (drains broadcasts from a channel into the socket).
7//!
8//! `chat.broadcast(room, body)` looks up every connection in `room`
9//! and pushes `body` onto its outbound channel. `chat.send(conn_id,
10//! body)` is the same but to a single connection.
11//!
12//! The registry is an `Arc<Mutex<…>>` because Lex's immutability means
13//! shared mutable state has to live in the host runtime. Lex code
14//! stays pure: it receives an event, returns Nil, and any side
15//! effects go through `chat.*` which is gated by the policy.
16
17// tungstenite's `accept_hdr` callback takes/returns a tungstenite
18// `ErrorResponse` which is large; we only ever return Ok so the
19// large-Err warning is noise.
20#![allow(clippy::result_large_err)]
21
22use crate::policy::Policy;
23use indexmap::IndexMap;
24use lex_bytecode::vm::Vm;
25use lex_bytecode::{Program, Value};
26use std::net::TcpListener;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::mpsc;
29use std::sync::{Arc, Mutex};
30use std::thread;
31use std::time::Duration;
32
33/// Per-connection state held in the global registry.
34struct Conn {
35    room: String,
36    /// Channel writer end. The connection's worker thread reads from
37    /// the corresponding Receiver and writes each message to the
38    /// WebSocket. Broadcasts push here.
39    outbound: mpsc::Sender<String>,
40}
41
42/// Global chat registry. One per `net.serve_ws` invocation.
43#[derive(Default)]
44pub struct ChatRegistry {
45    conns: Mutex<IndexMap<u64, Conn>>,
46}
47
48impl ChatRegistry {
49    fn register(&self, room: String, outbound: mpsc::Sender<String>) -> u64 {
50        static NEXT_ID: AtomicU64 = AtomicU64::new(1);
51        let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
52        self.conns.lock().unwrap().insert(id, Conn { room, outbound });
53        id
54    }
55    fn unregister(&self, id: u64) {
56        self.conns.lock().unwrap().shift_remove(&id);
57    }
58    fn broadcast(&self, room: &str, body: &str) {
59        let conns = self.conns.lock().unwrap();
60        for c in conns.values() {
61            if c.room == room {
62                let _ = c.outbound.send(body.to_string());
63            }
64        }
65    }
66    fn send_to(&self, id: u64, body: &str) -> bool {
67        if let Some(c) = self.conns.lock().unwrap().get(&id) {
68            let _ = c.outbound.send(body.to_string());
69            true
70        } else {
71            false
72        }
73    }
74}
75
76/// `chat.broadcast(room, body)` — looked up at runtime by the
77/// effect handler; called from inside the Lex VM.
78pub fn chat_broadcast(reg: &Arc<ChatRegistry>, room: &str, body: &str) {
79    reg.broadcast(room, body);
80}
81
82pub fn chat_send(reg: &Arc<ChatRegistry>, conn_id: u64, body: &str) -> bool {
83    reg.send_to(conn_id, body)
84}
85
86/// Bind a WebSocket server. Blocks; returns Unit on shutdown (the
87/// process is normally killed before that).
88pub fn serve_ws(
89    port: u16,
90    handler_name: String,
91    program: Arc<Program>,
92    policy: Policy,
93    registry: Arc<ChatRegistry>,
94) -> Result<Value, String> {
95    let listener = TcpListener::bind(("127.0.0.1", port))
96        .map_err(|e| format!("net.serve_ws bind {port}: {e}"))?;
97    eprintln!("net.serve_ws: listening on ws://127.0.0.1:{port}");
98    for stream in listener.incoming() {
99        let stream = match stream {
100            Ok(s) => s,
101            Err(e) => { eprintln!("net.serve_ws accept: {e}"); continue; }
102        };
103        let program = Arc::clone(&program);
104        let policy = policy.clone();
105        let handler_name = handler_name.clone();
106        let registry = Arc::clone(&registry);
107        thread::spawn(move || {
108            if let Err(e) = handle_connection(stream, program, policy, handler_name, registry) {
109                eprintln!("net.serve_ws connection error: {e}");
110            }
111        });
112    }
113    Ok(Value::Unit)
114}
115
116/// Classify a tungstenite handshake outcome, dropping bare TCP probes
117/// silently instead of treating them as connection errors.
118///
119/// `HandshakeError::Failure(Error::Protocol(ProtocolError::HandshakeIncomplete))`
120/// is what tungstenite returns when a peer opens a TCP connection and
121/// closes it without sending a complete HTTP upgrade — i.e. a bare TCP
122/// liveness probe such as a Docker healthcheck running
123/// `echo > /dev/tcp/host/port`. That is a probe, not a misbehaving
124/// client, so logging an error for each one floods the logs at the
125/// healthcheck interval. Returns `Ok(Some(ws))` for a completed
126/// handshake, `Ok(None)` for such a probe (caller returns cleanly), and
127/// `Err(msg)` for a genuine handshake failure. (#624)
128fn accept_or_drop_probe<Cb>(
129    result: Result<
130        tungstenite::WebSocket<std::net::TcpStream>,
131        tungstenite::HandshakeError<
132            tungstenite::handshake::server::ServerHandshake<std::net::TcpStream, Cb>,
133        >,
134    >,
135) -> Result<Option<tungstenite::WebSocket<std::net::TcpStream>>, String>
136where
137    Cb: tungstenite::handshake::server::Callback,
138{
139    match result {
140        Ok(ws) => Ok(Some(ws)),
141        Err(tungstenite::HandshakeError::Failure(tungstenite::Error::Protocol(
142            tungstenite::error::ProtocolError::HandshakeIncomplete,
143        ))) => Ok(None),
144        Err(e) => Err(format!("ws handshake: {e}")),
145    }
146}
147
148fn handle_connection(
149    stream: std::net::TcpStream,
150    program: Arc<Program>,
151    policy: Policy,
152    handler_name: String,
153    registry: Arc<ChatRegistry>,
154) -> Result<(), String> {
155    use tungstenite::{accept_hdr, handshake::server::{Request, Response}};
156
157    // Capture the request path during the handshake — used as the room name.
158    let mut path = String::new();
159    let path_ref = &mut path;
160    let accepted = accept_hdr(stream, |req: &Request, resp: Response| {
161        *path_ref = req.uri().path().to_string();
162        Ok(resp)
163    });
164    let mut ws = match accept_or_drop_probe(accepted)? {
165        Some(ws) => ws,
166        None => return Ok(()), // bare TCP probe — dropped silently
167    };
168
169    let room = path.trim_start_matches('/').to_string();
170
171    // Outbound channel: broadcast/send pushes here, this thread writes
172    // each message into the WebSocket.
173    let (tx, rx) = mpsc::channel::<String>();
174    let conn_id = registry.register(room.clone(), tx);
175
176    // Make WS reads non-blocking-ish so the same thread can also drain
177    // the outbound channel. tungstenite reads through the underlying
178    // TcpStream; setting a short read timeout lets us multiplex.
179    let _ = ws.get_mut().set_read_timeout(Some(Duration::from_millis(50)));
180
181    let result = run_loop(&mut ws, &rx, conn_id, &room, &program, &policy, &handler_name, &registry);
182    registry.unregister(conn_id);
183    let _ = ws.close(None);
184    result
185}
186
187#[allow(clippy::too_many_arguments)]
188fn run_loop(
189    ws: &mut tungstenite::WebSocket<std::net::TcpStream>,
190    rx: &mpsc::Receiver<String>,
191    conn_id: u64,
192    room: &str,
193    program: &Arc<Program>,
194    policy: &Policy,
195    handler_name: &str,
196    registry: &Arc<ChatRegistry>,
197) -> Result<(), String> {
198    use tungstenite::Message;
199    use std::io::ErrorKind;
200    loop {
201        // 1) Try to read one inbound message. WouldBlock = no data yet.
202        match ws.read() {
203            Ok(Message::Text(body)) => {
204                let ev = build_ws_event(conn_id, room, &body);
205                let handler = crate::handler::DefaultHandler::new(policy.clone())
206                    .with_program(Arc::clone(program))
207                    .with_chat_registry(Arc::clone(registry));
208                let mut vm = Vm::with_handler(program, Box::new(handler));
209                if let Err(e) = vm.call(handler_name, vec![ev]) {
210                    eprintln!("on_message {conn_id}: {e}");
211                }
212            }
213            Ok(Message::Binary(_)) => { /* binary frames ignored in v1 */ }
214            Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed) => break,
215            Ok(_) => {} // ping/pong/frame
216            Err(tungstenite::Error::Io(ref e)) if e.kind() == ErrorKind::WouldBlock
217                || e.kind() == ErrorKind::TimedOut => {}
218            Err(e) => return Err(format!("ws read: {e}")),
219        }
220        // 2) Drain outbound channel. Doesn't block.
221        loop {
222            match rx.try_recv() {
223                Ok(msg) => {
224                    if let Err(e) = ws.send(Message::Text(msg.into())) {
225                        return Err(format!("ws send: {e}"));
226                    }
227                }
228                Err(mpsc::TryRecvError::Empty) => break,
229                Err(mpsc::TryRecvError::Disconnected) => return Ok(()),
230            }
231        }
232    }
233    Ok(())
234}
235
236fn build_ws_event(conn_id: u64, room: &str, body: &str) -> Value {
237    let mut rec = IndexMap::new();
238    rec.insert("body".into(), Value::Str(body.into()));
239    rec.insert("conn_id".into(), Value::Int(conn_id as i64));
240    rec.insert("room".into(), Value::Str(room.into()));
241    Value::record_dynamic(rec)
242}
243
244// ── Closure-based WebSocket server (#359) ────────────────────────────────────
245
246/// Build a `WsConn` record value for the typed closure-based handler.
247fn build_ws_conn(conn_id: u64, path: &str, subprotocol: &str) -> Value {
248    let mut rec = IndexMap::new();
249    rec.insert("id".into(), Value::Str(conn_id.to_string().into()));
250    rec.insert("path".into(), Value::Str(path.into()));
251    rec.insert("subprotocol".into(), Value::Str(subprotocol.into()));
252    Value::record_dynamic(rec)
253}
254
255/// Build a `WsMessage` variant value.
256fn build_ws_message_text(body: &str) -> Value {
257    Value::Variant { name: "WsText".into(), args: vec![Value::Str(body.into())] }
258}
259
260fn build_ws_message_close() -> Value {
261    Value::Variant { name: "WsClose".into(), args: vec![] }
262}
263
264fn build_ws_message_ping() -> Value {
265    Value::Variant { name: "WsPing".into(), args: vec![] }
266}
267
268fn build_ws_message_binary(payload: &[u8]) -> Value {
269    let bytes = payload.iter().map(|b| Value::Int(*b as i64)).collect();
270    Value::Variant { name: "WsBinary".into(), args: vec![Value::List(bytes)] }
271}
272
273/// Interpret a `WsAction` variant and send the appropriate frame.
274/// Generic over the stream so this serves both the plaintext-only
275/// server path (`TcpStream`) and the dial path that may sit on top
276/// of a TLS-wrapped stream (`MaybeTlsStream<TcpStream>`).
277fn apply_ws_action<S: std::io::Read + std::io::Write>(
278    action: &Value,
279    ws: &mut tungstenite::WebSocket<S>,
280) -> Result<(), String> {
281    use tungstenite::Message;
282    match action {
283        Value::Variant { name, args } if name == "WsSend" => {
284            let text = match args.first() {
285                Some(Value::Str(s)) => s.clone(),
286                _ => return Err("WsSend payload must be Str".into()),
287            };
288            ws.send(Message::Text(text.to_string().into()))
289                .map_err(|e| format!("ws send: {e}"))
290        }
291        Value::Variant { name, args } if name == "WsSendBinary" => {
292            let bytes: Vec<u8> = match args.first() {
293                Some(Value::List(elems)) => elems
294                    .iter()
295                    .map(|v| match v {
296                        Value::Int(n) => Ok(*n as u8),
297                        _ => Err("WsSendBinary payload must be List[Int]".into()),
298                    })
299                    .collect::<Result<Vec<_>, String>>()?,
300                _ => return Err("WsSendBinary payload must be List[Int]".into()),
301            };
302            ws.send(Message::Binary(bytes.into()))
303                .map_err(|e| format!("ws send binary: {e}"))
304        }
305        Value::Variant { name, .. } if name == "WsNoOp" => Ok(()),
306        other => Err(format!("unexpected WsAction: {other:?}")),
307    }
308}
309
310/// Closure-based WebSocket server. Accepts a `Value::Closure` as the handler.
311pub fn serve_ws_fn(
312    port: u16,
313    subprotocol: String,
314    closure: Value,
315    program: Arc<Program>,
316    policy: Policy,
317    registry: Arc<ChatRegistry>,
318) -> Result<Value, String> {
319    // Fail fast: a configured subprotocol that can't be a valid HTTP
320    // header value would silently break every handshake later (the
321    // accept_hdr callback's `HeaderValue::from_str` would always
322    // return Err). Reject at startup with a clear message instead.
323    if !subprotocol.is_empty() {
324        if let Err(e) =
325            tungstenite::http::HeaderValue::from_str(&subprotocol)
326        {
327            return Err(format!(
328                "net.serve_ws_fn: subprotocol {subprotocol:?} is not a valid \
329                 HTTP header value: {e}"
330            ));
331        }
332    }
333    let listener = TcpListener::bind(("127.0.0.1", port))
334        .map_err(|e| format!("net.serve_ws_fn bind {port}: {e}"))?;
335    eprintln!("net.serve_ws_fn: listening on ws://127.0.0.1:{port}");
336    for stream in listener.incoming() {
337        let stream = match stream {
338            Ok(s) => s,
339            Err(e) => { eprintln!("net.serve_ws_fn accept: {e}"); continue; }
340        };
341        let program = Arc::clone(&program);
342        let policy = policy.clone();
343        let closure = closure.clone();
344        let subprotocol = subprotocol.clone();
345        let registry = Arc::clone(&registry);
346        thread::spawn(move || {
347            if let Err(e) = handle_connection_fn(
348                stream, program, policy, closure, subprotocol, registry,
349            ) {
350                eprintln!("net.serve_ws_fn connection error: {e}");
351            }
352        });
353    }
354    Ok(Value::Unit)
355}
356
357fn handle_connection_fn(
358    stream: std::net::TcpStream,
359    program: Arc<Program>,
360    policy: Policy,
361    closure: Value,
362    subprotocol: String,
363    registry: Arc<ChatRegistry>,
364) -> Result<(), String> {
365    use tungstenite::{accept_hdr, handshake::server::{Request, Response}};
366
367    let mut path = String::new();
368    let path_ref = &mut path;
369    let subproto_for_handshake = subprotocol.clone();
370    let accepted = accept_hdr(stream, |req: &Request, mut resp: Response| {
371        *path_ref = req.uri().path().to_string();
372        maybe_echo_subprotocol(req, &mut resp, &subproto_for_handshake);
373        Ok(resp)
374    });
375    let mut ws = match accept_or_drop_probe(accepted)? {
376        Some(ws) => ws,
377        None => return Ok(()), // bare TCP probe — dropped silently
378    };
379
380    let (tx, rx) = mpsc::channel::<String>();
381    let conn_id = registry.register(path.trim_start_matches('/').to_string(), tx);
382    let _ = ws.get_mut().set_read_timeout(Some(Duration::from_millis(50)));
383
384    let result = run_loop_fn(
385        &mut ws, &rx, conn_id, &path, &subprotocol,
386        &program, &policy, &closure, &registry,
387    );
388    registry.unregister(conn_id);
389    let _ = ws.close(None);
390    result
391}
392
393/// RFC 6455 §4.1: the server MUST advertise the negotiated
394/// subprotocol back in the handshake response, and MUST select one
395/// of the values the client offered. Echo when (a) the server has
396/// a non-empty subprotocol, (b) the client offered subprotocols,
397/// and (c) the server's configured value is among the client's
398/// offers. Empty server-side configuration → no echo (matches the
399/// dial_ws contract). Shared by `handle_connection_fn` (the
400/// always-accept variant) and `handle_connection_fn_auth` (the
401/// pre-handshake-auth variant).
402fn maybe_echo_subprotocol(
403    req: &tungstenite::handshake::server::Request,
404    resp: &mut tungstenite::handshake::server::Response,
405    subprotocol: &str,
406) {
407    use tungstenite::http::HeaderValue;
408    if subprotocol.is_empty() {
409        return;
410    }
411    let offered = match req.headers().get("Sec-WebSocket-Protocol") {
412        Some(v) => v,
413        None    => return,
414    };
415    let offered_str = match offered.to_str() {
416        Ok(s)  => s,
417        Err(_) => return,
418    };
419    let matches = offered_str
420        .split(',')
421        .map(|p| p.trim())
422        .any(|p| p == subprotocol);
423    if !matches {
424        return;
425    }
426    // from_str cannot fail here: serve_ws_fn[_auth] validated
427    // `subprotocol` upfront. Belt-and-braces: skip silently if it
428    // somehow does.
429    if let Ok(h) = HeaderValue::from_str(subprotocol) {
430        resp.headers_mut().insert("Sec-WebSocket-Protocol", h);
431    }
432}
433
434// ── serve_ws_fn_auth — pre-handshake auth callback (#423) ─────────────────────
435//
436// Variant of `serve_ws_fn` that runs a Lex closure against the
437// upgrade request's path + headers *before* accepting the WS
438// handshake. The closure returns `Result[Unit, Str]`:
439//
440//   Ok(())  → handshake proceeds, on_message handler runs as usual
441//   Err(msg) → respond `401 Unauthorized` with `msg` as the body;
442//              the WS upgrade never completes, on_message is never
443//              called for this connection
444//
445// This is the hook the OCPP Security Profile 2 (Basic Auth) and
446// Profile 3 (Bearer JWT) flows need — both check an `Authorization`
447// header that's present at the HTTP upgrade but not exposed to user
448// code by `serve_ws_fn`. The crypto primitives (`argon2id`,
449// `hs256`) live downstream in lex-crypto / lex-ocpp; this builtin
450// is just the missing transport hook that lets them fire at the
451// right time.
452//
453// Effect-polymorphic in the same row that `serve_ws_fn` is — the
454// auth callback and the on_message handler share `[Eff]`, so a
455// caller can use `[sql]` (look up the CP's password hash) in auth
456// and the same `[sql]` in subsequent message handling without
457// duplicating the row declaration.
458
459/// Closure-based WebSocket server with a pre-handshake auth
460/// callback. Calls `auth_closure(path, headers)` before completing
461/// the WS upgrade; rejects with 401 Unauthorized when the closure
462/// returns `Err(msg)`. See `serve_ws_fn` for the post-handshake
463/// behaviour (identical once auth passes).
464pub fn serve_ws_fn_auth(
465    port: u16,
466    subprotocol: String,
467    auth_closure: Value,
468    handler_closure: Value,
469    program: Arc<Program>,
470    policy: Policy,
471    registry: Arc<ChatRegistry>,
472) -> Result<Value, String> {
473    if !subprotocol.is_empty() {
474        if let Err(e) =
475            tungstenite::http::HeaderValue::from_str(&subprotocol)
476        {
477            return Err(format!(
478                "net.serve_ws_fn_auth: subprotocol {subprotocol:?} is not a \
479                 valid HTTP header value: {e}"
480            ));
481        }
482    }
483    let listener = TcpListener::bind(("127.0.0.1", port))
484        .map_err(|e| format!("net.serve_ws_fn_auth bind {port}: {e}"))?;
485    eprintln!("net.serve_ws_fn_auth: listening on ws://127.0.0.1:{port}");
486    for stream in listener.incoming() {
487        let stream = match stream {
488            Ok(s) => s,
489            Err(e) => {
490                eprintln!("net.serve_ws_fn_auth accept: {e}");
491                continue;
492            }
493        };
494        let program = Arc::clone(&program);
495        let policy = policy.clone();
496        let auth_closure = auth_closure.clone();
497        let handler_closure = handler_closure.clone();
498        let subprotocol = subprotocol.clone();
499        let registry = Arc::clone(&registry);
500        thread::spawn(move || {
501            if let Err(e) = handle_connection_fn_auth(
502                stream, program, policy, auth_closure, handler_closure,
503                subprotocol, registry,
504            ) {
505                eprintln!("net.serve_ws_fn_auth connection error: {e}");
506            }
507        });
508    }
509    Ok(Value::Unit)
510}
511
512#[allow(clippy::too_many_arguments)]
513fn handle_connection_fn_auth(
514    stream: std::net::TcpStream,
515    program: Arc<Program>,
516    policy: Policy,
517    auth_closure: Value,
518    handler_closure: Value,
519    subprotocol: String,
520    registry: Arc<ChatRegistry>,
521) -> Result<(), String> {
522    use tungstenite::{accept_hdr, handshake::server::{Request, Response}};
523    use tungstenite::http::StatusCode;
524
525    let mut path = String::new();
526    let path_ref = &mut path;
527    let subproto_for_handshake = subprotocol.clone();
528    // `accept_hdr`'s callback is `FnOnce`, so the closures + program
529    // + policy + registry it needs are moved in directly (no clones
530    // inside the closure body).
531    let auth_program = Arc::clone(&program);
532    let auth_policy = policy.clone();
533    let auth_registry = Arc::clone(&registry);
534    let auth_closure_for_cb = auth_closure;
535
536    let accepted = accept_hdr(stream, move |req: &Request, mut resp: Response| {
537        *path_ref = req.uri().path().to_string();
538
539        let headers_value = build_headers_value(req);
540        let path_arg = Value::Str(path_ref.clone().into());
541
542        let dh = crate::handler::DefaultHandler::new(auth_policy.clone())
543            .with_program(Arc::clone(&auth_program))
544            .with_chat_registry(Arc::clone(&auth_registry));
545        let mut vm = Vm::with_handler(&auth_program, Box::new(dh));
546        let auth_result = vm.invoke_closure_value(
547            auth_closure_for_cb,
548            vec![path_arg, headers_value],
549        );
550
551        match auth_result {
552            Ok(Value::Variant { name, .. }) if name == "Ok" => {
553                maybe_echo_subprotocol(req, &mut resp, &subproto_for_handshake);
554                Ok(resp)
555            }
556            Ok(Value::Variant { name, args }) if name == "Err" => {
557                let msg = match args.first() {
558                    Some(Value::Str(s)) => s.to_string(),
559                    _                   => "unauthorized".to_string(),
560                };
561                let err = build_unauthorized_response(StatusCode::UNAUTHORIZED, msg);
562                Err(err)
563            }
564            Ok(other) => {
565                let err = build_unauthorized_response(
566                    StatusCode::INTERNAL_SERVER_ERROR,
567                    format!(
568                        "net.serve_ws_fn_auth: auth callback returned \
569                         non-Result value: {other:?}"
570                    ),
571                );
572                Err(err)
573            }
574            Err(e) => {
575                let err = build_unauthorized_response(
576                    StatusCode::INTERNAL_SERVER_ERROR,
577                    format!("net.serve_ws_fn_auth: auth callback error: {e:?}"),
578                );
579                Err(err)
580            }
581        }
582    });
583    let mut ws = match accept_or_drop_probe(accepted)? {
584        Some(ws) => ws,
585        None => return Ok(()), // bare TCP probe — dropped silently
586    };
587
588    let (tx, rx) = mpsc::channel::<String>();
589    let conn_id = registry.register(path.trim_start_matches('/').to_string(), tx);
590    let _ = ws.get_mut().set_read_timeout(Some(Duration::from_millis(50)));
591
592    let result = run_loop_fn(
593        &mut ws, &rx, conn_id, &path, &subprotocol,
594        &program, &policy, &handler_closure, &registry,
595    );
596    registry.unregister(conn_id);
597    let _ = ws.close(None);
598    result
599}
600
601/// Project the upgrade request headers into a Lex value of type
602/// `List[{ name :: Str, value :: Str }]`. Non-UTF-8 header values
603/// are skipped (the HTTP spec allows them, the Lex `Str` type
604/// doesn't — and OCPP Auth / JWT headers are ASCII by construction
605/// so a strict drop is safe here).
606fn build_headers_value(req: &tungstenite::handshake::server::Request) -> Value {
607    let mut items: std::collections::VecDeque<Value> = std::collections::VecDeque::new();
608    for (name, val) in req.headers().iter() {
609        let v = match val.to_str() {
610            Ok(s)  => s.to_string(),
611            Err(_) => continue,
612        };
613        let mut rec = IndexMap::new();
614        rec.insert("name".into(), Value::Str(name.as_str().into()));
615        rec.insert("value".into(), Value::Str(v.into()));
616        items.push_back(Value::record_dynamic(rec));
617    }
618    Value::List(items)
619}
620
621fn build_unauthorized_response(
622    status: tungstenite::http::StatusCode,
623    msg: String,
624) -> tungstenite::handshake::server::ErrorResponse {
625    tungstenite::http::Response::builder()
626        .status(status)
627        .header("Content-Type", "text/plain; charset=utf-8")
628        .body(Some(msg))
629        .expect("ErrorResponse builder")
630}
631
632#[allow(clippy::too_many_arguments)]
633fn run_loop_fn(
634    ws: &mut tungstenite::WebSocket<std::net::TcpStream>,
635    rx: &mpsc::Receiver<String>,
636    conn_id: u64,
637    path: &str,
638    subprotocol: &str,
639    program: &Arc<Program>,
640    policy: &Policy,
641    closure: &Value,
642    registry: &Arc<ChatRegistry>,
643) -> Result<(), String> {
644    use tungstenite::Message;
645    use std::io::ErrorKind;
646
647    let ws_conn = build_ws_conn(conn_id, path, subprotocol);
648
649    loop {
650        let ws_msg = match ws.read() {
651            Ok(Message::Text(body)) => Some(build_ws_message_text(&body)),
652            Ok(Message::Binary(_)) => None,
653            Ok(Message::Ping(_)) => Some(build_ws_message_ping()),
654            Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed) => {
655                // Notify handler then exit.
656                let handler = crate::handler::DefaultHandler::new(policy.clone())
657                    .with_program(Arc::clone(program))
658                    .with_chat_registry(Arc::clone(registry));
659                let mut vm = Vm::with_handler(program, Box::new(handler));
660                let _ = vm.invoke_closure_value(
661                    closure.clone(),
662                    vec![ws_conn.clone(), build_ws_message_close()],
663                );
664                break;
665            }
666            Ok(_) => None, // pong / frame
667            Err(tungstenite::Error::Io(ref e))
668                if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => None,
669            Err(e) => return Err(format!("ws read: {e}")),
670        };
671
672        if let Some(msg) = ws_msg {
673            let handler = crate::handler::DefaultHandler::new(policy.clone())
674                .with_program(Arc::clone(program))
675                .with_chat_registry(Arc::clone(registry));
676            let mut vm = Vm::with_handler(program, Box::new(handler));
677            match vm.invoke_closure_value(closure.clone(), vec![ws_conn.clone(), msg]) {
678                Ok(action) => {
679                    if let Err(e) = apply_ws_action(&action, ws) {
680                        eprintln!("ws action {conn_id}: {e}");
681                    }
682                }
683                Err(e) => eprintln!("ws handler {conn_id}: {e}"),
684            }
685        }
686
687        // Drain broadcast/send outbound channel.
688        loop {
689            match rx.try_recv() {
690                Ok(msg) => {
691                    if let Err(e) = ws.send(Message::Text(msg.into())) {
692                        return Err(format!("ws send: {e}"));
693                    }
694                }
695                Err(mpsc::TryRecvError::Empty) => break,
696                Err(mpsc::TryRecvError::Disconnected) => return Ok(()),
697            }
698        }
699    }
700    Ok(())
701}
702
703// ── serve_ws_fn_actor — outbound-bridge actor registration (#459) ────────────
704//
705// Variant of `serve_ws_fn` that registers each connection as a named
706// actor in `conc_registry`, so non-WS callers (HTTP webhooks, scheduled
707// tasks, broadcast loops) can push frames into the socket via
708// `conc.lookup(name) |> conc.tell(frame)`.
709//
710// Signature:
711//
712//   net.serve_ws_fn_actor(
713//     port        :: Int,
714//     subprotocol :: Str,
715//     name_of     :: (WsConn) -> Str,                       # per-connection name
716//     on_message  :: (WsConn, WsMessage) -> [E] WsAction    # same as serve_ws_fn
717//   ) -> [net, concurrent, io] Unit
718//
719// On each accepted connection, the runtime:
720//   1. Builds the WsConn record (same shape as serve_ws_fn) and calls
721//      `name_of(conn)`. An empty-string return means "don't register
722//      this connection" — the socket still accepts inbound frames and
723//      runs `on_message`, but no outbound handle is exposed.
724//   2. Builds an `ActorHandler::Native` bridge whose `send` closure
725//      writes the message body to the per-connection `mpsc::Sender<String>`
726//      that already exists for the broadcast path.
727//   3. Registers the bridge actor under the name. Name collisions
728//      (`AlreadyRegistered`) abort the connection — surface the bug at
729//      the source level rather than silently overwriting an existing
730//      session.
731//   4. On disconnect, unregisters the name and tears down the socket.
732//
733// The inbound half (read frame → call `on_message` → apply `WsAction`)
734// is identical to `serve_ws_fn`.
735//
736// Out-of-scope for v1: binary message dispatch from a non-WS task. The
737// native bridge only accepts `Value::Str`; binary frames need a tagged
738// message type (`WsOut::Text(Str) | WsOut::Binary(List[Int])`) that the
739// native handler can match on. Filed as a follow-up.
740pub fn serve_ws_fn_actor(
741    port: u16,
742    subprotocol: String,
743    name_of_closure: Value,
744    on_message_closure: Value,
745    program: Arc<Program>,
746    policy: Policy,
747    registry: Arc<ChatRegistry>,
748) -> Result<Value, String> {
749    if !subprotocol.is_empty() {
750        if let Err(e) =
751            tungstenite::http::HeaderValue::from_str(&subprotocol)
752        {
753            return Err(format!(
754                "net.serve_ws_fn_actor: subprotocol {subprotocol:?} is not a valid \
755                 HTTP header value: {e}"
756            ));
757        }
758    }
759    let listener = TcpListener::bind(("127.0.0.1", port))
760        .map_err(|e| format!("net.serve_ws_fn_actor bind {port}: {e}"))?;
761    eprintln!("net.serve_ws_fn_actor: listening on ws://127.0.0.1:{port}");
762    for stream in listener.incoming() {
763        let stream = match stream {
764            Ok(s) => s,
765            Err(e) => { eprintln!("net.serve_ws_fn_actor accept: {e}"); continue; }
766        };
767        let program = Arc::clone(&program);
768        let policy = policy.clone();
769        let name_of_closure = name_of_closure.clone();
770        let on_message_closure = on_message_closure.clone();
771        let subprotocol = subprotocol.clone();
772        let registry = Arc::clone(&registry);
773        thread::spawn(move || {
774            if let Err(e) = handle_connection_fn_actor(
775                stream, program, policy, name_of_closure, on_message_closure,
776                subprotocol, registry,
777            ) {
778                eprintln!("net.serve_ws_fn_actor connection error: {e}");
779            }
780        });
781    }
782    Ok(Value::Unit)
783}
784
785#[allow(clippy::too_many_arguments)]
786fn handle_connection_fn_actor(
787    stream: std::net::TcpStream,
788    program: Arc<Program>,
789    policy: Policy,
790    name_of_closure: Value,
791    on_message_closure: Value,
792    subprotocol: String,
793    registry: Arc<ChatRegistry>,
794) -> Result<(), String> {
795    use tungstenite::{accept_hdr, handshake::server::{Request, Response}};
796
797    let mut path = String::new();
798    let path_ref = &mut path;
799    let subproto_for_handshake = subprotocol.clone();
800    let accepted = accept_hdr(stream, |req: &Request, mut resp: Response| {
801        *path_ref = req.uri().path().to_string();
802        maybe_echo_subprotocol(req, &mut resp, &subproto_for_handshake);
803        Ok(resp)
804    });
805    let mut ws = match accept_or_drop_probe(accepted)? {
806        Some(ws) => ws,
807        None => return Ok(()), // bare TCP probe — dropped silently
808    };
809
810    let (tx, rx) = mpsc::channel::<String>();
811    let conn_id = registry.register(path.trim_start_matches('/').to_string(), tx.clone());
812    let _ = ws.get_mut().set_read_timeout(Some(Duration::from_millis(50)));
813
814    // Build the WsConn record and ask the user's name_of closure what
815    // name to register this connection under. Empty string is the
816    // documented opt-out (inbound still works, no outbound handle is
817    // exposed). Type mismatch or runtime error aborts the connection —
818    // the same "surface the bug at the source level" reasoning as a
819    // `conc.register` name collision below; silently degrading to an
820    // unregistered connection would have a non-WS caller's
821    // `conc.lookup` return None and conclude the session is offline.
822    let ws_conn = build_ws_conn(conn_id, &path, &subprotocol);
823    let registered_name: Option<String> = {
824        let handler = crate::handler::DefaultHandler::new(policy.clone())
825            .with_program(Arc::clone(&program))
826            .with_chat_registry(Arc::clone(&registry));
827        let mut vm = Vm::with_handler(&program, Box::new(handler));
828        match vm.invoke_closure_value(name_of_closure.clone(), vec![ws_conn.clone()]) {
829            Ok(Value::Str(s)) if !s.is_empty() => Some(s.to_string()),
830            Ok(Value::Str(_)) => None,
831            Ok(other) => {
832                registry.unregister(conn_id);
833                let _ = ws.close(None);
834                return Err(format!(
835                    "net.serve_ws_fn_actor: name_of must return Str, got {other:?}"
836                ));
837            }
838            Err(e) => {
839                registry.unregister(conn_id);
840                let _ = ws.close(None);
841                return Err(format!(
842                    "net.serve_ws_fn_actor: name_of error: {e:?}"
843                ));
844            }
845        }
846    };
847
848    // Register the native bridge actor in the conc registry. The
849    // bridge captures `tx` and writes any inbound message body to the
850    // outbound channel, which the run loop drains into the socket.
851    if let Some(ref name) = registered_name {
852        let tx_for_bridge = tx.clone();
853        let bridge = lex_bytecode::value::NativeActorHandler {
854            send: Box::new(move |msg: Value| -> Result<Value, String> {
855                match msg {
856                    Value::Str(s) => {
857                        tx_for_bridge.send(s.to_string()).map_err(|e| {
858                            format!("net.serve_ws_fn_actor: outbound channel closed: {e}")
859                        })?;
860                        Ok(Value::Unit)
861                    }
862                    other => Err(format!(
863                        "net.serve_ws_fn_actor: native bridge accepts Str messages only, got {other:?}"
864                    )),
865                }
866            }),
867        };
868        let cell = Value::Actor(Arc::new(Mutex::new(lex_bytecode::value::ActorCell {
869            state: Value::Unit,
870            handler: lex_bytecode::value::ActorHandler::Native(Arc::new(bridge)),
871        })));
872        if let Err(e) = lex_bytecode::conc_registry::register(name, cell) {
873            // Name collision: abort the connection. Surfacing the
874            // duplicate immediately is more useful than silently
875            // overwriting whichever session was registered first.
876            registry.unregister(conn_id);
877            let _ = ws.close(None);
878            return Err(format!(
879                "net.serve_ws_fn_actor: conc.register({name:?}) failed: {e:?}"
880            ));
881        }
882    }
883
884    let result = run_loop_fn(
885        &mut ws, &rx, conn_id, &path, &subprotocol,
886        &program, &policy, &on_message_closure, &registry,
887    );
888
889    // Tear down: unregister from both the conc registry (so a subsequent
890    // `conc.lookup(name)` returns None) and the chat registry.
891    if let Some(ref name) = registered_name {
892        let _ = lex_bytecode::conc_registry::unregister(name);
893    }
894    registry.unregister(conn_id);
895    let _ = ws.close(None);
896    result
897}
898
899// ── Closure-based WebSocket client (#390) ────────────────────────────────────
900//
901// Inverse of `serve_ws_fn`: open a connection to a remote WS server and
902// run two Lex callbacks against it.
903//
904// - `on_open : () -> [E] WsAction` is invoked once after the handshake
905//   completes. The returned `WsAction` (typically `WsSend(boot_frame)`)
906//   is applied to the socket immediately. This is the hook for
907//   protocols like OCPP where the client sends a `BootNotification`
908//   the moment it connects.
909// - `on_message : (WsMessage) -> [E] WsAction` is invoked for every
910//   inbound frame. Same `WsAction` semantics as the server-side
911//   handler. A `WsClose` message is delivered once before the loop
912//   exits so handlers can run shutdown logic.
913//
914// Multi-frame sends from `on_open` (e.g. a charger that wants to
915// also kick off a heartbeat scheduler at connect-time) aren't
916// expressible in v1 — the issue's `send :: (Str) -> [net]
917// Result[Unit, Str]` closure would let users push outbound frames
918// from arbitrary `[net]` code, but that requires representing
919// Rust-native closures as Lex `Value`s, which is a separate
920// runtime change. v1 covers the BootNotification + reactive reply
921// pattern that motivates the issue.
922
923fn build_dial_result(ok: Result<(), String>) -> Value {
924    match ok {
925        Ok(()) => Value::Variant {
926            name: "Ok".into(),
927            args: vec![Value::Unit],
928        },
929        Err(msg) => Value::Variant {
930            name: "Err".into(),
931            args: vec![Value::Str(msg.into())],
932        },
933    }
934}
935
936/// `net.dial_ws(url, subprotocol, on_open, on_message) -> [net, E]
937/// Result[Unit, Str]`. Blocks for the lifetime of the connection;
938/// returns `Ok(())` on a clean close from the server, `Err(reason)`
939/// on dial failure, handshake failure, read error, or write error.
940pub fn dial_ws(
941    url: String,
942    subprotocol: String,
943    on_open: Value,
944    on_message: Value,
945    program: Arc<Program>,
946    policy: Policy,
947) -> Result<Value, String> {
948    use tungstenite::client::IntoClientRequest;
949    use tungstenite::http::HeaderValue;
950
951    // Build the request — when `subprotocol` is non-empty, attach the
952    // Sec-WebSocket-Protocol header so the server's accept-handler
953    // can match on it. Empty subprotocol → header omitted (the same
954    // contract as `serve_ws_fn`'s subprotocol arg).
955    //
956    // Caller-controlled inputs (URL syntax, subprotocol header value)
957    // surface as a Lex `Err(reason)`, not a Rust panic / handler
958    // error, so `match net.dial_ws(...) { Err(_) => ..., Ok(_) => ... }`
959    // works at the Lex level.
960    let mut req = match url.as_str().into_client_request() {
961        Ok(r) => r,
962        Err(e) => {
963            return Ok(build_dial_result(Err(format!(
964                "net.dial_ws: bad URL `{url}`: {e}"
965            ))));
966        }
967    };
968    if !subprotocol.is_empty() {
969        let header = match HeaderValue::from_str(&subprotocol) {
970            Ok(h) => h,
971            Err(e) => {
972                return Ok(build_dial_result(Err(format!(
973                    "net.dial_ws: invalid subprotocol `{subprotocol}`: {e}"
974                ))));
975            }
976        };
977        req.headers_mut().insert("Sec-WebSocket-Protocol", header);
978    }
979
980    let (mut ws, _resp) = match tungstenite::connect(req) {
981        Ok(pair) => pair,
982        Err(e) => {
983            return Ok(build_dial_result(Err(format!(
984                "net.dial_ws: connect to `{url}`: {e}"
985            ))));
986        }
987    };
988
989    // Non-blocking-ish reads so we don't tie up the thread on an idle
990    // socket, mirroring the server's read-timeout multiplexing.
991    if let Some(stream) = stream_for(&mut ws) {
992        let _ = stream.set_read_timeout(Some(Duration::from_millis(50)));
993    }
994
995    // 1. Fire on_open once and apply its action.
996    {
997        let handler = crate::handler::DefaultHandler::new(policy.clone())
998            .with_program(Arc::clone(&program));
999        let mut vm = Vm::with_handler(&program, Box::new(handler));
1000        match vm.invoke_closure_value(on_open.clone(), vec![]) {
1001            Ok(action) => {
1002                if let Err(e) = apply_ws_action(&action, &mut ws) {
1003                    return Ok(build_dial_result(Err(format!(
1004                        "net.dial_ws: on_open action: {e}"
1005                    ))));
1006                }
1007            }
1008            Err(e) => {
1009                return Ok(build_dial_result(Err(format!(
1010                    "net.dial_ws: on_open: {e}"
1011                ))));
1012            }
1013        }
1014    }
1015
1016    // 2. Run the read loop, dispatching each inbound frame to on_message.
1017    let loop_result = dial_run_loop(&mut ws, &on_message, &program, &policy);
1018    let _ = ws.close(None);
1019    Ok(build_dial_result(loop_result))
1020}
1021
1022/// Pull the underlying TCP stream out of a `MaybeTlsStream` so we can
1023/// set a read timeout. For plaintext connections this is the
1024/// `TcpStream` directly; for `rustls`-wrapped streams it's the inner
1025/// socket. Returns `None` if the wrapping is some other variant —
1026/// in that case we just skip the timeout and rely on blocking reads.
1027fn stream_for(
1028    ws: &mut tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<std::net::TcpStream>>,
1029) -> Option<&mut std::net::TcpStream> {
1030    use tungstenite::stream::MaybeTlsStream;
1031    match ws.get_mut() {
1032        MaybeTlsStream::Plain(s) => Some(s),
1033        MaybeTlsStream::Rustls(s) => Some(s.get_mut()),
1034        _ => None,
1035    }
1036}
1037
1038// ── dial_ws_actor — outbound-bridge actor for WS clients (#dial-actor) ──────
1039//
1040// Variant of `dial_ws` that registers the outgoing connection as a named
1041// actor in the conc registry, enabling `conc.tell(actor, frame_str)` to push
1042// frames into the socket from any other Lex actor (heartbeat timers,
1043// meter-value loops, etc.).
1044//
1045//   net.dial_ws_actor(
1046//     url         :: Str,
1047//     subprotocol :: Str,
1048//     name        :: Str,               — conc registry key ("" to skip)
1049//     on_open     :: () -> [E] WsAction,
1050//     on_message  :: (WsMessage) -> [E] WsAction
1051//   ) -> [net, E] Result[Unit, Str]
1052pub fn dial_ws_actor(
1053    url: String,
1054    subprotocol: String,
1055    name: String,
1056    on_open: Value,
1057    on_message: Value,
1058    program: Arc<Program>,
1059    policy: Policy,
1060) -> Result<Value, String> {
1061    use tungstenite::client::IntoClientRequest;
1062    use tungstenite::http::HeaderValue;
1063
1064    let mut req = match url.as_str().into_client_request() {
1065        Ok(r) => r,
1066        Err(e) => {
1067            return Ok(build_dial_result(Err(format!(
1068                "net.dial_ws_actor: bad URL `{url}`: {e}"
1069            ))));
1070        }
1071    };
1072    if !subprotocol.is_empty() {
1073        let header = match HeaderValue::from_str(&subprotocol) {
1074            Ok(h) => h,
1075            Err(e) => {
1076                return Ok(build_dial_result(Err(format!(
1077                    "net.dial_ws_actor: invalid subprotocol `{subprotocol}`: {e}"
1078                ))));
1079            }
1080        };
1081        req.headers_mut().insert("Sec-WebSocket-Protocol", header);
1082    }
1083
1084    let (mut ws, _resp) = match tungstenite::connect(req) {
1085        Ok(pair) => pair,
1086        Err(e) => {
1087            return Ok(build_dial_result(Err(format!(
1088                "net.dial_ws_actor: connect to `{url}`: {e}"
1089            ))));
1090        }
1091    };
1092    if let Some(stream) = stream_for(&mut ws) {
1093        let _ = stream.set_read_timeout(Some(Duration::from_millis(50)));
1094    }
1095
1096    // Set up the actor bridge — mpsc channel whose Sender end is wrapped in a
1097    // native actor cell registered in the conc registry.
1098    let (tx, rx) = mpsc::channel::<String>();
1099    if !name.is_empty() {
1100        let tx_for_bridge = tx.clone();
1101        let bridge = lex_bytecode::value::NativeActorHandler {
1102            send: Box::new(move |msg: Value| -> Result<Value, String> {
1103                match msg {
1104                    Value::Str(s) => tx_for_bridge
1105                        .send(s.to_string())
1106                        .map(|_| Value::Unit)
1107                        .map_err(|e| format!("net.dial_ws_actor: outbound channel closed: {e}")),
1108                    other => Err(format!(
1109                        "net.dial_ws_actor: native bridge accepts Str only, got {other:?}"
1110                    )),
1111                }
1112            }),
1113        };
1114        let cell = Value::Actor(Arc::new(Mutex::new(lex_bytecode::value::ActorCell {
1115            state: Value::Unit,
1116            handler: lex_bytecode::value::ActorHandler::Native(Arc::new(bridge)),
1117        })));
1118        if let Err(e) = lex_bytecode::conc_registry::register(&name, cell) {
1119            return Ok(build_dial_result(Err(format!(
1120                "net.dial_ws_actor: conc.register({name:?}) failed: {e:?}"
1121            ))));
1122        }
1123    }
1124
1125    // Fire on_open once, apply its WsAction to the socket.
1126    {
1127        let handler = crate::handler::DefaultHandler::new(policy.clone())
1128            .with_program(Arc::clone(&program));
1129        let mut vm = Vm::with_handler(&program, Box::new(handler));
1130        match vm.invoke_closure_value(on_open.clone(), vec![]) {
1131            Ok(action) => {
1132                if let Err(e) = apply_ws_action(&action, &mut ws) {
1133                    if !name.is_empty() {
1134                        let _ = lex_bytecode::conc_registry::unregister(&name);
1135                    }
1136                    return Ok(build_dial_result(Err(format!(
1137                        "net.dial_ws_actor: on_open action: {e}"
1138                    ))));
1139                }
1140            }
1141            Err(e) => {
1142                if !name.is_empty() {
1143                    let _ = lex_bytecode::conc_registry::unregister(&name);
1144                }
1145                return Ok(build_dial_result(Err(format!(
1146                    "net.dial_ws_actor: on_open: {e}"
1147                ))));
1148            }
1149        }
1150    }
1151
1152    let loop_result = dial_actor_run_loop(&mut ws, &rx, &on_message, &program, &policy);
1153    if !name.is_empty() {
1154        let _ = lex_bytecode::conc_registry::unregister(&name);
1155    }
1156    let _ = ws.close(None);
1157    Ok(build_dial_result(loop_result))
1158}
1159
1160fn dial_actor_run_loop(
1161    ws: &mut tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<std::net::TcpStream>>,
1162    rx: &mpsc::Receiver<String>,
1163    on_message: &Value,
1164    program: &Arc<Program>,
1165    policy: &Policy,
1166) -> Result<(), String> {
1167    use std::io::ErrorKind;
1168    use tungstenite::Message;
1169
1170    loop {
1171        let ws_msg = match ws.read() {
1172            Ok(Message::Text(body)) => Some(build_ws_message_text(&body)),
1173            Ok(Message::Binary(payload)) => Some(build_ws_message_binary(&payload)),
1174            Ok(Message::Ping(_)) => Some(build_ws_message_ping()),
1175            Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed) => {
1176                let handler = crate::handler::DefaultHandler::new(policy.clone())
1177                    .with_program(Arc::clone(program));
1178                let mut vm = Vm::with_handler(program, Box::new(handler));
1179                let _ = vm.invoke_closure_value(
1180                    on_message.clone(),
1181                    vec![build_ws_message_close()],
1182                );
1183                return Ok(());
1184            }
1185            Ok(_) => None,
1186            Err(tungstenite::Error::Io(ref e))
1187                if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut =>
1188            {
1189                None
1190            }
1191            Err(e) => return Err(format!("net.dial_ws_actor: read: {e}")),
1192        };
1193
1194        if let Some(msg) = ws_msg {
1195            let handler = crate::handler::DefaultHandler::new(policy.clone())
1196                .with_program(Arc::clone(program));
1197            let mut vm = Vm::with_handler(program, Box::new(handler));
1198            match vm.invoke_closure_value(on_message.clone(), vec![msg]) {
1199                Ok(action) => {
1200                    if let Err(e) = apply_ws_action(&action, ws) {
1201                        return Err(format!("net.dial_ws_actor: action: {e}"));
1202                    }
1203                }
1204                Err(e) => return Err(format!("net.dial_ws_actor: on_message: {e}")),
1205            }
1206        }
1207
1208        // Drain the actor mailbox — frames enqueued by conc.tell(actor, frame).
1209        loop {
1210            match rx.try_recv() {
1211                Ok(msg) => {
1212                    if let Err(e) = ws.send(Message::Text(msg.into())) {
1213                        return Err(format!("net.dial_ws_actor: send: {e}"));
1214                    }
1215                }
1216                Err(mpsc::TryRecvError::Empty) => break,
1217                Err(mpsc::TryRecvError::Disconnected) => return Ok(()),
1218            }
1219        }
1220    }
1221}
1222
1223fn dial_run_loop(
1224    ws: &mut tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<std::net::TcpStream>>,
1225    on_message: &Value,
1226    program: &Arc<Program>,
1227    policy: &Policy,
1228) -> Result<(), String> {
1229    use std::io::ErrorKind;
1230    use tungstenite::Message;
1231
1232    loop {
1233        let ws_msg = match ws.read() {
1234            Ok(Message::Text(body)) => Some(build_ws_message_text(&body)),
1235            Ok(Message::Binary(payload)) => Some(build_ws_message_binary(&payload)),
1236            Ok(Message::Ping(_)) => Some(build_ws_message_ping()),
1237            Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed) => {
1238                // Deliver WsClose so the handler can do shutdown work.
1239                let handler = crate::handler::DefaultHandler::new(policy.clone())
1240                    .with_program(Arc::clone(program));
1241                let mut vm = Vm::with_handler(program, Box::new(handler));
1242                let _ = vm.invoke_closure_value(
1243                    on_message.clone(),
1244                    vec![build_ws_message_close()],
1245                );
1246                return Ok(());
1247            }
1248            Ok(_) => None, // pong / raw frame
1249            Err(tungstenite::Error::Io(ref e))
1250                if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut =>
1251            {
1252                None
1253            }
1254            Err(e) => return Err(format!("net.dial_ws: read: {e}")),
1255        };
1256
1257        if let Some(msg) = ws_msg {
1258            let handler = crate::handler::DefaultHandler::new(policy.clone())
1259                .with_program(Arc::clone(program));
1260            let mut vm = Vm::with_handler(program, Box::new(handler));
1261            match vm.invoke_closure_value(on_message.clone(), vec![msg]) {
1262                Ok(action) => {
1263                    if let Err(e) = apply_ws_action(&action, ws) {
1264                        return Err(format!("net.dial_ws: action: {e}"));
1265                    }
1266                }
1267                Err(e) => return Err(format!("net.dial_ws: on_message: {e}")),
1268            }
1269        }
1270    }
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275    use super::accept_or_drop_probe;
1276    use tungstenite::handshake::server::{NoCallback, ServerHandshake};
1277    use tungstenite::{error::ProtocolError, Error, HandshakeError, WebSocket};
1278
1279    type ProbeResult = Result<
1280        WebSocket<std::net::TcpStream>,
1281        HandshakeError<ServerHandshake<std::net::TcpStream, NoCallback>>,
1282    >;
1283
1284    /// A bare TCP probe (open + close without completing the HTTP
1285    /// upgrade) surfaces as `HandshakeIncomplete` and must be dropped
1286    /// silently — `Ok(None)`, so the caller returns cleanly with no
1287    /// logged connection error. (#624)
1288    #[test]
1289    fn handshake_incomplete_is_dropped_as_probe() {
1290        let probe: ProbeResult = Err(HandshakeError::Failure(Error::Protocol(
1291            ProtocolError::HandshakeIncomplete,
1292        )));
1293        assert!(matches!(accept_or_drop_probe(probe), Ok(None)));
1294    }
1295
1296    /// A genuine handshake failure is still surfaced as an error so it
1297    /// is logged as a connection error.
1298    #[test]
1299    fn genuine_handshake_failure_is_surfaced() {
1300        let failure: ProbeResult = Err(HandshakeError::Failure(Error::ConnectionClosed));
1301        assert!(accept_or_drop_probe(failure).is_err());
1302    }
1303}