bun_runtime 0.1.0

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
// @trace TEST-ENG-007-HTTP-TE-PARITY [req:REQ-ENG-007] [level:integration]
// Behavioral parity split for HTTP/1.0 + Transfer-Encoding (RFC 9112 6.1,
// upstream Bun bdb738222): Bun.serve/Bun.listen reject the request outright
// with 400 INVALID_TRANSFER_ENCODING; node:http keeps Node llhttp semantics
// (dispatch the request, close the connection after — the HTTP/1.0 request
// already marks the connection close via isAncient). Both paths share the
// same uWS HttpParser; the split is carried by HttpFlags::isNodeHttp, set
// only by node_http::server_listen.

use bao_engine::context::JsContext;
use bao_engine::value::JsValue;
use std::io::{Read, Write};
use std::net::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 unified event loop (uWS sockets + timers + jobs) a few passes so
/// the in-process server accepts, parses and responds on our thread.
fn pump(ctx: &mut JsContext, passes: usize) {
    for _ in 0..passes {
        let mut cxm = ctx.cx();
        bun_runtime::timers::drain_and_check(&mut cxm);
        std::thread::sleep(Duration::from_millis(1));
    }
}

/// Send a raw request over a real TCP connection and collect the response,
/// alternating loop pumps with short-timeout reads (server and client share
/// this thread). Returns once the response head is complete or the peer
/// closes / budget is exhausted.
fn raw_roundtrip(ctx: &mut JsContext, port: u16, request: &[u8]) -> String {
    let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("tcp connect");
    stream
        .set_read_timeout(Some(Duration::from_millis(200)))
        .expect("read timeout");
    stream.write_all(request).expect("write request");

    let mut buf: Vec<u8> = Vec::new();
    let mut chunk = [0u8; 4096];
    for _ in 0..50 {
        pump(ctx, 2);
        match stream.read(&mut chunk) {
            Ok(0) => break,
            Ok(n) => {
                buf.extend_from_slice(&chunk[..n]);
                if buf.starts_with(b"HTTP/") && buf.windows(4).any(|w| w == b"\r\n\r\n") {
                    break;
                }
            }
            Err(ref e)
                if e.kind() == std::io::ErrorKind::WouldBlock
                    || e.kind() == std::io::ErrorKind::Interrupted =>
            {
                continue;
            }
            Err(_) => break,
        }
    }
    String::from_utf8_lossy(&buf).into_owned()
}

/// POST with `Transfer-Encoding: chunked` on an HTTP/1.0 request line — the
/// smuggling shape rejected by RFC 9112 6.1.
const SMUGGLED_10_TE: &[u8] =
    b"POST /a HTTP/1.0\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n";

/// First-principles realm model (ECMA-262/Node semantics): a Realm belongs to
/// the agent (JsContext) for its whole lifetime, NOT to a single script. State
/// installed by one `eval` (e.g. `globalThis.x = 1`) MUST be visible to a later
/// `eval` on the same context. The previous `eval`-per-global implementation
/// violated this (each eval got a fresh realm → eval B read `undefined`), which
/// is the same root disease as dispatch-after-eval handler loss. This test
/// pins the single-realm-per-context invariant.
#[test]
fn test_cross_eval_state_visibility() {
    bun_runtime::install_exit_handler();
    bun_runtime::bun_api::init_process_start();
    let mut ctx = JsContext::for_test().expect("JsContext init");
    ctx.set_global_setup(bun_runtime::globals::install_all);

    // eval A: install state on the shared global.
    let a = eval_str(
        &mut ctx,
        "globalThis.__realm_probe = 42; typeof globalThis.__realm_probe",
    );
    assert_eq!(a, "number", "eval A: setup write returned wrong type: {}", a);

    // eval B: the state MUST survive — same realm (Node semantics).
    let b = eval_str(&mut ctx, "globalThis.__realm_probe");
    assert_eq!(
        b, "42",
        "eval B: cross-eval state visibility broken (got {:?}); \
         single-realm-per-context invariant violated",
        b
    );

    // eval C: a function defined in eval A is callable from eval C.
    let c_def = eval_str(&mut ctx, "globalThis.__add = function(a,b){return a+b}; 'defined'");
    assert_eq!(c_def, "defined");
    let c_call = eval_str(&mut ctx, "globalThis.__add(40, 2)");
    assert_eq!(c_call, "42", "cross-eval function call broken");
}

/// Like `raw_roundtrip`, but keeps reading past the response head until the
/// peer closes or the budget is exhausted — used where the response BODY is
/// the assertion target (real JS handler output vs default echo).
fn raw_roundtrip_full(ctx: &mut JsContext, port: u16, request: &[u8]) -> String {
    let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("tcp connect");
    stream
        .set_read_timeout(Some(Duration::from_millis(200)))
        .expect("read timeout");
    stream.write_all(request).expect("write request");

    let mut buf: Vec<u8> = Vec::new();
    let mut chunk = [0u8; 4096];
    for _ in 0..50 {
        pump(ctx, 2);
        match stream.read(&mut chunk) {
            Ok(0) => break,
            Ok(n) => buf.extend_from_slice(&chunk[..n]),
            Err(ref e)
                if e.kind() == std::io::ErrorKind::WouldBlock
                    || e.kind() == std::io::ErrorKind::Interrupted =>
            {
                continue;
            }
            Err(_) => break,
        }
    }
    String::from_utf8_lossy(&buf).into_owned()
}

/// node:http2 rides the same uWS HTTP/1.x parser as node:http (all four App
/// construction sites in node_http2.rs set `set_is_node_http(true)`), so it
/// keeps the same node-family framing parity: an HTTP/1.0 request bearing
/// Transfer-Encoding is PARSED AND ROUTED to the stream handler (llhttp
/// parity), not 400-rejected as Bun.serve does per RFC 9112 6.1.
///
/// Why dispatch and not reject: real Node http2 GOAWAYs any HTTP/1.x text
/// outright (verified against node v24.5.0: SETTINGS+GOAWAY, stream handler
/// never invoked) — but Bao's http2 server is an HTTP/1.x-adapted compat
/// surface by design (it already dispatches plain HTTP/1.x requests as
/// pseudo-streams), so rejecting only the 1.0+TE pair would match neither
/// Node shape. The coherent split is: Bun.serve rejects at the parser,
/// node:* dispatches.
#[test]
fn test_node_http2_dispatches_http10_transfer_encoding_llhttp_parity() {
    bun_runtime::install_exit_handler();
    bun_runtime::bun_api::init_process_start();
    let mut ctx = JsContext::for_test().expect("JsContext init");
    ctx.set_global_setup(bun_runtime::globals::install_all);

    // http2's address() echoes the requested port (no ephemeral surfacing),
    // so reserve a free port in Rust and pass it explicitly.
    let probe = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("reserve free port");
    let port = probe.local_addr().unwrap().port();
    drop(probe);

    let setup = eval_str(
        &mut ctx,
        &format!(
            r#"
            var http2 = require('http2');
            var srv = http2.createServer(function(stream, headers) {{
                stream.respond({{ ':status': 200 }}, {{ endStream: false }});
                stream.end('ok');
            }});
            srv.listen({port}, '127.0.0.1');
            'setup-ok'
            "#
        ),
    );
    assert_eq!(setup, "setup-ok", "http2 server setup eval failed: {}", setup);

    let response = raw_roundtrip_full(&mut ctx, port, SMUGGLED_10_TE);
    assert!(
        !response.is_empty(),
        "node:http2 server produced no response for HTTP/1.0+TE"
    );
    assert!(
        !response.starts_with("HTTP/1.1 400") && !response.starts_with("HTTP/1.0 400"),
        "node:http2 must keep node-family llhttp parity (dispatch, not 400); got: {:?}",
        response.split("\r\n").next().unwrap_or("")
    );
    assert!(
        response.starts_with("HTTP/"),
        "node:http2 response missing status line: {:?}",
        response
    );
    // The JS stream handler's body is the handler-hit proof: dispatch
    // happened after the eval returned, so 'ok' can only come from the real
    // `stream.end('ok')` call inside the registered JS handler.
    assert!(
        response.ends_with("ok") || response.contains("\r\n\r\nok"),
        "node:http2 JS stream handler must have run and ended with 'ok'; got: {:?}",
        response
    );
}

/// Control: the same chunked request on HTTP/1.1 is valid framing and must
/// flow through the node:http2 stream handler untouched (guards against an
/// over-broad rejection that would also swallow legitimate 1.1 traffic).
#[test]
fn test_node_http2_accepts_http11_chunked_control() {
    bun_runtime::install_exit_handler();
    bun_runtime::bun_api::init_process_start();
    let mut ctx = JsContext::for_test().expect("JsContext init");
    ctx.set_global_setup(bun_runtime::globals::install_all);

    let probe = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("reserve free port");
    let port = probe.local_addr().unwrap().port();
    drop(probe);

    let setup = eval_str(
        &mut ctx,
        &format!(
            r#"
            var http2 = require('http2');
            var srv = http2.createServer(function(stream, headers) {{
                stream.respond({{ ':status': 200 }}, {{ endStream: false }});
                stream.end('ok11');
            }});
            srv.listen({port}, '127.0.0.1');
            'setup-ok'
            "#
        ),
    );
    assert_eq!(setup, "setup-ok", "http2 server setup eval failed: {}", setup);

    let response = raw_roundtrip_full(
        &mut ctx,
        port,
        b"POST /a HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n",
    );
    assert!(
        response.starts_with("HTTP/1.1 200"),
        "HTTP/1.1 + chunked is valid framing and must be served by the http2 compat layer; got: {:?}",
        response.split("\r\n").next().unwrap_or("")
    );
    assert!(
        response.ends_with("ok11") || response.contains("\r\n\r\nok11"),
        "node:http2 JS stream handler must have produced the body 'ok11'; got: {:?}",
        response
    );
}

/// node:http `listen(0)` must surface the OS-assigned ephemeral port through
/// `address()` (mirrors Bun.serve BCE-005 `actual_port`). This is the
/// runnable half of the node:http parity story: without it the ignored
/// dispatch test below cannot even address the server.
#[test]
fn test_node_http_listen_ephemeral_port() {
    bun_runtime::install_exit_handler();
    bun_runtime::bun_api::init_process_start();
    let mut ctx = JsContext::for_test().expect("JsContext init");
    ctx.set_global_setup(bun_runtime::globals::install_all);

    let port_str = eval_str(
        &mut ctx,
        r#"
        var http = require('http');
        var srv = http.createServer(function(req, res) {});
        srv.listen(0, '127.0.0.1');
        var addr = srv.address();
        (addr && typeof addr.port === 'number' && addr.port > 0) ? String(addr.port) : 'noaddr'
    "#,
    );
    let port: u16 = port_str.parse().unwrap_or_else(|_| {
        panic!("node:http listen(0) did not report the bound ephemeral port: {}", port_str)
    });

    // The reported port must be a real listening socket.
    let stream = TcpStream::connect_timeout(
        &::std::net::SocketAddr::from(([127, 0, 0, 1], port)),
        Duration::from_millis(1000),
    );
    assert!(stream.is_ok(), "connect to reported port {} refused", port);
    // No srv.close(): JsContext::eval is realm-per-call, so the setup eval's
    // `srv` is not reachable from a later eval. The server dies with the test
    // binary (same pattern as bug353 T7 leaving Bun.serve running).
}

/// node:http keeps llhttp framing semantics: an HTTP/1.0 request bearing
/// Transfer-Encoding is PARSED AND ROUTED (no 400) — the isNodeHttp=false
/// rejection never fires on this path.
///
/// Dispatch-after-eval is REAL here: the request is pumped through
/// `drain_and_check` AFTER the setup eval returned (its realm is popped),
/// so this exercises the persistent-rooted GcStore + AutoRealm dispatch
/// path end-to-end (BCE: handler used to be unresolvable via
/// CurrentGlobalOrNull → route handler returned without responding →
/// uWS std::terminate).
#[test]
fn test_node_http_dispatches_http10_transfer_encoding_llhttp_parity() {
    bun_runtime::install_exit_handler();
    bun_runtime::bun_api::init_process_start();
    let mut ctx = JsContext::for_test().expect("JsContext init");
    ctx.set_global_setup(bun_runtime::globals::install_all);

    let port_str = eval_str(
        &mut ctx,
        r#"
        var http = require('http');
        var srv = http.createServer(function(req, res) {
            res.statusCode = 200;
            res.end('ok');
        });
        srv.listen(0, '127.0.0.1');
        var addr = srv.address();
        (addr && typeof addr.port === 'number' && addr.port > 0) ? String(addr.port) : 'noaddr'
    "#,
    );
    let port: u16 = port_str.parse().unwrap_or_else(|_| {
        panic!("node:http listen(0) did not report the bound ephemeral port: {}", port_str)
    });

    let response = raw_roundtrip_full(&mut ctx, port, SMUGGLED_10_TE);
    assert!(
        !response.is_empty(),
        "node:http server produced no response for HTTP/1.0+TE"
    );
    assert!(
        !response.starts_with("HTTP/1.1 400") && !response.starts_with("HTTP/1.0 400"),
        "node:http must keep llhttp parity (dispatch, not 400); got: {:?}",
        response.split("\r\n").next().unwrap_or("")
    );
    assert!(
        response.starts_with("HTTP/"),
        "node:http response missing status line: {:?}",
        response
    );
    // The JS handler's body is the handler-hit proof: dispatch happened
    // after the eval returned (realm popped), so 'ok' can only come from the
    // real `res.end('ok')` call inside the registered JS handler.
    assert!(
        response.ends_with("ok") || response.contains("\r\n\r\nok"),
        "node:http JS handler must have run and ended with 'ok' (dispatch-after-eval); got: {:?}",
        response
    );
}

#[test]
fn test_bun_serve_rejects_http10_transfer_encoding() {
    bun_runtime::install_exit_handler();
    bun_runtime::bun_api::init_process_start();
    let mut ctx = JsContext::for_test().expect("JsContext init");
    ctx.set_global_setup(bun_runtime::globals::install_all);

    let port_str = eval_str(
        &mut ctx,
        r#"
        var s = Bun.serve({
            port: 0,
            fetch: function(req) { return new Response('hello'); }
        });
        String(s.port)
    "#,
    );
    assert!(port_str.parse::<u16>().is_ok(), "Bun.serve port: {}", port_str);
    let port: u16 = port_str.parse().unwrap();

    let response = raw_roundtrip(&mut ctx, port, SMUGGLED_10_TE);
    assert!(
        response.starts_with("HTTP/1.1 400"),
        "Bun.serve must reject HTTP/1.0+TE with 400 (RFC 9112 6.1); got: {:?}",
        response.split("\r\n").next().unwrap_or("")
    );

    // Handler never invoked: JsContext::eval creates a fresh realm per call,
    // so the setup eval's JS state is not readable afterwards. The 400 status
    // line is the authoritative signal — a routed request always completes as
    // 200 (worst case via Bun.serve's default-response fallback), so a 400 can
    // only come from the parser rejecting before routing.
}

/// Control: the same chunked request on HTTP/1.1 is valid framing and must
/// flow through Bun.serve untouched (guards against an over-broad guard).
///
/// The body assertion carries the dispatch-after-eval verdict: the request
/// is pumped via `drain_and_check` AFTER the setup eval returned (realm
/// popped), so a 200 with the handler's real body ("hello") proves the
/// persistent-rooted GcStore + AutoRealm dispatch reached the JS fetch
/// handler — the default echo (`{"method":...}`) is now reserved for
/// servers with NO registered handler and must not appear here.
#[test]
fn test_bun_serve_accepts_http11_chunked_control() {
    bun_runtime::install_exit_handler();
    bun_runtime::bun_api::init_process_start();
    let mut ctx = JsContext::for_test().expect("JsContext init");
    ctx.set_global_setup(bun_runtime::globals::install_all);

    let port_str = eval_str(
        &mut ctx,
        r#"
        var s = Bun.serve({
            port: 0,
            fetch: function(req) { return new Response('hello'); }
        });
        String(s.port)
    "#,
    );
    let port: u16 = port_str.parse().unwrap_or_else(|_| {
        panic!("Bun.serve did not report the bound ephemeral port: {}", port_str)
    });

    let response = raw_roundtrip_full(
        &mut ctx,
        port,
        b"POST /a HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n",
    );
    assert!(
        response.starts_with("HTTP/1.1 200"),
        "HTTP/1.1 + chunked is valid framing and must be served; got: {:?}",
        response.split("\r\n").next().unwrap_or("")
    );
    assert!(
        response.ends_with("hello") || response.contains("\r\n\r\nhello"),
        "real JS fetch handler must have produced the body 'hello' (dispatch-after-eval); got: {:?}",
        response
    );
    assert!(
        !response.contains("\"method\""),
        "default echo response must not impersonate the JS handler; got: {:?}",
        response
    );
}

/// Upstream f7ad274e3 parity (node:http2 stream release): a completed or
/// closed Http2Stream must be released from the session's stream registry
/// immediately — node drops a stream as soon as both halves close, and the
/// upstream bug leaked one stream per request for the whole session
/// lifetime (WeakRef never cleared). Bao's release points are the JS
/// session registry (`_streams`): eviction when the client bridge settles
/// (response delivered or fetch failed — the bridge returns the fetch
/// Promise, not the old synchronous placeholder), and on `stream.close()` /
/// `stream.destroy()` (session close sweeps via per-stream close).
///
/// The open-stream cases hide the global fetch bridge so `request()` leaves
/// the stream in flight (that is the only local way to hold an open
/// stream); the completed-request case drives the loop until the refused
/// port fetches settle (the rejection path evicts too). The fetch target is
/// a closed local port, so no real network peer is involved.
#[test]
fn test_node_http2_streams_released_from_session_registry_on_close() {
    bun_runtime::install_exit_handler();
    bun_runtime::bun_api::init_process_start();
    let mut ctx = JsContext::for_test().expect("JsContext init");
    ctx.set_global_setup(bun_runtime::globals::install_all);

    // Case 1 (release-on-completion, the upstream core scenario): N
    // completed outbound requests must not linger in the registry. The
    // fetches to the refused port settle asynchronously — pump the loop
    // (ConcurrentTask dispatch + microtasks) until the registry drains.
    let scheduled = eval_str(
        &mut ctx,
        r#"
        var http2 = require('http2');
        globalThis.__h2reg = http2.connect('127.0.0.1:9');
        for (var i = 0; i < 5; i++) {
          globalThis.__h2reg.request({ ':method': 'GET', ':path': '/' });
        }
        'scheduled'
        "#,
    );
    assert_eq!(scheduled, "scheduled");
    let drained = {
        let deadline = std::time::Instant::now() + Duration::from_secs(8);
        loop {
            let n = eval_str(
                &mut ctx,
                "String(Object.keys(globalThis.__h2reg._streams).length)",
            );
            if n == "0" || std::time::Instant::now() > deadline {
                break n;
            }
            let cx_raw = ctx.raw_cx();
            unsafe {
                mozjs_sys::jsapi::js::RunJobs(cx_raw);
            }
            let mut cxm = ctx.cx();
            bun_runtime::timers::drain_and_check(&mut cxm);
            std::thread::sleep(Duration::from_millis(2));
        }
    };
    assert_eq!(
        drained, "0",
        "5 settled outbound requests must not linger in the session registry"
    );

    // Cases 2-4: open streams, released by close()/destroy()/session
    // close. Hide the fetch bridge to keep requests in flight.
    let result = eval_str(
        &mut ctx,
        r#"
        (function() {
          var http2 = require('http2');
          var savedFetch = globalThis.__http2_fetch;
          delete globalThis.__http2_fetch;
          var out;
          try {
            var s2 = http2.connect('127.0.0.1:9');
            var a = s2.request({ ':method': 'GET', ':path': '/' });
            var b = s2.request({ ':method': 'GET', ':path': '/b' });
            var openRegistered = Object.keys(s2._streams).length;
            a.close();
            var afterStreamClose = Object.keys(s2._streams).length;
            b.destroy();
            var afterDestroy = Object.keys(s2._streams).length;

            var s3 = http2.connect('127.0.0.1:9');
            s3.request({ ':method': 'GET', ':path': '/' });
            s3.request({ ':method': 'GET', ':path': '/x' });
            var beforeSessionClose = Object.keys(s3._streams).length;
            s3.close();
            var afterSessionClose = Object.keys(s3._streams).length;

            out = [openRegistered, afterStreamClose, afterDestroy,
                   beforeSessionClose, afterSessionClose].join(',');
          } finally {
            globalThis.__http2_fetch = savedFetch;
          }
          delete globalThis.__h2reg;
          return out;
        })()
        "#,
    );
    // openRegistered=2 (premise: open streams ARE registered — otherwise
    // the zeros pass vacuously), afterStreamClose=1, afterDestroy=0,
    // beforeSessionClose=2 (premise), afterSessionClose=0.
    assert_eq!(
        result, "2,1,0,2,0",
        "node:http2 session registry must release completed/closed streams (f7ad274e3 parity); got: {}",
        result
    );
}