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
293impl Default for KeepAlive {
294    /// Sensible server defaults: ping an idle connection every 30s and close it
295    /// (1001 Going Away) if no frame arrives within a further 15s. This is what
296    /// [`BridgeConfig::default`] (and thus [`bridge`] / [`serve_h2`](crate::serve_h2))
297    /// use — keepalive is **on by default**. Tune via [`KeepAlive::new`], or set
298    /// [`BridgeConfig::keepalive`] to `None` to opt out.
299    fn default() -> Self {
300        Self::new(Duration::from_secs(30), Duration::from_secs(15))
301    }
302}
303
304/// Control-frame configuration and hooks for [`bridge_with`] /
305/// [`serve_h2_with`](crate::serve_h2_with).
306///
307/// [`BridgeConfig::default`] enables **server-initiated keepalive on by default**
308/// ([`KeepAlive::default`]: 30s ping / 15s timeout / 1001) so a silently-dead peer
309/// can't leak the bridge; wslay still auto-answers incoming pings, a Normal
310/// Closure is sent on teardown, and no hooks fire. To opt out of keepalive, set
311/// [`keepalive`](BridgeConfig::keepalive) to `None`.
312pub struct BridgeConfig {
313    /// Close frame this side sends when it starts the close after the peer/upstream
314    /// reached a **clean** EOF. Defaults to 1000 Normal Closure, empty reason.
315    pub close: CloseFrame,
316    /// Close frame this side sends when it tears down because the peer/upstream
317    /// **errored** (a read/write failure — e.g. a reset — not a clean EOF).
318    /// Defaults to 1011 (Internal Error); the proxy sets 1014 (Bad Gateway) so a
319    /// failed upstream is distinguishable from a clean shutdown (à la nginx 502).
320    pub error_close: CloseFrame,
321    /// Server-initiated keepalive (ping-and-timeout). Defaults to
322    /// `Some(`[`KeepAlive::default`]`)`; set to `None` to opt out (and optionally
323    /// drive your own pings via [`control_channel`] instead).
324    pub keepalive: Option<KeepAlive>,
325    /// Receiver from [`control_channel`], to send control frames while running.
326    pub control: Option<ControlReceiver>,
327    /// Called once when the connection ends, with the close frame describing why:
328    /// the peer's Close, the keepalive-timeout close, the teardown close, or
329    /// 1006 (Abnormal) if the transport dropped without a Close.
330    pub on_close: Option<CloseHook>,
331    /// Called when a Ping is received (wslay has already auto-queued the Pong).
332    pub on_ping: Option<ControlHook>,
333    /// Called when a Pong is received.
334    pub on_pong: Option<ControlHook>,
335}
336
337impl Default for BridgeConfig {
338    fn default() -> Self {
339        Self {
340            close: CloseFrame::default(),
341            // An errored (vs cleanly-closed) peer/upstream tears down with 1011.
342            error_close: CloseFrame {
343                code: wslay_status_code_WSLAY_CODE_INTERNAL_SERVER_ERROR as u16,
344                reason: String::new(),
345            },
346            // Keepalive is on by default (opt out with `keepalive: None`) so a
347            // peer that dies without a TCP FIN can't leak the bridge task.
348            keepalive: Some(KeepAlive::default()),
349            control: None,
350            on_close: None,
351            on_ping: None,
352            on_pong: None,
353        }
354    }
355}
356
357/// Why the bridge ended — used to surface a close reason to `on_close`.
358enum EndReason {
359    /// The peer sent a Close with this code + reason.
360    PeerClose(CloseFrame),
361    /// This side closed (peer/upstream EOF, or keepalive failure).
362    LocalClose(CloseFrame),
363    /// The WS transport ended without a Close frame (abnormal).
364    Abnormal,
365}
366
367/// Queue a control/data message of `opcode` carrying `payload` (wslay copies it).
368unsafe fn queue_msg(ctx: wslay_event_context_ptr, opcode: u8, payload: &[u8]) {
369    let msg = wslay_event_msg {
370        opcode,
371        msg: payload.as_ptr(),
372        msg_length: payload.len(),
373    };
374    wslay_event_queue_msg(ctx, &msg);
375}
376
377/// Queue a close with `code` + `reason`.
378unsafe fn queue_close(ctx: wslay_event_context_ptr, code: u16, reason: &[u8]) {
379    let (ptr, len) = if reason.is_empty() {
380        (ptr::null(), 0)
381    } else {
382        (reason.as_ptr(), reason.len())
383    };
384    wslay_event_queue_close(ctx, code, ptr, len);
385}
386
387/// Pump bytes full-duplex between a WebSocket and a byte-stream peer until either
388/// side closes, using wslay for framing. Equivalent to [`bridge_with`] with a
389/// default [`BridgeConfig`].
390///
391/// `ws_io` is the raw upgraded WebSocket byte stream from [`accept`](crate::accept).
392/// This is item 3's core: `bridge(ws_io, TcpStream::connect(upstream))` is a
393/// websockify-equivalent WS→TCP proxy.
394pub async fn bridge<S, P>(ws_io: S, peer: P) -> io::Result<()>
395where
396    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
397    P: AsyncRead + AsyncWrite + Unpin + Send + 'static,
398{
399    bridge_with(ws_io, peer, BridgeConfig::default()).await
400}
401
402/// Like [`bridge`], but with control-frame configuration and hooks
403/// ([`BridgeConfig`]): send control frames via [`control_channel`], observe
404/// received close/ping/pong, and set the close sent on teardown.
405///
406/// WS message payloads flow to `peer`; `peer` bytes flow back as binary WS
407/// frames — all streamed incrementally, never buffering a whole frame.
408pub async fn bridge_with<S, P>(ws_io: S, peer: P, config: BridgeConfig) -> io::Result<()>
409where
410    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
411    P: AsyncRead + AsyncWrite + Unpin + Send + 'static,
412{
413    let BridgeConfig {
414        close,
415        error_close,
416        keepalive,
417        control,
418        mut on_close,
419        mut on_ping,
420        mut on_pong,
421    } = config;
422    // `ws_to_peer` needs its own copy of the error close (the `peer_to_ws` closure
423    // takes the original).
424    let error_close_ws = error_close.clone();
425
426    let (mut ws_read, ws_write) = tokio::io::split(ws_io);
427    let (mut peer_read, mut peer_write) = tokio::io::split(peer);
428    let ws_write = Arc::new(Mutex::new(ws_write));
429
430    // Last time a frame was received from the WS peer; read by the keepalive
431    // direction to decide when to ping and whether a pong came back. Shared and
432    // `Send`; only ever locked briefly (never across an await).
433    let last_activity = Arc::new(std::sync::Mutex::new(Instant::now()));
434
435    // Lives for the whole future; the callbacks and every direction reach it via
436    // its address (see the module note on Send-safety).
437    let shared: Box<RefCell<Shared>> = Box::new(RefCell::new(Shared::default()));
438    let sp_addr = &*shared as *const RefCell<Shared> as usize;
439
440    // Confine every bare pointer to this block so none can live across an await.
441    let (ctx_addr, _guard) = {
442        let callbacks = wslay_event_callbacks {
443            recv_callback: Some(recv_cb),
444            send_callback: Some(send_cb),
445            genmask_callback: None, // server role never masks
446            on_frame_recv_start_callback: Some(frame_start_cb),
447            on_frame_recv_chunk_callback: Some(frame_chunk_cb),
448            on_frame_recv_end_callback: None,
449            on_msg_recv_callback: Some(msg_recv_cb), // surface control frames
450        };
451        let mut ctx_raw: wslay_event_context_ptr = ptr::null_mut();
452        let rc = unsafe {
453            wslay_event_context_server_init(&mut ctx_raw, &callbacks, sp_addr as *mut c_void)
454        };
455        if rc != 0 || ctx_raw.is_null() {
456            return Err(io::Error::other("wslay_event_context_server_init failed"));
457        }
458        unsafe { wslay_event_config_set_no_buffering(ctx_raw, 1) }; // incremental frames
459        let addr = ctx_raw as usize;
460        (addr, CtxGuard(addr))
461    };
462
463    // Direction 1: WebSocket -> peer. Also fires ping/pong hooks and flushes
464    // wslay's auto-queued pong/close replies. Reports why it ends.
465    let ws_to_peer = {
466        let ws_write = ws_write.clone();
467        let last_activity = last_activity.clone();
468        async move {
469            let mut buf = vec![0u8; READ_CHUNK];
470            loop {
471                let n = ws_read.read(&mut buf).await?;
472                if n == 0 {
473                    let _ = peer_write.shutdown().await;
474                    return Ok(EndReason::Abnormal); // WS transport EOF, no Close
475                }
476                *last_activity.lock().unwrap() = Instant::now();
477                let (to_peer, outbound, closed, events) = unsafe {
478                    let ctx = ctx_addr as wslay_event_context_ptr;
479                    let sp = &*(sp_addr as *const RefCell<Shared>);
480                    sp.borrow_mut().inbound.extend_from_slice(&buf[..n]);
481                    let recv_rc = wslay_event_recv(ctx); // fires callbacks
482                    wslay_event_send(ctx); // flush auto-queued pong/close
483                    let mut s = sp.borrow_mut();
484                    let pos = s.inbound_pos;
485                    s.inbound.drain(..pos);
486                    s.inbound_pos = 0;
487                    let closed = recv_rc != 0 || wslay_event_get_close_received(ctx) != 0;
488                    (
489                        std::mem::take(&mut s.to_peer),
490                        std::mem::take(&mut s.outbound),
491                        closed,
492                        std::mem::take(&mut s.control_events),
493                    )
494                };
495                if !to_peer.is_empty() && peer_write.write_all(&to_peer).await.is_err() {
496                    // Upstream write failed: tell the client with the error close
497                    // (1011 by default, 1014 for a proxy) rather than a bare 1006.
498                    let outbound = unsafe {
499                        let ctx = ctx_addr as wslay_event_context_ptr;
500                        queue_close(ctx, error_close_ws.code, error_close_ws.reason.as_bytes());
501                        wslay_event_send(ctx);
502                        std::mem::take(
503                            &mut (*(sp_addr as *const RefCell<Shared>)).borrow_mut().outbound,
504                        )
505                    };
506                    if !outbound.is_empty() {
507                        let _ = ws_write.lock().await.write_all(&outbound).await;
508                    }
509                    return Ok(EndReason::LocalClose(error_close_ws.clone()));
510                }
511                let mut peer_close = None;
512                for event in events {
513                    match event {
514                        ControlEvent::Close { code, reason } => {
515                            let code = if code == 0 {
516                                wslay_status_code_WSLAY_CODE_NO_STATUS_RCVD as u16
517                            } else {
518                                code
519                            };
520                            peer_close = Some(CloseFrame {
521                                code,
522                                reason: String::from_utf8_lossy(&reason).into_owned(),
523                            });
524                        }
525                        ControlEvent::Ping(p) => {
526                            if let Some(cb) = on_ping.as_mut() {
527                                cb(&p);
528                            }
529                        }
530                        ControlEvent::Pong(p) => {
531                            if let Some(cb) = on_pong.as_mut() {
532                                cb(&p);
533                            }
534                        }
535                    }
536                }
537                if !outbound.is_empty() {
538                    ws_write.lock().await.write_all(&outbound).await?;
539                }
540                if closed {
541                    let _ = peer_write.shutdown().await;
542                    return Ok(peer_close.map_or(EndReason::Abnormal, EndReason::PeerClose));
543                }
544            }
545        }
546    };
547
548    // Direction 2: peer -> WebSocket (framed as binary messages).
549    let peer_to_ws = {
550        let ws_write = ws_write.clone();
551        async move {
552            let mut buf = vec![0u8; READ_CHUNK];
553            loop {
554                let n = match peer_read.read(&mut buf).await {
555                    Ok(n) if n > 0 => n,
556                    other => {
557                        // Ok(0) = clean upstream EOF -> `close` (1000 by default; any
558                        // h2 GOAWAY already flowed through as bytes). Err = upstream
559                        // failure/reset -> `error_close` (1011, or 1014 for a proxy).
560                        let cf = if other.is_ok() { &close } else { &error_close };
561                        let outbound = unsafe {
562                            let ctx = ctx_addr as wslay_event_context_ptr;
563                            queue_close(ctx, cf.code, cf.reason.as_bytes());
564                            wslay_event_send(ctx);
565                            std::mem::take(
566                                &mut (*(sp_addr as *const RefCell<Shared>)).borrow_mut().outbound,
567                            )
568                        };
569                        if !outbound.is_empty() {
570                            let _ = ws_write.lock().await.write_all(&outbound).await;
571                        }
572                        return Ok(EndReason::LocalClose(cf.clone()));
573                    }
574                };
575                let outbound = unsafe {
576                    let ctx = ctx_addr as wslay_event_context_ptr;
577                    queue_msg(ctx, wslay_opcode_WSLAY_BINARY_FRAME as u8, &buf[..n]);
578                    wslay_event_send(ctx);
579                    std::mem::take(
580                        &mut (*(sp_addr as *const RefCell<Shared>)).borrow_mut().outbound,
581                    )
582                };
583                if !outbound.is_empty() {
584                    ws_write.lock().await.write_all(&outbound).await?;
585                }
586            }
587        }
588    };
589
590    // Direction 3: application-injected control frames (ping/pong/close).
591    let control_dir = {
592        let ws_write = ws_write.clone();
593        async move {
594            let mut rx = match control {
595                Some(ControlReceiver(rx)) => rx,
596                // No control channel: park forever so this branch never ends the
597                // bridge on its own.
598                None => return future::pending::<io::Result<EndReason>>().await,
599            };
600            while let Some(cmd) = rx.recv().await {
601                let outbound = unsafe {
602                    let ctx = ctx_addr as wslay_event_context_ptr;
603                    match &cmd {
604                        ControlCmd::Ping(p) => queue_msg(ctx, wslay_opcode_WSLAY_PING as u8, p),
605                        ControlCmd::Pong(p) => queue_msg(ctx, wslay_opcode_WSLAY_PONG as u8, p),
606                        ControlCmd::Close(cf) => queue_close(ctx, cf.code, cf.reason.as_bytes()),
607                    }
608                    wslay_event_send(ctx);
609                    std::mem::take(
610                        &mut (*(sp_addr as *const RefCell<Shared>)).borrow_mut().outbound,
611                    )
612                };
613                if !outbound.is_empty() {
614                    ws_write.lock().await.write_all(&outbound).await?;
615                }
616            }
617            // All senders dropped: park rather than tear down the live bridge.
618            future::pending::<io::Result<EndReason>>().await
619        }
620    };
621
622    // Direction 4: server-initiated keepalive (ping when idle, close on timeout).
623    let keepalive_dir = {
624        let ws_write = ws_write.clone();
625        let last_activity = last_activity.clone();
626        async move {
627            let ka = match keepalive {
628                Some(ka) => ka,
629                None => return future::pending::<io::Result<EndReason>>().await,
630            };
631            loop {
632                // Wait until the connection has been idle for a full interval.
633                let idle = last_activity.lock().unwrap().elapsed();
634                if idle < ka.interval {
635                    tokio::time::sleep(ka.interval - idle).await;
636                    continue;
637                }
638                // Idle: send a Ping and note when.
639                let outbound = unsafe {
640                    let ctx = ctx_addr as wslay_event_context_ptr;
641                    queue_msg(ctx, wslay_opcode_WSLAY_PING as u8, b"");
642                    wslay_event_send(ctx);
643                    std::mem::take(
644                        &mut (*(sp_addr as *const RefCell<Shared>)).borrow_mut().outbound,
645                    )
646                };
647                if !outbound.is_empty() {
648                    ws_write.lock().await.write_all(&outbound).await?;
649                }
650                let pinged_at = Instant::now();
651                tokio::time::sleep(ka.timeout).await;
652                // No frame (pong or data) since our ping? Disconnect.
653                if *last_activity.lock().unwrap() <= pinged_at {
654                    let outbound = unsafe {
655                        let ctx = ctx_addr as wslay_event_context_ptr;
656                        queue_close(ctx, ka.close.code, ka.close.reason.as_bytes());
657                        wslay_event_send(ctx);
658                        std::mem::take(
659                            &mut (*(sp_addr as *const RefCell<Shared>)).borrow_mut().outbound,
660                        )
661                    };
662                    if !outbound.is_empty() {
663                        let _ = ws_write.lock().await.write_all(&outbound).await;
664                    }
665                    return Ok(EndReason::LocalClose(ka.close));
666                }
667            }
668        }
669    };
670
671    // Single task, cooperative: the directions interleave only at awaits, so the
672    // synchronous wslay sections never overlap. First to finish tears down.
673    let result = tokio::select! {
674        r = ws_to_peer => r,
675        r = peer_to_ws => r,
676        r = control_dir => r,
677        r = keepalive_dir => r,
678    };
679    drop(_guard); // free ctx while `shared` is still alive
680    drop(shared);
681
682    // Surface the close reason once, whatever ended the bridge.
683    let closed_with = match &result {
684        Ok(EndReason::PeerClose(cf)) | Ok(EndReason::LocalClose(cf)) => cf.clone(),
685        Ok(EndReason::Abnormal) => CloseFrame {
686            code: wslay_status_code_WSLAY_CODE_ABNORMAL_CLOSURE as u16,
687            reason: String::new(),
688        },
689        Err(e) => CloseFrame {
690            code: wslay_status_code_WSLAY_CODE_ABNORMAL_CLOSURE as u16,
691            reason: e.to_string(),
692        },
693    };
694    if let Some(cb) = on_close.as_mut() {
695        cb(&closed_with);
696    }
697    result.map(|_| ())
698}