bun_runtime 0.1.2

Bao runtime integration — JS engine + Bun API + event loop
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
// @trace TEST-ENG-006-WS-ASYNC [req:REQ-ENG-006] [req:REQ-STL-001] [level:integration]
// Async page-WebSocket root fix: `new WebSocket(..)` must NOT connect on the
// JS thread (background worker + drain pump) and the wss:// handshake must
// apply the thread's StealthProfile (same application path as fetch()).
//
// Coverage:
//   1. ws:// echo round-trip: constructor returns immediately, onopen fires
//      from the drain pump, send→onmessage("ECHO:hello"), close→onclose.
//   2. Constructor non-blocking proof: a server that accepts TCP but never
//      completes the WS handshake leaves readyState===0 while a 10ms timer
//      still fires on the JS thread (the old code blocked the JS/Script
//      thread inside the constructor for the full handshake window).
//   3. wss:// echo round-trip against a real BoringSSL TLS server with a
//      self-signed cert. The client handshake runs the full stealth path
//      (`stealth_profile_to_ssl_config` → `configure_http_client_with_alpn`:
//      Firefox cipher list / TLS1.3 suites / curves / sigalgs + ALPN), so a
//      green test proves the stealth-configured ClientHello is accepted by a
//      real TLS peer — functional stealth-path evidence.
//   4. Connect failure surfaces explicitly: refused port → onerror (with the
//      reason) + onclose, readyState===3. Never a silent swallow.

use bao_boringssl_bridge::connection::{TlsConnection, TlsState};
use bao_boringssl_bridge::server::TlsServer;
use bao_engine::context::JsContext;
use bao_engine::value::JsValue;
use bun_uws::ws_codec::{apply_mask, FrameDecoder, FrameEncoder};
use bun_uws::ws_handshake::server_handshake;
use mozjs::rooted;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::time::Duration;

fn eval_str(ctx: &mut JsContext, code: &str) -> String {
    match ctx.eval(code, "<test>") {
        Ok(JsValue::String(s)) => s,
        Ok(JsValue::Number(n)) => format!("{}", n),
        Ok(JsValue::Bool(b)) => if b { "true" } else { "false" }.to_string(),
        Ok(v) => format!("{:?}", v),
        Err(e) => format!("ERROR: {:?}", e),
    }
}

/// Pump the event loop (timers + jobs + the WebSocket drain pump) until
/// `probe` (a JS expression) evaluates truthy, or the budget runs out.
/// Enters the thread's persistent realm first — the CLI eval loop invokes
/// its drain hook inside the realm, and timer callbacks resolve through
/// CurrentGlobalOrNull.
fn pump_until(ctx: &mut JsContext, probe: &str, budget_ms: u64) -> bool {
    let deadline = std::time::Instant::now() + Duration::from_millis(budget_ms);
    while std::time::Instant::now() < deadline {
        let mut cxm = ctx.cx();
        let global = bao_engine::context::thread_realm_global();
        if let Some(g) = global {
            rooted!(&in(cxm) let g_root = g);
            let mut realm = mozjs::realm::AutoRealm::new_from_handle(&mut cxm, g_root.handle());
            let realm_cx: &mut mozjs::context::JSContext = &mut realm;
            bun_runtime::timers::drain_and_check(realm_cx);
        } else {
            bun_runtime::timers::drain_and_check(&mut cxm);
        }
        if eval_str(ctx, &format!("Boolean({})", probe)) == "true" {
            return true;
        }
        std::thread::sleep(Duration::from_millis(2));
    }
    false
}

// ── Test servers ──────────────────────────────────────────────────────────

/// Encode a server→client text frame (no mask, server side).
fn encode_server_text(payload: &str) -> Vec<u8> {
    let mut buf = Vec::with_capacity(payload.len() + 2);
    buf.push(0x81); // FIN + Text
    buf.push(payload.len() as u8); // server frames are unmasked
    buf.extend_from_slice(payload.as_bytes());
    buf
}

fn encode_server_close() -> Vec<u8> {
    vec![0x88, 0x02, 0x03, 0xE8] // FIN + Close, code 1000
}

/// Serve one plain ws:// connection: handshake, echo text frames as
/// "ECHO:<text>", reply to close frames.
fn serve_plain_connection(mut stream: TcpStream) {
    stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
    if server_handshake(&mut stream).is_err() {
        eprintln!("[ws-server] handshake failed");
        return;
    }
    let _ = stream.write_all(&encode_server_text("OPEN"));
    let _ = stream.flush();
    let mut decoder = FrameDecoder::new();
    loop {
        let header = match decoder.decode_frame(&mut stream) {
            Ok(Some(h)) => h,
            Ok(None) => {
                eprintln!("[ws-server] eof");
                return;
            }
            Err(e) => {
                eprintln!("[ws-server] decode error: {:?}", e.kind());
                return;
            }
        };
        let payload = if header.mask {
            let mask = decoder.take_mask();
            let mut p = decoder.take_payload(&header);
            apply_mask(&mut p, &mask);
            p
        } else {
            decoder.take_payload(&header)
        };
        match header.opcode {
            bun_uws::ws_codec::Opcode::Text => {
                let text = String::from_utf8_lossy(&payload).into_owned();
                let _ = stream.write_all(&encode_server_text(&format!("ECHO:{}", text)));
                let _ = stream.flush();
            }
            bun_uws::ws_codec::Opcode::Close => {
                let _ = stream.write_all(&encode_server_close());
                let _ = stream.flush();
                return;
            }
            _ => {
                eprintln!("[ws-server] other opcode: {:?}", header.opcode);
            }
        }
    }
}

/// TLS stream adapter for the test server: drives the server-side
/// BoringSSL state machine over the raw TCP socket (mirror of the client's
/// private `TlsStream` in web_api.rs).
struct ServerTlsIo {
    tcp: TcpStream,
    tls: TlsConnection,
    /// Decrypted plaintext not yet consumed (byte-at-a-time readers — see
    /// the client-side BCE-20260814-WS-TLS note).
    pending_plain: Vec<u8>,
    pending_off: usize,
}

impl ServerTlsIo {
    fn handshake(tcp: &mut TcpStream, tls: &mut TlsConnection) -> std::io::Result<()> {
        loop {
            let res = tls
                .process()
                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
            // Flush the flight (ServerHello/cert/Finished) BEFORE blocking
            // on read — same ordering contract as the client loop.
            loop {
                let outgoing = tls.take_outgoing();
                if outgoing.is_empty() {
                    break;
                }
                tcp.write_all(&outgoing)?;
            }
            if res.state == TlsState::Active || res.state == TlsState::PeerClosed {
                return Ok(());
            }
            let mut buf = [0u8; 16_384];
            match tcp.read(&mut buf) {
                Ok(0) => {
                    return Err(std::io::Error::new(
                        std::io::ErrorKind::UnexpectedEof,
                        "peer closed during tls handshake",
                    ))
                }
                Ok(n) => tls.feed(&buf[..n]),
                Err(e) => return Err(e),
            }
        }
    }

    fn read_plaintext(&mut self) -> std::io::Result<Vec<u8>> {
        loop {
            let outgoing = self.tls.take_outgoing();
            if !outgoing.is_empty() {
                self.tcp.write_all(&outgoing)?;
            }
            let res = self
                .tls
                .process()
                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
            if !res.plaintext.is_empty() {
                let mut joined = Vec::new();
                for chunk in res.plaintext {
                    joined.extend_from_slice(&chunk);
                }
                return Ok(joined);
            }
            let mut buf = [0u8; 16_384];
            match self.tcp.read(&mut buf) {
                Ok(0) => {
                    return Err(std::io::Error::new(
                        std::io::ErrorKind::UnexpectedEof,
                        "tls peer closed",
                    ))
                }
                Ok(n) => self.tls.feed(&buf[..n]),
                Err(e) => return Err(e),
            }
        }
    }
}

impl Read for ServerTlsIo {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        if self.pending_off >= self.pending_plain.len() {
            self.pending_plain = self.read_plaintext()?;
            self.pending_off = 0;
        }
        let avail = &self.pending_plain[self.pending_off..];
        let n = avail.len().min(buf.len());
        buf[..n].copy_from_slice(&avail[..n]);
        self.pending_off += n;
        Ok(n)
    }
}

impl Write for ServerTlsIo {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        let n = self
            .tls
            .write(buf)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
        let outgoing = self.tls.take_outgoing();
        if !outgoing.is_empty() {
            self.tcp.write_all(&outgoing)?;
        }
        Ok(n)
    }
    fn flush(&mut self) -> std::io::Result<()> {
        self.tcp.flush()
    }
}

/// Serve one wss:// connection: TLS handshake (self-signed), WS handshake,
/// echo text frames, reply to close frames. Runs the full server-side TLS
/// state machine — the client's stealth-configured ClientHello must be
/// acceptable to this peer for the test to pass. Records whether the TLS
/// handshake resumed a cached session (server-side view) into `resumed`.
fn serve_tls_connection(
    mut tcp: TcpStream,
    server: &TlsServer,
    resumed: &std::sync::Arc<std::sync::Mutex<Vec<bool>>>,
) {
    tcp.set_read_timeout(Some(Duration::from_secs(5))).ok();
    let mut tls = match server.accept() {
        Ok(t) => t,
        Err(e) => {
            eprintln!("[wss-server] accept failed: {}", e);
            return;
        }
    };
    if let Err(e) = ServerTlsIo::handshake(&mut tcp, &mut tls) {
        eprintln!("[wss-server] tls handshake failed: {:?}", e);
        return;
    }
    resumed
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .push(bao_boringssl_bridge::session_cache::session_reused(tls.ssl_ptr()));
    let mut io = ServerTlsIo {
        tcp,
        tls,
        pending_plain: Vec::new(),
        pending_off: 0,
    };
    if server_handshake(&mut io).is_err() {
        return;
    }
    let _ = io.write_all(&encode_server_text("OPEN"));
    let _ = io.flush();
    let mut decoder = FrameDecoder::new();
    loop {
        let header = match decoder.decode_frame(&mut io) {
            Ok(Some(h)) => h,
            _ => return,
        };
        let payload = if header.mask {
            let mask = decoder.take_mask();
            let mut p = decoder.take_payload(&header);
            apply_mask(&mut p, &mask);
            p
        } else {
            decoder.take_payload(&header)
        };
        match header.opcode {
            bun_uws::ws_codec::Opcode::Text => {
                let text = String::from_utf8_lossy(&payload).into_owned();
                let _ = io.write_all(&encode_server_text(&format!("ECHO:{}", text)));
                let _ = io.flush();
            }
            bun_uws::ws_codec::Opcode::Close => {
                let mut enc = FrameEncoder::new();
                let _ = io.write_all(enc.encode_close(1000, ""));
                let _ = io.flush();
                return;
            }
            _ => {}
        }
    }
}

fn spawn_plain_ws_server() -> u16 {
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
    let port = listener.local_addr().unwrap().port();
    std::thread::spawn(move || {
        for stream in listener.incoming() {
            match stream {
                Ok(s) => serve_plain_connection(s),
                Err(_) => return,
            }
        }
    });
    port
}

/// Spawn the TLS WS echo server; returns its port plus the shared log of
/// per-connection server-side resumption flags (one entry per completed TLS
/// handshake, in connection order).
fn spawn_tls_ws_server() -> (u16, std::sync::Arc<std::sync::Mutex<Vec<bool>>>) {
    let (cert, key) =
        bao_boringssl_bridge::generate_self_signed_pem("localhost", 365).expect("self-signed cert");
    let server = std::sync::Arc::new(TlsServer::new(&cert, &key).expect("TlsServer"));
    let resumed = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
    let port = listener.local_addr().unwrap().port();
    let resumed_clone = resumed.clone();
    std::thread::spawn(move || {
        for stream in listener.incoming() {
            match stream {
                Ok(s) => serve_tls_connection(s, &server, &resumed_clone),
                Err(_) => return,
            }
        }
    });
    (port, resumed)
}

/// Accept one TCP connection and never speak (the non-blocking proof server).
fn spawn_silent_tcp_server() -> u16 {
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
    let port = listener.local_addr().unwrap().port();
    std::thread::spawn(move || {
        if let Ok((stream, _)) = listener.accept() {
            // Hold the connection open (accepted, no WS handshake) so the
            // client stays CONNECTING. Drop after 8s to bound the test.
            std::thread::sleep(Duration::from_secs(8));
            drop(stream);
        }
    });
    port
}

// ── Tests ─────────────────────────────────────────────────────────────────

fn new_test_ctx() -> JsContext {
    bun_runtime::install_exit_handler();
    bun_runtime::bun_api::init_process_start();
    let mut ctx = JsContext::for_test().expect("JsContext");
    ctx.set_global_setup(bun_runtime::globals::install_all);
    ctx
}

/// ws:// echo round-trip through the async model: constructor returns with
/// readyState 0, the drain pump delivers onopen/onmessage/onclose.
#[test]
fn ws_async_open_echo_close_roundtrip() {
    let port = spawn_plain_ws_server();
    let mut ctx = new_test_ctx();

    let setup = format!(
        r#"
        var wsLog = [];
        var ws = new WebSocket("ws://127.0.0.1:{}/test");
        wsLog.push("ctor:" + ws.readyState);
        ws.onopen = function() {{ wsLog.push("open:" + ws.readyState); ws.send("hello"); }};
        ws.onmessage = function(ev) {{ wsLog.push("msg:" + ev.data); ws.close(); }};
        ws.onerror = function(ev) {{ wsLog.push("error:" + (ev.data || "?")); }};
        ws.onclose = function() {{ wsLog.push("close:" + ws.readyState); }};
        "done"
        "#,
        port
    );
    assert_eq!(
        eval_str(&mut ctx, &setup),
        "done",
        "constructor eval failed"
    );

    // Constructor must be non-instantly CONNECTING (not yet open).
    assert_eq!(
        eval_str(&mut ctx, "wsLog[0]"),
        "ctor:0",
        "constructor must return while CONNECTING (readyState 0), got: {}",
        eval_str(&mut ctx, "wsLog.join(',')")
    );

    let opened = pump_until(
        &mut ctx,
        "wsLog.some(function(l){{return l==='close:3'}})",
        8_000,
    );
    let log = eval_str(&mut ctx, "wsLog.join('|')");
    assert!(
        opened,
        "WS round-trip did not finish in budget; log: {}",
        log
    );
    assert!(
        log.contains("ctor:0"),
        "readyState must be 0 right after constructor: {}",
        log
    );
    assert!(
        log.contains("open:1"),
        "onopen must fire with readyState 1: {}",
        log
    );
    assert!(
        log.contains("msg:ECHO:hello"),
        "onmessage must deliver the echo: {}",
        log
    );
    assert!(
        log.contains("close:3"),
        "onclose must fire with readyState 3: {}",
        log
    );
    assert!(
        !log.contains("error"),
        "no onerror expected in this flow: {}",
        log
    );
}

/// Non-blocking proof: with a server that never completes the WS handshake,
/// the constructor returns immediately and the JS thread stays responsive
/// (a 10ms timer fires while the WS is still CONNECTING). The pre-fix code
/// performed the blocking connect inside the constructor on the JS thread.
#[test]
fn ws_constructor_does_not_block_js_thread() {
    let port = spawn_silent_tcp_server();
    let mut ctx = new_test_ctx();

    let setup = format!(
        r#"
        var wsLog = [];
        var ws = new WebSocket("ws://127.0.0.1:{}/hang");
        // If the constructor blocked (old behavior), these two statements
        // only run after the ~10s connect window and timerFired stays false.
        var ctorReturnedAt = Date.now();
        var timerFired = false;
        setTimeout(function() {{ timerFired = true; }}, 10);
        "done"
        "#,
        port
    );

    let started = std::time::Instant::now();
    assert_eq!(
        eval_str(&mut ctx, &setup),
        "done",
        "constructor eval failed"
    );
    let ctor_elapsed = started.elapsed();
    assert!(
        ctor_elapsed < Duration::from_secs(2),
        "WebSocket constructor blocked the JS thread for {:?} (must return immediately)",
        ctor_elapsed
    );

    let fired = pump_until(&mut ctx, "timerFired", 3_000);
    assert!(fired, "timer must fire while WS connect is pending");
    assert_eq!(
        eval_str(&mut ctx, "ws.readyState"),
        "0",
        "WS must still be CONNECTING (handshake deliberately unanswered)"
    );
}

/// wss:// echo round-trip: the client TLS handshake runs the full stealth
/// application path (Firefox cipher list / TLS1.3 suites / curves / sigalgs
/// via configure_http_client_with_alpn) against a real BoringSSL server —
/// green test proves the stealth-configured ClientHello is accepted.
#[test]
fn wss_async_roundtrip_with_stealth_profile() {
    let (port, _resumed) = spawn_tls_ws_server();
    let mut ctx = new_test_ctx();

    // install_all installs the default Firefox stealth profile — the exact
    // production configuration a page gets.
    let setup = format!(
        r#"
        var wsLog = [];
        var ws = new WebSocket("wss://127.0.0.1:{}/secure");
        ws.onopen = function() {{ wsLog.push("open"); ws.send("ping"); }};
        ws.onmessage = function(ev) {{ wsLog.push("msg:" + ev.data); if (ev.data.indexOf("ECHO:") === 0) {{ ws.close(); }} }};
        ws.onerror = function(ev) {{ wsLog.push("error:" + (ev.data || "?")); }};
        ws.onclose = function() {{ wsLog.push("close"); }};
        "done"
        "#,
        port
    );
    assert_eq!(
        eval_str(&mut ctx, &setup),
        "done",
        "constructor eval failed"
    );

    let done = pump_until(&mut ctx, "wsLog.indexOf('close') >= 0", 8_000);
    let log = eval_str(&mut ctx, "wsLog.join('|')");
    assert!(
        done,
        "wss round-trip did not finish in budget; log: {}",
        log
    );
    assert!(log.contains("open"), "wss onopen must fire: {}", log);
    assert!(
        log.contains("msg:ECHO:ping"),
        "wss onmessage must deliver the echo: {}",
        log
    );
    assert!(!log.contains("error"), "no onerror expected: {}", log);
}

/// TLS session resumption for the wss:// client path: two sequential wss
/// connections to the same origin — the first performs a full handshake and
/// its new-session callback populates the process-wide cache; the second
/// must be offered (and accept) the cached session (1-RTT / PSK resume).
/// Asserted server-side per connection (both ends agree on resumption).
/// Proves the `offer_session` wiring in `WsConn::connect_tls` plus the
/// `TlsClient::new` ctx callback (the exact production path a page's
/// `new WebSocket("wss://…")` drives).
#[test]
fn wss_second_connection_resumes_session() {
    let (port, resumed) = spawn_tls_ws_server();
    let mut ctx = new_test_ctx();

    for i in 0..2 {
        let setup = format!(
            r#"
            var wsLog = [];
            var ws = new WebSocket("wss://127.0.0.1:{}/secure");
            ws.onopen = function() {{ wsLog.push("open"); ws.send("ping"); }};
            ws.onmessage = function(ev) {{ wsLog.push("msg:" + ev.data); if (ev.data.indexOf("ECHO:") === 0) {{ ws.close(); }} }};
            ws.onerror = function(ev) {{ wsLog.push("error:" + (ev.data || "?")); }};
            ws.onclose = function() {{ wsLog.push("close"); }};
            "done"
            "#,
            port
        );
        assert_eq!(
            eval_str(&mut ctx, &setup),
            "done",
            "constructor eval failed"
        );
        let done = pump_until(&mut ctx, "wsLog.indexOf('close') >= 0", 8_000);
        let log = eval_str(&mut ctx, "wsLog.join('|')");
        assert!(
            done,
            "wss round-trip #{} did not finish in budget; log: {}",
            i + 1,
            log
        );
        assert!(!log.contains("error"), "no onerror expected: {}", log);
    }

    let flags = resumed.lock().unwrap_or_else(|e| e.into_inner()).clone();
    assert_eq!(flags.len(), 2, "exactly two TLS connections expected");
    assert!(!flags[0], "first connection must be a full handshake");
    assert!(flags[1], "second connection to the same origin must resume");
}

/// Connect failure must surface explicitly: refused port → onerror (with the
/// reason) + onclose, readyState 3. Never a constructor throw, never silence.
#[test]
fn ws_connect_failure_fires_onerror_and_onclose() {
    // Port 1 on loopback: connection refused (nothing listens there).
    let mut ctx = new_test_ctx();

    let setup = r#"
        var wsLog = [];
        var ws = new WebSocket("ws://127.0.0.1:1/refused");
        ws.onopen = function() { wsLog.push("open"); };
        ws.onerror = function(ev) { wsLog.push("error:" + (ev.data || "no-reason")); };
        ws.onclose = function() { wsLog.push("close:" + ws.readyState); };
        "done"
        "#;
    assert_eq!(eval_str(&mut ctx, setup), "done", "constructor eval failed");

    let done = pump_until(
        &mut ctx,
        "wsLog.some(function(l){return l.indexOf('close:')===0})",
        8_000,
    );
    let log = eval_str(&mut ctx, "wsLog.join('|')");
    assert!(done, "failure did not surface in budget; log: {}", log);
    assert!(
        log.starts_with("error:"),
        "onerror must fire FIRST with the failure reason, got: {}",
        log
    );
    assert!(
        !log.contains("error:no-reason") && !log.contains("error:?"),
        "onerror must carry the reason (no silent failure), got: {}",
        log
    );
    assert!(
        log.contains("close:3"),
        "onclose must fire with readyState 3: {}",
        log
    );
    assert!(
        !log.contains("open"),
        "onopen must not fire for a refused connect: {}",
        log
    );
    assert_eq!(
        eval_str(&mut ctx, "ws.readyState"),
        "3",
        "final readyState must be CLOSED"
    );
}

/// Raw TLS handshake isolation (no JS): drive a bao_boringssl_bridge client
/// against the test TLS server, with and without the stealth config, to
/// isolate which leg of the wss handshake stalls.
#[test]
fn wss_raw_handshake_isolation() {
    use bao_boringssl_bridge::client::TlsClient;
    use bao_boringssl_bridge::connection::TlsConnection;
    use std::net::TcpStream;

    fn try_handshake(port: u16, stealth: bool) -> Result<(), String> {
        let mut tcp = TcpStream::connect(("127.0.0.1", port)).map_err(|e| e.to_string())?;
        tcp.set_read_timeout(Some(Duration::from_secs(3))).ok();
        let tls_client = TlsClient::new().map_err(|e| e.to_string())?;
        let mut tls =
            TlsConnection::new_client(&tls_client, "127.0.0.1").map_err(|e| e.to_string())?;
        if stealth {
            let profile = Some(bao_stealth::StealthProfile::firefox_default());
            let cfg = bun_runtime::stealth_http::stealth_profile_to_ssl_config(&profile);
            let host_c = std::ffi::CString::new("127.0.0.1").unwrap();
            let ssl = tls.ssl_ptr();
            bun_http::configure_http_client_with_alpn(
                unsafe { &mut *ssl },
                host_c.as_ptr(),
                bun_http::AlpnOffer::H1,
                Some(&cfg),
            );
        }
        loop {
            let res = tls.process().map_err(|e| format!("process: {}", e))?;
            // Flush every flight before blocking on read (BCE-20260814-WS-TLS
            // ordering contract).
            loop {
                let outgoing = tls.take_outgoing();
                if outgoing.is_empty() {
                    break;
                }
                tcp.write_all(&outgoing).map_err(|e| e.to_string())?;
            }
            if matches!(res.state, TlsState::Active | TlsState::PeerClosed) {
                return Ok(());
            }
            let mut buf = [0u8; 16_384];
            match tcp.read(&mut buf) {
                Ok(0) => return Err("eof".into()),
                Ok(n) => tls.feed(&buf[..n]),
                Err(e) => return Err(format!("read: {}", e)),
            }
        }
    }

    let (port, _resumed) = spawn_tls_ws_server();
    let plain = try_handshake(port, false);
    eprintln!("[raw] no-stealth result: {:?}", plain);
    let stealth = try_handshake(port, true);
    eprintln!("[raw] stealth result: {:?}", stealth);
    assert!(plain.is_ok(), "no-stealth handshake must work: {:?}", plain);
    assert!(
        stealth.is_ok(),
        "stealth handshake must work: {:?}",
        stealth
    );
}