Files
bun-src/test/cli/install/bun-install-stalled-tls.test.ts
2026-08-27 21:09:14 +00:00

77 lines
3.1 KiB
TypeScript

// https://github.com/oven-sh/bun/issues/30325
//
// `bun install` would hang indefinitely when an HTTPS registry connection
// stalled during the TLS handshake: the HTTP client only armed its idle
// timer once `onWritable` fired *after* the handshake completed, so a server
// that accepted TCP (socket ESTABLISHED) but never answered ClientHello left
// the request — and the whole install — blocked in epoll_wait with no timer.
//
// The fix arms the idle timer in `onOpen()` and makes the duration
// configurable via `BUN_CONFIG_HTTP_IDLE_TIMEOUT` so this test can run in a
// few seconds rather than the 5-minute default.
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import * as net from "node:net";
test("bun install times out when the registry accepts TCP but never completes the TLS handshake", async () => {
// Raw TCP listener: accepts the connection, reads (and discards) the
// ClientHello, and never writes a single byte back. From the client's
// point of view the socket is ESTABLISHED but the handshake stalls forever.
const sockets = new Set<net.Socket>();
const server = net.createServer(socket => {
sockets.add(socket);
socket.on("data", () => {});
socket.on("close", () => sockets.delete(socket));
socket.on("error", () => {});
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as net.AddressInfo).port;
try {
using dir = tempDir("install-handshake-stall", {
"package.json": JSON.stringify({
name: "stall-repro",
version: "1.0.0",
dependencies: { lodash: "4.17.21" },
}),
"bunfig.toml": Bun.TOML.stringify({
install: {
registry: `https://127.0.0.1:${port}/`,
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: {
...bunEnv,
// Trip the idle timer after a few seconds instead of 5 minutes.
BUN_CONFIG_HTTP_IDLE_TIMEOUT: "3",
// Don't spin through 5 retries (each its own timeout) — one is enough
// to prove the request completed with an error rather than hanging.
BUN_CONFIG_HTTP_RETRY_COUNT: "0",
// Keep the self-signed / hostname-mismatch check from short-circuiting
// the handshake with a different error before the stall is observed.
NODE_TLS_REJECT_UNAUTHORIZED: "0",
},
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const combined = stdout + stderr;
// The manifest GET should have failed with the idle-timeout error. Exact
// wording differs between paths ("Timeout", "timed out"), so accept either.
expect(combined.toLowerCase()).toMatch(/time.?out|timed out/);
// Must have actually exited — a hang would never reach here, and a
// successful install (somehow) would be wrong.
expect(exitCode).not.toBe(0);
} finally {
for (const s of sockets) s.destroy();
await new Promise<void>(resolve => server.close(() => resolve()));
}
}, 60_000);