Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions src/runtime/bake/DevServer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2660,15 +2660,24 @@ impl DevServer {
// SAFETY: `self` is live; `framework_bundle` points into
// `self.route_bundles[route_bundle_index].data`. Raw-ptr receiver — see
// Note on `compute_arguments_for_framework_request`.
let args = unsafe {
let args = match unsafe {
Self::compute_arguments_for_framework_request(
self,
route_bundle_index,
framework_bundle,
params_js_value,
true,
)
}?;
} {
Ok(a) => a,
Err(e) => {
// `Saved.ctx` is `Copy`; explicit deinit so the pool slot releases.
if let SavedRequestUnion::Saved(mut saved) = req {
saved.deinit();
}
return Err(e);
}
};

self.server
.as_ref()
Expand Down Expand Up @@ -3107,7 +3116,7 @@ impl DeferredRequest {
);
let handler = ::core::mem::replace(&mut self.handler, Handler::Aborted);
match handler {
Handler::ServerHandler(saved) => {
Handler::ServerHandler(mut saved) => {
deferred_request::debug_log_dr!(
" request url: {}",
// SAFETY: saved.request is a live *mut webcore::Request (held strong by ctx)
Expand All @@ -3116,8 +3125,8 @@ impl DeferredRequest {
saved
.ctx
.set_signal_aborted(jsc::CommonAbortReason::ConnectionClosed);
// Note: saved.js_request (jsc::Strong) drops at end of arm
drop(saved);
// `ctx` is `Copy`; explicit deinit so `on_abort`'s deref reaches 0.
saved.deinit();
}
Handler::BundledHtmlPage(r) => {
// Reached from JS event-loop tasks (on_plugins_rejected, the
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,10 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
// SAFETY: `this` is the live server backref for this request.
let Some(server_js) = unsafe { &*this }.js_value_for_dispatch() else {
server_body::respond_stopped_503(bun_opaque::opaque_deref_mut(resp));
// `Saved.ctx` is `Copy`; explicit deinit so the pool slot releases.
if let SavedRequestUnion::Saved(mut saved) = req {
saved.deinit();
}
return;
};
let prepared: PreparedRequest<SSL, DEBUG> = match &req {
Expand Down
87 changes: 86 additions & 1 deletion test/bake/dev/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Bundle tests are tests concerning bundling bugs that only occur in DevServer.
import { expect } from "bun:test";
import { devTest, emptyHtmlFile, minimalFramework } from "../bake-harness";
import { once } from "node:events";
import fs from "node:fs";
import net from "node:net";
import { devTest, emptyHtmlFile, minimalFramework, WAIT_MULTIPLIER } from "../bake-harness";

devTest("import identifier doesnt get renamed", {
framework: minimalFramework,
Expand Down Expand Up @@ -865,3 +868,85 @@ devTest("barrel optimization: namespace re-export cycle through a star-exported
await c.expectMessage("result: object Y KEEP DEEP OTHER");
},
});
// A framework-route request that is deferred (SavedRequest in
// DeferredRequest::ServerHandler) and then aborted by the client must release
// its RequestContext refcount so `pending_requests` reaches zero and the
// server can deinit. The harness's gracefulExit() asserts deinit fires.
devTest("client disconnect on a deferred framework route releases the RequestContext", {
framework: minimalFramework,
// The plugin makes bundling observable: it writes `.entered` once the
// route's bundle has started (i.e. the request has been deferred) and then
// blocks until `.unblock` appears, giving the test a deterministic window to
// close the socket mid-bundle.
pluginFile: `
import * as fs from 'node:fs';
import * as path from 'node:path';
export default [
{
name: 'gate',
setup(build) {
build.onLoad({ filter: /gate\\.ts$/ }, async (args) => {
const dir = path.dirname(args.path);
fs.writeFileSync(path.join(dir, 'gate.entered'), '');
while (!fs.existsSync(path.join(dir, 'gate.unblock'))) {
await new Promise(r => setTimeout(r, 5));
}
return { contents: 'export const gate = 1;', loader: 'ts' };
});
},
},
];
`,
files: {
"gate.ts": `throw new Error('plugin should have replaced this');`,
"routes/index.ts": `
import { gate } from '../gate.ts';
export default function () {
return new Response('gate: ' + gate);
}
`,
},
async test(dev) {
const entered = dev.join("gate.entered");
const unblock = dev.join("gate.unblock");

// Raw TCP so closing the socket is an unambiguous client abort.
const sock = net.connect(dev.port, "127.0.0.1");
try {
await new Promise<void>((resolve, reject) => {
sock.once("connect", resolve);
sock.once("error", reject);
});
sock.write(`GET / HTTP/1.1\r\nHost: 127.0.0.1:${dev.port}\r\nConnection: keep-alive\r\n\r\n`);

// Wait until the bundler has actually picked up gate.ts: the request has
// reached the server and is parked in the deferred list.
const deadline = Date.now() + 5_000 * WAIT_MULTIPLIER;
while (!fs.existsSync(entered)) {
if (Date.now() > deadline) throw new Error("plugin gate never entered");
await new Promise(r => setTimeout(r, 5));
}

// Client goes away mid-bundle → RequestContext::on_abort runs the
// DeferredRequest abort callback and drops the SavedRequest.
sock.destroy();
await once(sock, "close");
} finally {
sock.destroy();
}
// Force the server's event loop to turn so the FIN is processed (and
// DeferredRequest::abort has run) before the plugin gate is released.
// /_dev_server_test_set is a plain route added by the harness bootstrap,
// so it returns without touching the bundler.
await dev.fetch("/_dev_server_test_set");

// Let bundling finish so the deferred list is drained (Handler is now
// Aborted; the drain loop skips it).
fs.writeFileSync(unblock, "");
await dev.fetch("/").equals("gate: 1");

// gracefulExit() at the end of this test sends `stop(true)` and polls
// getDevServerDeinitCount(); if pending_requests is stuck at 1 the
// server never deinits and the harness throws.
},
});
Loading