Compare commits

..

3 Commits

Author SHA1 Message Date
Jarred Sumner
c1eccb94a4 Merge branch 'main' into claude/fix-ipv6-connect-timeout 2026-02-23 17:39:13 -08:00
Claude Bot
a9a0533998 fix(net): reduce connection attempt timeout from 10s to 1s
Per review feedback, reduce the Happy Eyeballs connection attempt timeout
from 10 seconds to 1 second. With 4s timer granularity this fires within
~4-8s, which is still fast enough to avoid long hangs on broken IPv6.

Also increase the test elapsed-time threshold to 20s and remove the
explicit test timeout to avoid flakiness in CI.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-22 06:00:32 +00:00
Claude Bot
23a22ab1dc fix(net): add connection timeout to prevent IPv6 hangs in bun update
When DNS returns both IPv6 and IPv4 addresses (common for npm registries),
broken IPv6 connectivity causes connection attempts to hang indefinitely.
The TCP stack's default SYN timeout is typically 120+ seconds per attempt,
and without a connection-level timeout, `bun update` and other commands
freeze waiting for unreachable IPv6 addresses.

This adds a 10-second connection attempt timeout to sockets created during
multi-address DNS connection establishment (the `start_connections` path in
uSockets). When a connecting socket times out, the timer sweep now treats
it as a connection failure and triggers the existing fallback logic to try
the next address in the list. This implements a simplified Happy Eyeballs
(RFC 8305) approach.

Changes:
- Set 10s timeout on individual sockets in `start_connections()` instead
  of inheriting the disabled (255) timeout from the connecting socket
- In the timer sweep, detect timed-out connecting sockets (those with
  non-null `connect_state`) and call `us_internal_socket_after_open()`
  with ETIMEDOUT to trigger address fallback

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-22 01:04:49 +00:00
11 changed files with 78 additions and 189 deletions

View File

@@ -591,7 +591,13 @@ int start_connections(struct us_connecting_socket_t *c, int count) {
struct us_socket_context_t* context = c->context;
struct us_socket_t *s = (struct us_socket_t *)us_create_poll(loop, 0, sizeof(struct us_socket_t) + c->socket_ext_size);
s->context = context;
s->timeout = c->timeout;
/* Set a connection attempt timeout so we don't hang forever on unreachable
* addresses (e.g. broken IPv6 connectivity). With the 4s timer granularity,
* this fires within ~4-8s. This implements a simplified Happy Eyeballs
* (RFC 8305) approach: if a connection attempt doesn't succeed within this
* window, it will be treated as a connect error and the next address in
* the list will be tried. */
us_socket_timeout(0, s, 1);
s->long_timeout = c->long_timeout;
struct us_socket_flags* flags = &s->flags;
flags->low_prio_state = 0;

View File

@@ -158,7 +158,15 @@ void us_internal_timer_sweep(struct us_loop_t *loop) {
if (short_ticks == s->timeout) {
s->timeout = 255;
if (context->on_socket_timeout != NULL) context->on_socket_timeout(s);
/* If this socket is still connecting (has a connect_state), treat the
* timeout as a connection failure. This implements Happy Eyeballs-style
* fallback: if an address (e.g. IPv6) doesn't respond within the
* connection timeout, we fail it and try the next address. */
if (s->connect_state != NULL) {
us_internal_socket_after_open(s, ETIMEDOUT);
} else {
if (context->on_socket_timeout != NULL) context->on_socket_timeout(s);
}
}
if (context->iterator == s && long_ticks == s->long_timeout) {
@@ -462,7 +470,7 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in
#ifdef _WIN32
const int recv_flags = MSG_PUSH_IMMEDIATE;
#else
const int recv_flags = MSG_DONTWAIT;
const int recv_flags = MSG_DONTWAIT | MSG_NOSIGNAL;
#endif
int length;

View File

@@ -467,9 +467,7 @@ public:
/* Write mark on first call to write */
writeMark();
if (!(httpResponseData->state & HttpResponseData<SSL>::HTTP_WROTE_TRANSFER_ENCODING_HEADER)) {
writeHeader("Transfer-Encoding", "chunked");
}
writeHeader("Transfer-Encoding", "chunked");
Super::write("\r\n", 2);
httpResponseData->state |= HttpResponseData<SSL>::HTTP_WRITE_CALLED;
}
@@ -491,9 +489,7 @@ public:
/* Write mark on first call to write */
writeMark();
if (!(httpResponseData->state & HttpResponseData<SSL>::HTTP_WROTE_TRANSFER_ENCODING_HEADER)) {
writeHeader("Transfer-Encoding", "chunked");
}
writeHeader("Transfer-Encoding", "chunked");
Super::write("\r\n", 2);
httpResponseData->state |= HttpResponseData<SSL>::HTTP_WRITE_CALLED;
}
@@ -562,9 +558,7 @@ public:
/* Write mark on first call to write */
writeMark();
if (!(httpResponseData->state & HttpResponseData<SSL>::HTTP_WROTE_TRANSFER_ENCODING_HEADER)) {
writeHeader("Transfer-Encoding", "chunked");
}
writeHeader("Transfer-Encoding", "chunked");
Super::write("\r\n", 2);
httpResponseData->state |= HttpResponseData<SSL>::HTTP_WRITE_CALLED;
}

View File

@@ -87,7 +87,6 @@ struct HttpResponseData : AsyncSocketData<SSL>, HttpParser {
HTTP_CONNECTION_CLOSE = 16, // used
HTTP_WROTE_CONTENT_LENGTH_HEADER = 32, // used
HTTP_WROTE_DATE_HEADER = 64, // used
HTTP_WROTE_TRANSFER_ENCODING_HEADER = 128, // used
};
/* Shared context pointer */

View File

@@ -336,7 +336,7 @@ pub const StandaloneModuleGraph = struct {
.contents = sliceToZ(raw_bytes, module.contents),
.sourcemap = if (module.sourcemap.length > 0)
.{ .serialized = .{
.bytes = sliceTo(raw_bytes, module.sourcemap),
.bytes = @alignCast(sliceTo(raw_bytes, module.sourcemap)),
} }
else
.none,
@@ -588,7 +588,7 @@ pub const StandaloneModuleGraph = struct {
if (comptime Environment.isDebug) {
// An expensive sanity check:
var graph = try fromBytes(allocator, output_bytes, offsets);
var graph = try fromBytes(allocator, @alignCast(output_bytes), offsets);
defer {
graph.files.unlockPointers();
graph.files.deinit();

View File

@@ -570,11 +570,6 @@ static void writeFetchHeadersToUWSResponse(WebCore::FetchHeaders& headers, uWS::
if (header.key == WebCore::HTTPHeaderName::Date) {
data->state |= uWS::HttpResponseData<isSSL>::HTTP_WROTE_DATE_HEADER;
}
// Prevent automatic Transfer-Encoding: chunked insertion when user provides one
if (header.key == WebCore::HTTPHeaderName::TransferEncoding) {
data->state |= uWS::HttpResponseData<isSSL>::HTTP_WROTE_TRANSFER_ENCODING_HEADER;
}
writeResponseHeader<isSSL>(res, name, value);
}
@@ -647,7 +642,6 @@ static void NodeHTTPServer__writeHead(
String key = propertyNames[i].string();
String value = headerValue.toWTFString(globalObject);
RETURN_IF_EXCEPTION(scope, void());
writeResponseHeader<isSSL>(response, key, value);
}
}

View File

@@ -2035,10 +2035,10 @@ pub fn readAll(fd: bun.FileDescriptor, buf: []u8) Maybe(usize) {
return .{ .result = total_read };
}
const send_flags_nonblock = c.MSG_DONTWAIT | c.MSG_NOSIGNAL;
const socket_flags_nonblock = c.MSG_DONTWAIT | c.MSG_NOSIGNAL;
pub fn recvNonBlock(fd: bun.FileDescriptor, buf: []u8) Maybe(usize) {
return recv(fd, buf, recv_flags_nonblock);
return recv(fd, buf, socket_flags_nonblock);
}
pub fn poll(fds: []std.posix.pollfd, timeout: i32) Maybe(usize) {
@@ -2119,7 +2119,7 @@ pub fn kevent(fd: bun.FileDescriptor, changelist: []const std.c.Kevent, eventlis
}
pub fn sendNonBlock(fd: bun.FileDescriptor, buf: []const u8) Maybe(usize) {
return send(fd, buf, send_flags_nonblock);
return send(fd, buf, socket_flags_nonblock);
}
pub fn send(fd: bun.FileDescriptor, buf: []const u8, flag: u32) Maybe(usize) {
@@ -4359,13 +4359,11 @@ const bun = @import("bun");
const Environment = bun.Environment;
const FD = bun.FD;
const MAX_PATH_BYTES = bun.MAX_PATH_BYTES;
const c = bun.c; // translated c headers
const jsc = bun.jsc;
const libc_stat = bun.Stat;
const darwin_nocancel = bun.darwin.nocancel;
const c = bun.c; // translated c headers
const recv_flags_nonblock = c.MSG_DONTWAIT;
const windows = bun.windows;
const kernel32 = bun.windows.kernel32;
const ntdll = bun.windows.ntdll;

View File

@@ -0,0 +1,48 @@
import { expect, test } from "bun:test";
// This test verifies that connection attempts via the multi-address DNS path
// don't hang forever. The connection timeout in the socket layer should
// cause individual connection attempts to fail within a bounded time,
// implementing a simplified Happy Eyeballs (RFC 8305) approach.
//
// This is critical for `bun update` and other package manager operations
// where IPv6 addresses may be returned by DNS but IPv6 connectivity is
// broken, causing connections to hang indefinitely.
test("fetch to IPv4 server via localhost succeeds", async () => {
// Start a server only on IPv4 127.0.0.1
await using server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch() {
return new Response("ok");
},
});
// Connect directly to the IPv4 address - this should work immediately
const resp = await fetch(`http://127.0.0.1:${server.port}/`);
expect(resp.status).toBe(200);
expect(await resp.text()).toBe("ok");
});
test("fetch via localhost resolves when server is on 127.0.0.1", async () => {
// Start a server only on IPv4 127.0.0.1
// "localhost" may resolve to both ::1 and 127.0.0.1
// The connection to ::1 should fail quickly and fall back to 127.0.0.1
await using server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch() {
return new Response("localhost-ok");
},
});
const start = performance.now();
const resp = await fetch(`http://localhost:${server.port}/`);
const elapsed = performance.now() - start;
expect(resp.status).toBe(200);
expect(await resp.text()).toBe("localhost-ok");
// With a 1s connect timeout and 4s timer granularity, fallback fires in ~4-8s.
// Use 20s threshold to avoid flakiness from timer jitter and CI load.
expect(elapsed).toBeLessThan(20_000);
});

View File

@@ -1,10 +1,10 @@
import { expect, test } from "bun:test";
import { test } from "bun:test";
import { once } from "events";
import { createServer, request } from "http";
import { AddressInfo, connect, Server } from "net";
import { request } from "http";
import { AddressInfo, Server } from "net";
const fixture = "node-http-transfer-encoding-fixture.ts";
test(`should not duplicate transfer-encoding header in request`, async () => {
test(`should not duplicate transfer-encoding header`, async () => {
const { resolve, promise } = Promise.withResolvers();
const tcpServer = new Server();
tcpServer.listen(0, "127.0.0.1");
@@ -54,43 +54,3 @@ test(`should not duplicate transfer-encoding header in request`, async () => {
return promise;
});
test("should not duplicate transfer-encoding header in response when explicitly set", async () => {
await using server = createServer((req, res) => {
res.writeHead(200, { "Transfer-Encoding": "chunked" });
res.write("Hello, World!");
res.end("Goodbye, World!");
});
await once(server.listen(0, "127.0.0.1"), "listening");
const { port } = server.address() as AddressInfo;
const { promise, resolve, reject } = Promise.withResolvers<string>();
const socket = connect(port, "127.0.0.1", () => {
socket.write("GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
});
let rawResponse = "";
socket.on("data", (chunk: Buffer) => {
rawResponse += chunk.toString();
});
socket.on("end", () => resolve(rawResponse));
socket.on("error", reject);
const response = await promise;
const headerSection = response.split("\r\n\r\n")[0];
const headerLines = headerSection
.split("\r\n")
.slice(1) // Skip status line
.filter(line => line.length > 0);
const transferEncodingHeaders = headerLines.filter(line => line.toLowerCase().startsWith("transfer-encoding:"));
expect(transferEncodingHeaders).toHaveLength(1);
// Verify the body content is correctly delivered via chunked encoding
const bodySection = response.split("\r\n\r\n").slice(1).join("\r\n\r\n");
expect(bodySection).toContain("Hello, World!");
expect(bodySection).toContain("Goodbye, World!");
});

View File

@@ -1,61 +0,0 @@
import { expect, test } from "bun:test";
import { bunEnv, tempDir } from "harness";
import { join } from "path";
// Regression test for https://github.com/oven-sh/bun/issues/27383
// Standalone executables with inline sourcemaps could crash with
// "panic: incorrect alignment" on Windows ARM64 (ReleaseSafe builds)
// because @alignCast promoted the alignment of sourcemap byte slices
// inside the LazySourceMap union from 1 to 8, but serialized sourcemap
// data in standalone binaries can be at any offset.
test("standalone compile with inline sourcemap does not crash from alignment", async () => {
// Use files with varying name lengths to increase the chance of
// non-8-byte-aligned sourcemap offsets in the standalone binary.
using dir = tempDir("issue-27383", {
"a.js": `export function a() { throw new Error("error from a"); }`,
"bb.js": `export function bb() { throw new Error("error from bb"); }`,
"ccc.js": `export function ccc() { throw new Error("error from ccc"); }`,
"ddddd.js": `export function ddddd() { throw new Error("error from ddddd"); }`,
"entry.js": `
import { a } from "./a.js";
import { bb } from "./bb.js";
import { ccc } from "./ccc.js";
import { ddddd } from "./ddddd.js";
const fns = [a, bb, ccc, ddddd];
const fn = fns[Math.floor(Math.random() * fns.length)];
try { fn(); } catch (e) {
// Accessing the stack triggers sourcemap parsing
console.log(e.stack);
}
`,
});
const result = await Bun.build({
entrypoints: [join(String(dir), "entry.js")],
compile: true,
sourcemap: "inline",
});
expect(result.success).toBe(true);
expect(result.outputs.length).toBe(1);
const executablePath = result.outputs[0].path;
await using proc = Bun.spawn({
cmd: [executablePath],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The stack trace should contain original file names (sourcemap worked)
expect(stdout).toMatch(/error from (a|bb|ccc|ddddd)/);
// Should not crash
expect(exitCode).toBe(0);
});

View File

@@ -1,57 +0,0 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// Regression test for #27389: recvfrom() was called with MSG_NOSIGNAL which
// is only valid for send operations. This caused EINVAL in strict environments
// like gVisor (Google Cloud Run). The fix removes MSG_NOSIGNAL from recv flags.
//
// On standard Linux the kernel silently ignores the invalid flag, so we verify
// the fix by ensuring socket recv operations complete without error.
test("socket recv works without EINVAL from invalid flags", async () => {
// Start a simple echo server and client that exercises the recv path
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const server = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
open(socket) {},
data(socket, data) {
// Echo back the data
socket.write(data);
socket.end();
},
},
});
const client = await Bun.connect({
hostname: "127.0.0.1",
port: server.port,
socket: {
open(socket) {
socket.write("hello");
},
data(socket, data) {
console.log(Buffer.from(data).toString());
socket.end();
},
close() {
server.stop(true);
},
},
});
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("hello");
expect(exitCode).toBe(0);
});