Skip to main content

h2ts_server/
wslay.rs

1//! wslay framing (the WebSocket ⇄ byte-stream pump).
2//!
3//! wslay, driven through its event API with `no_buffering` enabled, delivers
4//! each frame's payload **incrementally** via `on_frame_recv_chunk_callback` —
5//! so we never hold a whole frame in memory, no matter how large. Control frames
6//! (ping / close) are auto-handled by wslay.
7//!
8//! wslay is a synchronous, I/O-agnostic state machine: it never touches the
9//! socket itself, only in-memory buffers via callbacks. We drive it from async
10//! Rust — feeding it bytes we read from the WebSocket and flushing bytes it
11//! produces. The HTTP upgrade is done separately in [`accept`](crate::accept),
12//! which hands us the raw upgraded byte stream (no frames have been read yet, so
13//! nothing is lost).
14//!
15//! The wslay context and the shared-state cell are reached from the two
16//! directions as `usize` addresses (which are `Send`), cast back to raw pointers
17//! only inside the synchronous `unsafe` sections. This keeps the future `Send`
18//! (so it composes like a normal async fn) while never letting a bare pointer
19//! live across an `.await`. It is sound because everything runs on one task: the
20//! two directions interleave at awaits but never execute concurrently.
21
22use std::cell::RefCell;
23use std::ffi::c_void;
24use std::future;
25use std::io;
26use std::os::raw::c_int;
27use std::ptr;
28use std::slice;
29use std::sync::Arc;
30use std::time::{Duration, Instant};
31
32use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
33use tokio::sync::{mpsc, Mutex};
34use wslay_sys::*;
35
36const READ_CHUNK: usize = 64 * 1024;
37
38/// Single-task state, shared between async Rust (between wslay calls) and the C
39/// callbacks (during wslay calls). No borrow is ever held across an `.await`,
40/// and everything runs on one task, so the accesses never overlap.
41#[derive(Default)]
42struct Shared {
43    /// Raw WS bytes read from the socket, consumed by `recv_cb`.
44    inbound: Vec<u8>,
45    inbound_pos: usize,
46    /// Bytes wslay wants sent on the WS (framed data + pong/close replies).
47    outbound: Vec<u8>,
48    /// Decoded payload of data frames, to forward to the peer.
49    to_peer: Vec<u8>,
50    /// Whether the frame currently being received is a data (non-control) frame.
51    cur_is_data: bool,
52    /// Received control frames (close/ping/pong), surfaced to the bridge's hooks.
53    control_events: Vec<ControlEvent>,
54}
55
56/// An inbound WebSocket control frame, surfaced from a wslay callback.
57enum ControlEvent {
58    Close { code: u16, reason: Vec<u8> },
59    Ping(Vec<u8>),
60    Pong(Vec<u8>),
61}
62
63#[inline]
64unsafe fn shared<'a>(user_data: *mut c_void) -> &'a RefCell<Shared> {
65    &*(user_data as *const RefCell<Shared>)
66}
67
68/// wslay wants raw bytes from the peer; feed it from `inbound`.
69unsafe extern "C" fn recv_cb(
70    ctx: wslay_event_context_ptr,
71    buf: *mut u8,
72    len: usize,
73    _flags: c_int,
74    user_data: *mut c_void,
75) -> isize {
76    let mut s = shared(user_data).borrow_mut();
77    let avail = s.inbound.len() - s.inbound_pos;
78    if avail == 0 {
79        wslay_event_set_error(ctx, wslay_error_WSLAY_ERR_WOULDBLOCK); // non-blocking: stop
80        return -1;
81    }
82    let n = len.min(avail);
83    let from = s.inbound_pos;
84    ptr::copy_nonoverlapping(s.inbound.as_ptr().add(from), buf, n);
85    s.inbound_pos += n;
86    n as isize
87}
88
89/// wslay wants to send raw bytes; buffer them for the async writer.
90unsafe extern "C" fn send_cb(
91    _ctx: wslay_event_context_ptr,
92    data: *const u8,
93    len: usize,
94    _flags: c_int,
95    user_data: *mut c_void,
96) -> isize {
97    shared(user_data)
98        .borrow_mut()
99        .outbound
100        .extend_from_slice(slice::from_raw_parts(data, len));
101    len as isize
102}
103
104/// A new frame started; remember whether it carries data (vs. a control frame).
105unsafe extern "C" fn frame_start_cb(
106    _ctx: wslay_event_context_ptr,
107    arg: *const wslay_event_on_frame_recv_start_arg,
108    user_data: *mut c_void,
109) {
110    // is_ctrl_frame(opcode) == (opcode >> 3) & 1
111    shared(user_data).borrow_mut().cur_is_data = ((*arg).opcode >> 3) & 1 == 0;
112}
113
114/// A chunk of the current frame's payload arrived — forward it if it's data.
115/// This is the incremental, never-buffer-a-whole-frame path.
116unsafe extern "C" fn frame_chunk_cb(
117    _ctx: wslay_event_context_ptr,
118    arg: *const wslay_event_on_frame_recv_chunk_arg,
119    user_data: *mut c_void,
120) {
121    let mut s = shared(user_data).borrow_mut();
122    if s.cur_is_data {
123        let chunk = slice::from_raw_parts((*arg).data, (*arg).data_length);
124        s.to_peer.extend_from_slice(chunk);
125    }
126}
127
128/// A complete message was received. Under `no_buffering`, data messages arrive
129/// here with a NULL payload (already streamed via `frame_chunk_cb`); control
130/// frames (close/ping/pong) are still delivered complete. Surface the control
131/// ones so the bridge can hand them to the caller's hooks. wslay has already
132/// auto-queued the pong (for a ping) / close echo by the time this runs.
133unsafe extern "C" fn msg_recv_cb(
134    _ctx: wslay_event_context_ptr,
135    arg: *const wslay_event_on_msg_recv_arg,
136    user_data: *mut c_void,
137) {
138    let arg = &*arg;
139    // Opcodes: 0x8 close, 0x9 ping, 0xA pong. Data frames (0x0/0x1/0x2) arrive
140    // with a NULL msg under no_buffering and are ignored here.
141    let event = match arg.opcode {
142        0x8 => {
143            // Close payload is [2-byte code][reason]; wslay pre-parsed the code.
144            let reason = if arg.msg_length >= 2 && !arg.msg.is_null() {
145                slice::from_raw_parts(arg.msg.add(2), arg.msg_length - 2).to_vec()
146            } else {
147                Vec::new()
148            };
149            Some(ControlEvent::Close {
150                code: arg.status_code,
151                reason,
152            })
153        }
154        0x9 | 0xA => {
155            let payload = if arg.msg.is_null() || arg.msg_length == 0 {
156                Vec::new()
157            } else {
158                slice::from_raw_parts(arg.msg, arg.msg_length).to_vec()
159            };
160            Some(if arg.opcode == 0x9 {
161                ControlEvent::Ping(payload)
162            } else {
163                ControlEvent::Pong(payload)
164            })
165        }
166        _ => None,
167    };
168    if let Some(ev) = event {
169        shared(user_data).borrow_mut().control_events.push(ev);
170    }
171}
172
173/// Frees the wslay context on drop (even on early return / panic). Holds the
174/// context as a `usize` address so the guard is naturally `Send` and no bare
175/// pointer is ever kept across an `.await`.
176struct CtxGuard(usize);
177impl Drop for CtxGuard {
178    fn drop(&mut self) {
179        unsafe { wslay_event_context_free(self.0 as wslay_event_context_ptr) };
180    }
181}
182
183/// A WebSocket close: status code and (UTF-8) reason. Uses RFC 6455 close codes;
184/// the reason should be at most 123 bytes.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct CloseFrame {
187    pub code: u16,
188    pub reason: String,
189}
190
191impl Default for CloseFrame {
192    /// 1000 (Normal Closure), empty reason.
193    fn default() -> Self {
194        Self {
195            code: wslay_status_code_WSLAY_CODE_NORMAL_CLOSURE as u16,
196            reason: String::new(),
197        }
198    }
199}
200
201/// A control frame to send into a running bridge (see [`WsControl`]).
202enum ControlCmd {
203    Ping(Vec<u8>),
204    Pong(Vec<u8>),
205    Close(CloseFrame),
206}
207
208/// Sends WebSocket control frames into a running [`bridge_with`]. Cheaply
209/// cloneable; call from any task. Frames are queued and flushed by the bridge on
210/// its next turn; a send fails only once the bridge has ended.
211#[derive(Clone)]
212pub struct WsControl {
213    tx: mpsc::UnboundedSender<ControlCmd>,
214}
215
216impl WsControl {
217    /// Send a Ping with `payload` (≤125 bytes). The peer's Pong surfaces via
218    /// [`BridgeConfig::on_pong`].
219    pub fn ping(&self, payload: impl Into<Vec<u8>>) -> io::Result<()> {
220        self.send(ControlCmd::Ping(payload.into()))
221    }
222
223    /// Send an unsolicited Pong with `payload` (≤125 bytes).
224    pub fn pong(&self, payload: impl Into<Vec<u8>>) -> io::Result<()> {
225        self.send(ControlCmd::Pong(payload.into()))
226    }
227
228    /// Queue a Close with `code` and `reason` (≤123 bytes); the bridge winds down
229    /// after the peer's close echo.
230    pub fn close(&self, code: u16, reason: impl Into<String>) -> io::Result<()> {
231        self.send(ControlCmd::Close(CloseFrame {
232            code,
233            reason: reason.into(),
234        }))
235    }
236
237    fn send(&self, cmd: ControlCmd) -> io::Result<()> {
238        self.tx
239            .send(cmd)
240            .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "bridge has ended"))
241    }
242}
243
244/// The receiving half of a [`control_channel`]; give it to
245/// [`BridgeConfig::control`].
246pub struct ControlReceiver(mpsc::UnboundedReceiver<ControlCmd>);
247
248/// Create a control channel. Keep the [`WsControl`] to send control frames; put
249/// the [`ControlReceiver`] in [`BridgeConfig::control`].
250pub fn control_channel() -> (WsControl, ControlReceiver) {
251    let (tx, rx) = mpsc::unbounded_channel();
252    (WsControl { tx }, ControlReceiver(rx))
253}
254
255/// Hook invoked with the close frame describing why the connection ended.
256pub type CloseHook = Box<dyn FnMut(&CloseFrame) + Send>;
257/// Hook invoked with a received Ping/Pong payload.
258pub type ControlHook = Box<dyn FnMut(&[u8]) + Send>;
259
260/// Server-initiated keepalive: send a Ping when the connection goes idle, and
261/// disconnect the peer if it doesn't respond in time.
262///
263/// This is the *proactive* half of ping/pong — wslay already auto-answers
264/// *incoming* pings, but never initiates its own. Browsers can't send pings from
265/// JavaScript, so liveness for an h2ts client must be driven from the server;
266/// the browser's platform auto-answers these pings transparently.
267#[derive(Debug, Clone)]
268pub struct KeepAlive {
269    /// Send a Ping once the connection has been idle (no frame received) this long.
270    pub interval: Duration,
271    /// If no frame arrives within this long after that Ping, close the peer.
272    pub timeout: Duration,
273    /// Close frame sent to the peer, and surfaced to [`BridgeConfig::on_close`],
274    /// when keepalive fails. Defaults to 1001 (Going Away), "keepalive timeout".
275    pub close: CloseFrame,
276}
277
278impl KeepAlive {
279    /// Keepalive with the given ping `interval` and response `timeout`, closing
280    /// with 1001 (Going Away) on failure.
281    pub fn new(interval: Duration, timeout: Duration) -> Self {
282        Self {
283            interval,
284            timeout,
285            close: CloseFrame {
286                code: wslay_status_code_WSLAY_CODE_GOING_AWAY as u16,
287                reason: "keepalive timeout".to_string(),
288            },
289        }
290    }
291}
292
293/// Control-frame configuration and hooks for [`bridge_with`] /
294/// [`serve_h2_with`](crate::serve_h2_with).
295///
296/// [`BridgeConfig::default`] reproduces plain [`bridge`] behaviour: wslay
297/// auto-answers pings with pongs, a Normal Closure is sent on teardown, no
298/// keepalive, and no hooks fire. All fields are opt-in.
299#[derive(Default)]
300pub struct BridgeConfig {
301    /// Close frame this side sends when it starts the close (e.g. the peer /
302    /// upstream reached EOF). Defaults to 1000 Normal Closure, empty reason.
303    pub close: CloseFrame,
304    /// Server-initiated keepalive (ping-and-timeout). `None` disables it — send
305    /// your own pings via [`control_channel`] instead.
306    pub keepalive: Option<KeepAlive>,
307    /// Receiver from [`control_channel`], to send control frames while running.
308    pub control: Option<ControlReceiver>,
309    /// Called once when the connection ends, with the close frame describing why:
310    /// the peer's Close, the keepalive-timeout close, the teardown close, or
311    /// 1006 (Abnormal) if the transport dropped without a Close.
312    pub on_close: Option<CloseHook>,
313    /// Called when a Ping is received (wslay has already auto-queued the Pong).
314    pub on_ping: Option<ControlHook>,
315    /// Called when a Pong is received.
316    pub on_pong: Option<ControlHook>,
317}
318
319/// Why the bridge ended — used to surface a close reason to `on_close`.
320enum EndReason {
321    /// The peer sent a Close with this code + reason.
322    PeerClose(CloseFrame),
323    /// This side closed (peer/upstream EOF, or keepalive failure).
324    LocalClose(CloseFrame),
325    /// The WS transport ended without a Close frame (abnormal).
326    Abnormal,
327}
328
329/// Queue a control/data message of `opcode` carrying `payload` (wslay copies it).
330unsafe fn queue_msg(ctx: wslay_event_context_ptr, opcode: u8, payload: &[u8]) {
331    let msg = wslay_event_msg {
332        opcode,
333        msg: payload.as_ptr(),
334        msg_length: payload.len(),
335    };
336    wslay_event_queue_msg(ctx, &msg);
337}
338
339/// Queue a close with `code` + `reason`.
340unsafe fn queue_close(ctx: wslay_event_context_ptr, code: u16, reason: &[u8]) {
341    let (ptr, len) = if reason.is_empty() {
342        (ptr::null(), 0)
343    } else {
344        (reason.as_ptr(), reason.len())
345    };
346    wslay_event_queue_close(ctx, code, ptr, len);
347}
348
349/// Pump bytes full-duplex between a WebSocket and a byte-stream peer until either
350/// side closes, using wslay for framing. Equivalent to [`bridge_with`] with a
351/// default [`BridgeConfig`].
352///
353/// `ws_io` is the raw upgraded WebSocket byte stream from [`accept`](crate::accept).
354/// This is item 3's core: `bridge(ws_io, TcpStream::connect(upstream))` is a
355/// websockify-equivalent WS→TCP proxy.
356pub async fn bridge<S, P>(ws_io: S, peer: P) -> io::Result<()>
357where
358    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
359    P: AsyncRead + AsyncWrite + Unpin + Send + 'static,
360{
361    bridge_with(ws_io, peer, BridgeConfig::default()).await
362}
363
364/// Like [`bridge`], but with control-frame configuration and hooks
365/// ([`BridgeConfig`]): send control frames via [`control_channel`], observe
366/// received close/ping/pong, and set the close sent on teardown.
367///
368/// WS message payloads flow to `peer`; `peer` bytes flow back as binary WS
369/// frames — all streamed incrementally, never buffering a whole frame.
370pub async fn bridge_with<S, P>(ws_io: S, peer: P, config: BridgeConfig) -> io::Result<()>
371where
372    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
373    P: AsyncRead + AsyncWrite + Unpin + Send + 'static,
374{
375    let BridgeConfig {
376        close,
377        keepalive,
378        control,
379        mut on_close,
380        mut on_ping,
381        mut on_pong,
382    } = config;
383
384    let (mut ws_read, ws_write) = tokio::io::split(ws_io);
385    let (mut peer_read, mut peer_write) = tokio::io::split(peer);
386    let ws_write = Arc::new(Mutex::new(ws_write));
387
388    // Last time a frame was received from the WS peer; read by the keepalive
389    // direction to decide when to ping and whether a pong came back. Shared and
390    // `Send`; only ever locked briefly (never across an await).
391    let last_activity = Arc::new(std::sync::Mutex::new(Instant::now()));
392
393    // Lives for the whole future; the callbacks and every direction reach it via
394    // its address (see the module note on Send-safety).
395    let shared: Box<RefCell<Shared>> = Box::new(RefCell::new(Shared::default()));
396    let sp_addr = &*shared as *const RefCell<Shared> as usize;
397
398    // Confine every bare pointer to this block so none can live across an await.
399    let (ctx_addr, _guard) = {
400        let callbacks = wslay_event_callbacks {
401            recv_callback: Some(recv_cb),
402            send_callback: Some(send_cb),
403            genmask_callback: None, // server role never masks
404            on_frame_recv_start_callback: Some(frame_start_cb),
405            on_frame_recv_chunk_callback: Some(frame_chunk_cb),
406            on_frame_recv_end_callback: None,
407            on_msg_recv_callback: Some(msg_recv_cb), // surface control frames
408        };
409        let mut ctx_raw: wslay_event_context_ptr = ptr::null_mut();
410        let rc = unsafe {
411            wslay_event_context_server_init(&mut ctx_raw, &callbacks, sp_addr as *mut c_void)
412        };
413        if rc != 0 || ctx_raw.is_null() {
414            return Err(io::Error::other("wslay_event_context_server_init failed"));
415        }
416        unsafe { wslay_event_config_set_no_buffering(ctx_raw, 1) }; // incremental frames
417        let addr = ctx_raw as usize;
418        (addr, CtxGuard(addr))
419    };
420
421    // Direction 1: WebSocket -> peer. Also fires ping/pong hooks and flushes
422    // wslay's auto-queued pong/close replies. Reports why it ends.
423    let ws_to_peer = {
424        let ws_write = ws_write.clone();
425        let last_activity = last_activity.clone();
426        async move {
427            let mut buf = vec![0u8; READ_CHUNK];
428            loop {
429                let n = ws_read.read(&mut buf).await?;
430                if n == 0 {
431                    let _ = peer_write.shutdown().await;
432                    return Ok(EndReason::Abnormal); // WS transport EOF, no Close
433                }
434                *last_activity.lock().unwrap() = Instant::now();
435                let (to_peer, outbound, closed, events) = unsafe {
436                    let ctx = ctx_addr as wslay_event_context_ptr;
437                    let sp = &*(sp_addr as *const RefCell<Shared>);
438                    sp.borrow_mut().inbound.extend_from_slice(&buf[..n]);
439                    let recv_rc = wslay_event_recv(ctx); // fires callbacks
440                    wslay_event_send(ctx); // flush auto-queued pong/close
441                    let mut s = sp.borrow_mut();
442                    let pos = s.inbound_pos;
443                    s.inbound.drain(..pos);
444                    s.inbound_pos = 0;
445                    let closed = recv_rc != 0 || wslay_event_get_close_received(ctx) != 0;
446                    (
447                        std::mem::take(&mut s.to_peer),
448                        std::mem::take(&mut s.outbound),
449                        closed,
450                        std::mem::take(&mut s.control_events),
451                    )
452                };
453                if !to_peer.is_empty() {
454                    peer_write.write_all(&to_peer).await?;
455                }
456                let mut peer_close = None;
457                for event in events {
458                    match event {
459                        ControlEvent::Close { code, reason } => {
460                            let code = if code == 0 {
461                                wslay_status_code_WSLAY_CODE_NO_STATUS_RCVD as u16
462                            } else {
463                                code
464                            };
465                            peer_close = Some(CloseFrame {
466                                code,
467                                reason: String::from_utf8_lossy(&reason).into_owned(),
468                            });
469                        }
470                        ControlEvent::Ping(p) => {
471                            if let Some(cb) = on_ping.as_mut() {
472                                cb(&p);
473                            }
474                        }
475                        ControlEvent::Pong(p) => {
476                            if let Some(cb) = on_pong.as_mut() {
477                                cb(&p);
478                            }
479                        }
480                    }
481                }
482                if !outbound.is_empty() {
483                    ws_write.lock().await.write_all(&outbound).await?;
484                }
485                if closed {
486                    let _ = peer_write.shutdown().await;
487                    return Ok(peer_close.map_or(EndReason::Abnormal, EndReason::PeerClose));
488                }
489            }
490        }
491    };
492
493    // Direction 2: peer -> WebSocket (framed as binary messages).
494    let peer_to_ws = {
495        let ws_write = ws_write.clone();
496        async move {
497            let mut buf = vec![0u8; READ_CHUNK];
498            loop {
499                let n = match peer_read.read(&mut buf).await {
500                    Ok(0) | Err(_) => {
501                        let outbound = unsafe {
502                            let ctx = ctx_addr as wslay_event_context_ptr;
503                            queue_close(ctx, close.code, close.reason.as_bytes());
504                            wslay_event_send(ctx);
505                            std::mem::take(&mut (*(sp_addr as *const RefCell<Shared>)).borrow_mut().outbound)
506                        };
507                        if !outbound.is_empty() {
508                            let _ = ws_write.lock().await.write_all(&outbound).await;
509                        }
510                        return Ok(EndReason::LocalClose(close));
511                    }
512                    Ok(n) => n,
513                };
514                let outbound = unsafe {
515                    let ctx = ctx_addr as wslay_event_context_ptr;
516                    queue_msg(ctx, wslay_opcode_WSLAY_BINARY_FRAME as u8, &buf[..n]);
517                    wslay_event_send(ctx);
518                    std::mem::take(&mut (*(sp_addr as *const RefCell<Shared>)).borrow_mut().outbound)
519                };
520                if !outbound.is_empty() {
521                    ws_write.lock().await.write_all(&outbound).await?;
522                }
523            }
524        }
525    };
526
527    // Direction 3: application-injected control frames (ping/pong/close).
528    let control_dir = {
529        let ws_write = ws_write.clone();
530        async move {
531            let mut rx = match control {
532                Some(ControlReceiver(rx)) => rx,
533                // No control channel: park forever so this branch never ends the
534                // bridge on its own.
535                None => return future::pending::<io::Result<EndReason>>().await,
536            };
537            while let Some(cmd) = rx.recv().await {
538                let outbound = unsafe {
539                    let ctx = ctx_addr as wslay_event_context_ptr;
540                    match &cmd {
541                        ControlCmd::Ping(p) => queue_msg(ctx, wslay_opcode_WSLAY_PING as u8, p),
542                        ControlCmd::Pong(p) => queue_msg(ctx, wslay_opcode_WSLAY_PONG as u8, p),
543                        ControlCmd::Close(cf) => queue_close(ctx, cf.code, cf.reason.as_bytes()),
544                    }
545                    wslay_event_send(ctx);
546                    std::mem::take(&mut (*(sp_addr as *const RefCell<Shared>)).borrow_mut().outbound)
547                };
548                if !outbound.is_empty() {
549                    ws_write.lock().await.write_all(&outbound).await?;
550                }
551            }
552            // All senders dropped: park rather than tear down the live bridge.
553            future::pending::<io::Result<EndReason>>().await
554        }
555    };
556
557    // Direction 4: server-initiated keepalive (ping when idle, close on timeout).
558    let keepalive_dir = {
559        let ws_write = ws_write.clone();
560        let last_activity = last_activity.clone();
561        async move {
562            let ka = match keepalive {
563                Some(ka) => ka,
564                None => return future::pending::<io::Result<EndReason>>().await,
565            };
566            loop {
567                // Wait until the connection has been idle for a full interval.
568                let idle = last_activity.lock().unwrap().elapsed();
569                if idle < ka.interval {
570                    tokio::time::sleep(ka.interval - idle).await;
571                    continue;
572                }
573                // Idle: send a Ping and note when.
574                let outbound = unsafe {
575                    let ctx = ctx_addr as wslay_event_context_ptr;
576                    queue_msg(ctx, wslay_opcode_WSLAY_PING as u8, b"");
577                    wslay_event_send(ctx);
578                    std::mem::take(&mut (*(sp_addr as *const RefCell<Shared>)).borrow_mut().outbound)
579                };
580                if !outbound.is_empty() {
581                    ws_write.lock().await.write_all(&outbound).await?;
582                }
583                let pinged_at = Instant::now();
584                tokio::time::sleep(ka.timeout).await;
585                // No frame (pong or data) since our ping? Disconnect.
586                if *last_activity.lock().unwrap() <= pinged_at {
587                    let outbound = unsafe {
588                        let ctx = ctx_addr as wslay_event_context_ptr;
589                        queue_close(ctx, ka.close.code, ka.close.reason.as_bytes());
590                        wslay_event_send(ctx);
591                        std::mem::take(&mut (*(sp_addr as *const RefCell<Shared>)).borrow_mut().outbound)
592                    };
593                    if !outbound.is_empty() {
594                        let _ = ws_write.lock().await.write_all(&outbound).await;
595                    }
596                    return Ok(EndReason::LocalClose(ka.close));
597                }
598            }
599        }
600    };
601
602    // Single task, cooperative: the directions interleave only at awaits, so the
603    // synchronous wslay sections never overlap. First to finish tears down.
604    let result = tokio::select! {
605        r = ws_to_peer => r,
606        r = peer_to_ws => r,
607        r = control_dir => r,
608        r = keepalive_dir => r,
609    };
610    drop(_guard); // free ctx while `shared` is still alive
611    drop(shared);
612
613    // Surface the close reason once, whatever ended the bridge.
614    let closed_with = match &result {
615        Ok(EndReason::PeerClose(cf)) | Ok(EndReason::LocalClose(cf)) => cf.clone(),
616        Ok(EndReason::Abnormal) => CloseFrame {
617            code: wslay_status_code_WSLAY_CODE_ABNORMAL_CLOSURE as u16,
618            reason: String::new(),
619        },
620        Err(e) => CloseFrame {
621            code: wslay_status_code_WSLAY_CODE_ABNORMAL_CLOSURE as u16,
622            reason: e.to_string(),
623        },
624    };
625    if let Some(cb) = on_close.as_mut() {
626        cb(&closed_with);
627    }
628    result.map(|_| ())
629}