Compare commits

...

5 Commits

Author SHA1 Message Date
Claude Bot
7b848af6ef fix(tty): restore cursor visibility on process exit
When stdout is a TTY, write the cursor-show escape sequence (\x1b[?25h)
in bun_restore_stdio() before restoring termios settings.

Many CLI applications (like Ink) hide the cursor during operation and
rely on cleanup handlers to restore it. On macOS, if the process exits
before the cursor-show sequence is flushed, the cursor remains invisible
in the terminal. This fix ensures the cursor is always visible after Bun
exits, regardless of how the process terminates.

Fixes #26642

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:17:05 +00:00
Ciro Spaciari
a14a89ca95 fix(proxy): respect NO_PROXY for explicit proxy options in fetch and ws (#26608)
### What does this PR do?

Extract NO_PROXY checking logic from getHttpProxyFor into a reusable
isNoProxy method on the env Loader. This allows both fetch() and
WebSocket to check NO_PROXY even when a proxy is explicitly provided via
the proxy option (not just via http_proxy env var).

Changes:
- env_loader.zig: Extract isNoProxy() from getHttpProxyFor()
- FetchTasklet.zig: Check isNoProxy() before using explicit proxy
- WebSocket.cpp: Check Bun__isNoProxy() before using explicit proxy
- virtual_machine_exports.zig: Export Bun__isNoProxy for C++ access
- Add NO_PROXY tests for both fetch and WebSocket proxy paths

### How did you verify your code works?
Tests

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-01-30 16:20:45 -08:00
robobun
a5246344fa fix(types): Socket.reload() now correctly expects { socket: handler } (#26291)
## Summary
- Fix type definition for `Socket.reload()` to match runtime behavior
- The runtime expects `{ socket: handler }` but types previously
accepted just `handler`

## Test plan
- [x] Added regression test `test/regression/issue/26290.test.ts`
- [x] Verified test passes with `bun bd test`

Fixes #26290

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Alistair Smith <hi@alistair.sh>
2026-01-30 13:23:04 -08:00
robobun
f648483fe7 fix(types): add missing SIMD variants to Bun.Build.CompileTarget type (#26248)
## Summary

- Adds missing SIMD variants to the `Build.Target` TypeScript type
- The runtime accepts targets like `bun-linux-x64-modern` but TypeScript
was rejecting them
- Generalized the type to use `${Architecture}` template where possible

## Test plan

- [x] Added regression test in `test/regression/issue/26247.test.ts`
that validates all valid target combinations type-check correctly
- [x] Verified with `bun bd test test/regression/issue/26247.test.ts`

Fixes #26247

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Alistair Smith <hi@alistair.sh>
2026-01-30 13:13:28 -08:00
ddmoney420
01fa61045f fix(types): add missing bun-linux-x64-${SIMD} compile target type (#26607)
## Summary

- Adds missing `bun-linux-x64-baseline` and `bun-linux-x64-modern`
compile target types
- These targets are supported by the Bun CLI but were missing from the
TypeScript type definitions

## Changes

Added `bun-linux-x64-${SIMD}` to the `CompileTarget` type union, which
expands to:
- `bun-linux-x64-baseline`
- `bun-linux-x64-modern`

## Test plan

- [x] TypeScript should now accept `target: 'bun-linux-x64-modern'`
without type errors

Closes #26247

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-30 12:21:11 -08:00
11 changed files with 365 additions and 67 deletions

View File

@@ -2433,12 +2433,13 @@ declare module "bun" {
type SIMD = "baseline" | "modern";
type CompileTarget =
| `bun-darwin-${Architecture}`
| `bun-darwin-x64-${SIMD}`
| `bun-darwin-${Architecture}-${SIMD}`
| `bun-linux-${Architecture}`
| `bun-linux-${Architecture}-${Libc}`
| `bun-linux-${Architecture}-${SIMD}`
| `bun-linux-${Architecture}-${SIMD}-${Libc}`
| "bun-windows-x64"
| `bun-windows-x64-${SIMD}`
| `bun-linux-x64-${SIMD}-${Libc}`;
| `bun-windows-x64-${SIMD}`;
}
/**
@@ -5678,7 +5679,7 @@ declare module "bun" {
*
* This will apply to all sockets from the same {@link Listener}. it is per socket only for {@link Bun.connect}.
*/
reload(handler: SocketHandler): void;
reload(options: Pick<SocketOptions<Data>, "socket">): void;
/**
* Get the server that created this socket
@@ -6021,7 +6022,7 @@ declare module "bun" {
stop(closeActiveConnections?: boolean): void;
ref(): void;
unref(): void;
reload(options: Pick<Partial<SocketOptions>, "socket">): void;
reload(options: Pick<SocketOptions<Data>, "socket">): void;
data: Data;
}
interface TCPSocketListener<Data = unknown> extends SocketListener<Data> {

View File

@@ -394,6 +394,22 @@ extern "C" void bun_restore_stdio()
#if !OS(WINDOWS)
// Restore cursor visibility on TTY before exiting.
// Many CLI applications (like Ink) hide the cursor during operation and rely on
// cleanup handlers to restore it. If the process exits before the cursor-show
// escape sequence is flushed, the cursor remains invisible in the terminal.
// Writing the cursor-show sequence here ensures the cursor is always visible
// after Bun exits, regardless of how the process terminates.
// See: https://github.com/oven-sh/bun/issues/26642
if (bun_stdio_tty[STDOUT_FILENO]) {
// Show cursor: CSI ? 25 h
const char show_cursor[] = "\x1b[?25h";
ssize_t ret;
do {
ret = write(STDOUT_FILENO, show_cursor, sizeof(show_cursor) - 1);
} while (ret == -1 && errno == EINTR);
}
// restore stdio
for (int32_t fd = 0; fd < 3; fd++) {
if (!bun_stdio_tty[fd])

View File

@@ -83,6 +83,7 @@
namespace WebCore {
WTF_MAKE_TZONE_ALLOCATED_IMPL(WebSocket);
extern "C" int Bun__getTLSRejectUnauthorizedValue();
extern "C" bool Bun__isNoProxy(const char* hostname, size_t hostname_len, const char* host, size_t host_len);
static ErrorEvent::Init createErrorEventInit(WebSocket& webSocket, const String& reason, JSC::JSGlobalObject* globalObject)
{
@@ -573,6 +574,19 @@ ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& pr
// Determine connection type based on proxy usage and TLS requirements
bool hasProxy = proxyConfig.has_value();
// Check NO_PROXY even for explicitly-provided proxies
if (hasProxy) {
auto hostStr = m_url.host().toString();
auto hostWithPort = hostName(m_url, is_secure);
auto hostUtf8 = hostStr.utf8();
auto hostWithPortUtf8 = hostWithPort.utf8();
if (Bun__isNoProxy(hostUtf8.data(), hostUtf8.length(), hostWithPortUtf8.data(), hostWithPortUtf8.length())) {
proxyConfig = std::nullopt;
hasProxy = false;
}
}
bool proxyIsHTTPS = hasProxy && proxyConfig->isHTTPS;
// Connection type determines what kind of socket we use:

View File

@@ -158,6 +158,13 @@ export fn Bun__getTLSRejectUnauthorizedValue() i32 {
return if (jsc.VirtualMachine.get().getTLSRejectUnauthorized()) 1 else 0;
}
export fn Bun__isNoProxy(hostname_ptr: [*]const u8, hostname_len: usize, host_ptr: [*]const u8, host_len: usize) bool {
const vm = jsc.VirtualMachine.get();
const hostname: ?[]const u8 = if (hostname_len > 0) hostname_ptr[0..hostname_len] else null;
const host: ?[]const u8 = if (host_len > 0) host_ptr[0..host_len] else null;
return vm.transpiler.env.isNoProxy(hostname, host);
}
export fn Bun__setVerboseFetchValue(value: i32) void {
VirtualMachine.get().default_verbose_fetch = if (value == 1) .headers else if (value == 2) .curl else .none;
}

View File

@@ -1036,9 +1036,14 @@ pub const FetchTasklet = struct {
var proxy: ?ZigURL = null;
if (fetch_options.proxy) |proxy_opt| {
if (!proxy_opt.isEmpty()) { //if is empty just ignore proxy
proxy = fetch_options.proxy orelse jsc_vm.transpiler.env.getHttpProxyFor(fetch_options.url);
// Check NO_PROXY even for explicitly-provided proxies
if (!jsc_vm.transpiler.env.isNoProxy(fetch_options.url.hostname, fetch_options.url.host)) {
proxy = proxy_opt;
}
}
// else: proxy: "" means explicitly no proxy (direct connection)
} else {
// no proxy provided, use default proxy resolution
proxy = jsc_vm.transpiler.env.getHttpProxyFor(fetch_options.url);
}

View File

@@ -184,71 +184,88 @@ pub const Loader = struct {
}
}
// NO_PROXY filter
// See the syntax at https://about.gitlab.com/blog/2021/01/27/we-need-to-talk-no-proxy/
if (http_proxy != null and hostname != null) {
if (this.get("no_proxy") orelse this.get("NO_PROXY")) |no_proxy_text| {
if (no_proxy_text.len == 0 or strings.eqlComptime(no_proxy_text, "\"\"") or strings.eqlComptime(no_proxy_text, "''")) {
return http_proxy;
}
var no_proxy_iter = std.mem.splitScalar(u8, no_proxy_text, ',');
while (no_proxy_iter.next()) |no_proxy_item| {
var no_proxy_entry = strings.trim(no_proxy_item, &strings.whitespace_chars);
if (no_proxy_entry.len == 0) {
continue;
}
if (strings.eql(no_proxy_entry, "*")) {
return null;
}
//strips .
if (strings.startsWithChar(no_proxy_entry, '.')) {
no_proxy_entry = no_proxy_entry[1..];
if (no_proxy_entry.len == 0) {
continue;
}
}
// Determine if entry contains a port or is an IPv6 address
// IPv6 addresses contain multiple colons (e.g., "::1", "2001:db8::1")
// Bracketed IPv6 with port: "[::1]:8080"
// Host with port: "localhost:8080" (single colon)
const colon_count = std.mem.count(u8, no_proxy_entry, ":");
const is_bracketed_ipv6 = strings.startsWithChar(no_proxy_entry, '[');
const has_port = blk: {
if (is_bracketed_ipv6) {
// Bracketed IPv6: check for "]:port" pattern
if (std.mem.indexOf(u8, no_proxy_entry, "]:")) |_| {
break :blk true;
}
break :blk false;
} else if (colon_count == 1) {
// Single colon means host:port (not IPv6)
break :blk true;
}
// Multiple colons without brackets = bare IPv6 literal (no port)
break :blk false;
};
if (has_port) {
// Entry has a port, do exact match against host:port
if (host) |h| {
if (strings.eqlCaseInsensitiveASCII(h, no_proxy_entry, true)) {
return null;
}
}
} else {
// Entry is hostname/IPv6 only, match against hostname (suffix match)
if (strings.endsWith(hostname.?, no_proxy_entry)) {
return null;
}
}
}
if (this.isNoProxy(hostname, host)) {
return null;
}
}
return http_proxy;
}
/// Returns true if the given hostname/host should bypass the proxy
/// according to the NO_PROXY / no_proxy environment variable.
pub fn isNoProxy(this: *const Loader, hostname: ?[]const u8, host: ?[]const u8) bool {
// NO_PROXY filter
// See the syntax at https://about.gitlab.com/blog/2021/01/27/we-need-to-talk-no-proxy/
const hn = hostname orelse return false;
const no_proxy_text = this.get("no_proxy") orelse this.get("NO_PROXY") orelse return false;
if (no_proxy_text.len == 0 or strings.eqlComptime(no_proxy_text, "\"\"") or strings.eqlComptime(no_proxy_text, "''")) {
return false;
}
var no_proxy_iter = std.mem.splitScalar(u8, no_proxy_text, ',');
while (no_proxy_iter.next()) |no_proxy_item| {
var no_proxy_entry = strings.trim(no_proxy_item, &strings.whitespace_chars);
if (no_proxy_entry.len == 0) {
continue;
}
if (strings.eql(no_proxy_entry, "*")) {
return true;
}
//strips .
if (strings.startsWithChar(no_proxy_entry, '.')) {
no_proxy_entry = no_proxy_entry[1..];
if (no_proxy_entry.len == 0) {
continue;
}
}
// Determine if entry contains a port or is an IPv6 address
// IPv6 addresses contain multiple colons (e.g., "::1", "2001:db8::1")
// Bracketed IPv6 with port: "[::1]:8080"
// Host with port: "localhost:8080" (single colon)
const colon_count = std.mem.count(u8, no_proxy_entry, ":");
const is_bracketed_ipv6 = strings.startsWithChar(no_proxy_entry, '[');
const has_port = blk: {
if (is_bracketed_ipv6) {
// Bracketed IPv6: check for "]:port" pattern
if (std.mem.indexOf(u8, no_proxy_entry, "]:")) |_| {
break :blk true;
}
break :blk false;
} else if (colon_count == 1) {
// Single colon means host:port (not IPv6)
break :blk true;
}
// Multiple colons without brackets = bare IPv6 literal (no port)
break :blk false;
};
if (has_port) {
// Entry has a port, do exact match against host:port
if (host) |h| {
if (strings.eqlCaseInsensitiveASCII(h, no_proxy_entry, true)) {
return true;
}
}
} else {
// Entry is hostname/IPv6 only, match exact or dot-boundary suffix (case-insensitive)
const entry_len = no_proxy_entry.len;
if (hn.len == entry_len) {
if (strings.eqlCaseInsensitiveASCII(hn, no_proxy_entry, true)) return true;
} else if (hn.len > entry_len and
hn[hn.len - entry_len - 1] == '.' and
strings.eqlCaseInsensitiveASCII(hn[hn.len - entry_len ..], no_proxy_entry, true))
{
return true;
}
}
}
return false;
}
var did_load_ccache_path: bool = false;
pub fn loadCCachePath(this: *Loader, fs: *Fs.FileSystem) void {

View File

@@ -1,10 +1,21 @@
import { expectType } from "./utilities";
import { expectAssignable, expectType } from "./utilities";
Bun.build({
entrypoints: ["hey"],
splitting: false,
});
// Build.CompileTarget should accept SIMD variants (issue #26247)
expectAssignable<Bun.Build.CompileTarget>("bun-linux-x64-modern");
expectAssignable<Bun.Build.CompileTarget>("bun-linux-x64-baseline");
expectAssignable<Bun.Build.CompileTarget>("bun-linux-arm64-modern");
expectAssignable<Bun.Build.CompileTarget>("bun-linux-arm64-baseline");
expectAssignable<Bun.Build.CompileTarget>("bun-linux-x64-modern-glibc");
expectAssignable<Bun.Build.CompileTarget>("bun-linux-x64-modern-musl");
expectAssignable<Bun.Build.CompileTarget>("bun-darwin-x64-modern");
expectAssignable<Bun.Build.CompileTarget>("bun-darwin-arm64-baseline");
expectAssignable<Bun.Build.CompileTarget>("bun-windows-x64-modern");
Bun.build({
entrypoints: ["hey"],
splitting: false,

View File

@@ -145,3 +145,23 @@ listener.reload({
// ...listener.
},
});
// Test Socket.reload() type signature (issue #26290)
// The socket instance's reload() method should also accept { socket: handler }
await Bun.connect({
data: { arg: "asdf" },
socket: {
open(socket) {
// Socket.reload() should accept { socket: handler }, not handler directly
socket.reload({
socket: {
open() {},
data() {},
},
});
},
data() {},
},
hostname: "localhost",
port: 1,
});

View File

@@ -1,7 +1,7 @@
import axios from "axios";
import type { Server } from "bun";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { tls as tlsCert } from "harness";
import { bunEnv, bunExe, tls as tlsCert } from "harness";
import { HttpsProxyAgent } from "https-proxy-agent";
import { once } from "node:events";
import net from "node:net";
@@ -859,3 +859,84 @@ describe("proxy object format with headers", () => {
expect(response.status).toBe(200);
});
});
describe.concurrent("NO_PROXY with explicit proxy option", () => {
// These tests use subprocess spawning because NO_PROXY is read from the
// process environment at startup. A dead proxy that immediately closes
// connections is used so that if NO_PROXY doesn't work, the fetch fails
// with a connection error.
let deadProxyPort: number;
let deadProxy: ReturnType<typeof Bun.listen>;
beforeAll(() => {
deadProxy = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
open(socket) {
socket.end();
},
data() {},
},
});
deadProxyPort = deadProxy.port;
});
afterAll(() => {
deadProxy.stop(true);
});
test("NO_PROXY bypasses explicit proxy for fetch", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const resp = await fetch("http://localhost:${httpServer.port}", { proxy: "http://127.0.0.1:${deadProxyPort}" }); console.log(resp.status);`,
],
env: { ...bunEnv, NO_PROXY: "localhost" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
if (exitCode !== 0) console.error("stderr:", stderr);
expect(stdout.trim()).toBe("200");
expect(exitCode).toBe(0);
});
test("NO_PROXY with port bypasses explicit proxy for fetch", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const resp = await fetch("http://localhost:${httpServer.port}", { proxy: "http://127.0.0.1:${deadProxyPort}" }); console.log(resp.status);`,
],
env: { ...bunEnv, NO_PROXY: `localhost:${httpServer.port}` },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
if (exitCode !== 0) console.error("stderr:", stderr);
expect(stdout.trim()).toBe("200");
expect(exitCode).toBe(0);
});
test("NO_PROXY non-match does not bypass explicit proxy", async () => {
// NO_PROXY doesn't match, so fetch should try the dead proxy and fail
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`try { await fetch("http://localhost:${httpServer.port}", { proxy: "http://127.0.0.1:${deadProxyPort}" }); process.exit(1); } catch { process.exit(0); }`,
],
env: { ...bunEnv, NO_PROXY: "other.com" },
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
// exit(0) means fetch threw (proxy connection failed), proving proxy was used
expect(exitCode).toBe(0);
});
});

View File

@@ -13,6 +13,8 @@ const { HttpsProxyAgent } = require("https-proxy-agent") as {
// Use docker-compose infrastructure for squid proxy
const gc = harness.gc;
const bunExe = harness.bunExe;
const bunEnv = harness.bunEnv;
const isDockerEnabled = harness.isDockerEnabled;
// HTTP CONNECT proxy server for WebSocket tunneling
@@ -656,3 +658,86 @@ describe("ws module with HttpsProxyAgent", () => {
gc();
});
});
describe.concurrent("WebSocket NO_PROXY bypass", () => {
test("NO_PROXY matching hostname bypasses explicit proxy for ws://", async () => {
// authProxy requires credentials; if NO_PROXY works, the WebSocket bypasses
// the proxy and connects directly. If NO_PROXY doesn't work, the proxy
// rejects with 407 and the WebSocket errors.
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const ws = new WebSocket("ws://127.0.0.1:${wsPort}", { proxy: "http://127.0.0.1:${authProxyPort}" });
ws.onopen = () => { ws.close(); process.exit(0); };
ws.onerror = () => { process.exit(1); };`,
],
env: { ...bunEnv, NO_PROXY: "127.0.0.1" },
stdout: "pipe",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
if (exitCode !== 0) console.error("stderr:", stderr);
expect(exitCode).toBe(0);
});
test("NO_PROXY matching host:port bypasses proxy for ws://", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const ws = new WebSocket("ws://127.0.0.1:${wsPort}", { proxy: "http://127.0.0.1:${authProxyPort}" });
ws.onopen = () => { ws.close(); process.exit(0); };
ws.onerror = () => { process.exit(1); };`,
],
env: { ...bunEnv, NO_PROXY: `127.0.0.1:${wsPort}` },
stdout: "pipe",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
if (exitCode !== 0) console.error("stderr:", stderr);
expect(exitCode).toBe(0);
});
test("NO_PROXY not matching still uses proxy (auth fails)", async () => {
// NO_PROXY doesn't match the target, so the WebSocket should go through
// the auth proxy without credentials, which rejects with 407.
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const ws = new WebSocket("ws://127.0.0.1:${wsPort}", { proxy: "http://127.0.0.1:${authProxyPort}" });
ws.onopen = () => { process.exit(1); };
ws.onerror = () => { process.exit(0); };`,
],
env: { ...bunEnv, NO_PROXY: "other.host.com" },
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
// exit(0) means onerror fired, proving the proxy was used (and auth failed)
expect(exitCode).toBe(0);
});
test("NO_PROXY=* bypasses all proxies", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const ws = new WebSocket("ws://127.0.0.1:${wsPort}", { proxy: "http://127.0.0.1:${authProxyPort}" });
ws.onopen = () => { ws.close(); process.exit(0); };
ws.onerror = () => { process.exit(1); };`,
],
env: { ...bunEnv, NO_PROXY: "*" },
stdout: "pipe",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
if (exitCode !== 0) console.error("stderr:", stderr);
expect(exitCode).toBe(0);
});
});

View File

@@ -0,0 +1,41 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, isMacOS, isWindows } from "harness";
// Test that cursor visibility is restored on process exit when stdout is a TTY.
// This is needed because CLI applications like Ink hide the cursor during operation
// and rely on cleanup handlers to restore it. If the process exits before the
// cursor-show escape sequence is flushed, the cursor remains invisible.
// See: https://github.com/oven-sh/bun/issues/26642
// Skip on Windows and non-macOS - the script command behavior varies and CI
// environments often don't provide proper PTY support. The actual fix is
// most critical for macOS terminals where users reported the issue.
test.skipIf(isWindows || !isMacOS)("cursor visibility is restored on exit when stdout is TTY", async () => {
// Check if script command is available (needed to create a PTY)
const hasScript = Bun.which("script");
if (!hasScript) {
console.log("Skipping test: requires 'script' command for PTY simulation");
return;
}
// Script that just exits immediately - cursor restore should happen automatically
const testScript = `process.exit(0);`;
// Use script command to provide a PTY environment (macOS syntax)
const scriptCmd = ["script", "-q", "/dev/null", bunExe(), "-e", testScript];
await using proc = Bun.spawn({
cmd: scriptCmd,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The cursor-show escape sequence is \x1b[?25h
// It should be present in stdout when running in a TTY
const cursorShow = "\x1b[?25h";
expect(stdout).toContain(cursorShow);
expect(exitCode).toBe(0);
});