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