Compare commits

...

22 Commits

Author SHA1 Message Date
Claude Bot
9820a8fe95 fix: hoist remaining closures from kDoFetch and setTimeout to module scope
- Extract .then(response => ...) to module-scope onFetchResponse + .bind
- Extract handleResponse closure to module-scope handleFetchResponse + .bind
- Extract res.setTimeout closure to module-scope resSetTimeout function
- Extract res timeout timer to module-scope resTimeoutFired (uses
  setTimeout's extra args to pass res/callback without closure)
- Extract .catch() to module-scope onFetchError + .bind
- Extract .finally() to module-scope onFetchFinally + .bind (keepOpen
  moved to instance property kKeepOpen)
- Extract prototype setTimeout timer to module-scope onRequestTimeout + .bind
- Store fetch response on instance via kResponse symbol

The only closures remaining are:
- DNS lookup callback (external API requires callback)
- iterate() for happy eyeballs (captures mutable candidates array)
- Async generator body (generators have their own this)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-21 03:19:47 +00:00
Claude Bot
3ccbda5138 fix: eliminate remaining closures in ClientRequest
- Replace per-instance no-op closures (kResolveNextChunk, kHandleResponse,
  kOnEnd initializers) with shared module-scope noop function
- Extract `(s, e) => s.emit("error", e)` closures to module-scope
  emitErrorNT function
- Extract `fail()` closure from DNS lookup callback to module-scope
  emitLookupError function

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-21 03:11:33 +00:00
Claude Bot
cdc05d0ec8 fix: use symbol keys for internal methods, replace closures with .bind
- All internal prototype methods now use Symbol keys instead of string
  keys (e.g., _writeInternal -> [kWriteInternal]) so they are not
  user-observable
- Replace signal abort closure with onSignalAbort.bind(this)
- Replace _send's onEnd closure with onEndCallHandleResponse.bind(this)
- Replace _onAbort.bind(this) with [kOnAbort].bind(this)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-21 03:03:37 +00:00
Claude Bot
1c5f27a0b7 fix: remove unused local port variable in ClientRequest constructor
Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-21 02:48:19 +00:00
Claude Bot
727b1b820a refactor(http): hoist ClientRequest closures to prototype/module scope
Move all ~30 closures from inside the ClientRequest constructor to
prototype methods and module-scope helpers. This eliminates closure
captures of the parent scope, reducing memory per-instance and
improving JIT optimization.

- Convert instance method assignments (write, end, destroy, flushHeaders,
  abort, _ensureTls, setSocketKeepAlive, setNoDelay, kClearTimeout) to
  prototype methods
- Convert internal helpers (maybeEmitSocket/Prefinish/Finish/Close,
  socketCloseListener, onAbort, pushChunk, write_, send, startFetch)
  to prototype methods with _ prefix
- Extract getURL and emitResponseNT as module-scope functions
- Replace closure variables (fetching, writeCount, resolveNextChunk,
  handleResponse, onEnd) with symbol-keyed instance properties
- Drop the no-op oldEnd capture (OutgoingMessage.prototype.end is a stub)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-21 02:29:04 +00:00
robobun
76754a8ead fix(shell): support -e and -E flags in builtin echo (#27144)
## Summary

- Bun's builtin `echo` only supported the `-n` flag. The `-e` flag (and
`-E`) were treated as literal text, causing `echo -e password` to output
`-e password` instead of `password`. This broke common patterns like
`echo -e $password | sudo -S ...`.
- Added full `-e` (enable backslash escapes) and `-E` (disable backslash
escapes) flag support, matching bash behavior including combined flags
like `-ne`, `-en`, `-eE`, `-Ee`.
- Supported escape sequences: `\\`, `\a`, `\b`, `\c`, `\e`/`\E`, `\f`,
`\n`, `\r`, `\t`, `\v`, `\0nnn` (octal), `\xHH` (hex).

Closes #17405

## Test plan

- [x] Added 22 tests in `test/regression/issue/17405.test.ts` covering
all escape sequences, flag combinations, and the original issue scenario
- [x] Verified tests fail with system bun (19/22 fail) and pass with
debug build (22/22 pass)
- [x] Verified existing shell tests (`bunshell.test.ts`) still pass —
all 27 echo-related tests pass, no regressions

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

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
2026-02-19 20:21:49 -08:00
robobun
ecd4e680eb fix(router): don't cache file descriptors in Route.parse to prevent stale fd reuse (#27164)
## Summary
- `FileSystemRouter.Route.parse()` was caching file descriptors in the
global entry cache (`entry.cache.fd`). When `Bun.build()` later closed
these fds during `ParseTask`, the cache still referenced them.
Subsequent `Bun.build()` calls would find these stale fds, pass them to
`readFileWithAllocator`, and `seekTo(0)` would fail with EBADF (errno
9).
- The fix ensures `Route.parse` always closes the file it opens for
`getFdPath` instead of caching it in the shared entry. The fd was only
used to resolve the absolute path via `getFdPath`, so caching was
unnecessary and harmful.

Closes #18242

## Test plan
- [x] Added regression test `test/regression/issue/18242.test.ts` that
creates a `FileSystemRouter` and runs `Bun.build()` three times
sequentially
- [x] Test passes with `bun bd test test/regression/issue/18242.test.ts`
- [x] Test fails with `USE_SYSTEM_BUN=1 bun test
test/regression/issue/18242.test.ts` (system bun v1.3.9)
- [x] Verified 5 sequential builds work correctly after the fix

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

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
2026-02-19 20:15:11 -08:00
robobun
044bb00382 fix(sqlite): finalize transaction statements on close() to prevent "database is locked" (#27202)
## Summary

- Fixes `db.close(true)` throwing "database is locked" after using
`db.transaction()`
- The `getController` function creates prepared statements via
`db.prepare()` which bypasses the query cache, so they were never
finalized during `close()`
- `close()` now explicitly finalizes any cached transaction controller
statements before calling `sqlite3_close()`

Fixes #14709

## Test plan

- [x] New regression tests in `test/regression/issue/14709.test.ts`
covering:
  - Basic `close(true)` after `transaction()`
  - `close(true)` after transaction with actual work
  - `using` declaration (calls `close(true)` via `Symbol.dispose`)
  - Multiple transaction types (deferred, immediate, exclusive)
  - Nested transactions
- [x] All new tests fail with system bun (`USE_SYSTEM_BUN=1`) and pass
with debug build
- [x] Existing SQLite test suite (`test/js/bun/sqlite/sqlite.test.js`)
passes with no regressions

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

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-19 20:11:02 -08:00
robobun
655aab845d fix(css): prevent style rule deduplication across @property boundaries (#27119)
## Summary

- Fixes the CSS bundler incorrectly removing a `:root` selector when it
appears before an `@property` at-rule and another `:root` exists after
it
- The deduplication logic in `CssRuleList.minify()` was merging style
rules across non-style rule boundaries (like `@property`), which changes
CSS semantics
- Clears the `style_rules` deduplication map when a non-style rule is
appended, preventing merges across these boundaries

## Test plan

- [x] Added regression test in `test/regression/issue/27117.test.ts`
- [x] Verified test fails with system bun (`USE_SYSTEM_BUN=1`) —
reproduces the bug
- [x] Verified test passes with debug build (`bun bd test`)
- [x] Verified adjacent `:root` rules (without intervening at-rules) are
still correctly merged
- [x] All existing CSS bundler tests pass
(`test/bundler/esbuild/css.test.ts` — 53 tests)
- [x] All CSS modules tests pass (`test/bundler/css/css-modules.test.ts`
— 3 tests)

Closes #27117

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

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-19 20:03:18 -08:00
robobun
4141ef1edf fix(shell): fix unicode cursor tracking causing __bunstr_N leak in output (#27226)
## Summary
- Fixed `srcBytesAtCursor()` and `cursorPos()` in the shell lexer's
unicode path (`ShellCharIter(.wtf8)`) to use `self.src.cursor.i` instead
of `self.src.iter.i`, which was permanently stuck at 0
- Fixed `bumpCursorAscii()` to properly decode the codepoint at the new
cursor position instead of storing the last digit character, which
caused the wrong character to be returned on the next read

## Root Cause
When the shell template literal source contained multi-byte UTF-8
characters (e.g., `Í`, `€`), the `LexerUnicode` path was used. In this
path, `srcBytesAtCursor()` and `cursorPos()` referenced
`self.src.iter.i` — the `CodepointIterator`'s internal field that is
never modified (the `next()` method takes `*const Iterator`). This
meant:

1. `srcBytesAtCursor()` always returned bytes from position 0 (the start
of the source)
2. `looksLikeJSStringRef()` checked for `__bunstr_` at position 0
instead of the current cursor position, failing to match
3. The `\x08__bunstr_N` reference was passed through as literal text
into the shell output

This only occurred when **both** conditions were met:
- An interpolated value contained a space (triggering
`needsEscapeBunstr` → stored as `__bunstr_N` ref)
- A subsequent value contained multi-byte UTF-8 (triggering
`LexerUnicode` instead of `LexerAscii`)

Closes #17244

## Test plan
- [x] Added regression tests in `test/regression/issue/17244.test.ts`
- [x] Verified tests fail with `USE_SYSTEM_BUN=1` (system bun 1.3.9)
- [x] Verified tests pass with `bun bd test`
- [x] Ran existing shell tests (`bunshell.test.ts`,
`bunshell-default.test.ts`, `bunshell-instance.test.ts`) — no
regressions

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

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-19 19:38:33 -08:00
Jarred Sumner
e57593759f Update no-validate-leaksan.txt 2026-02-19 16:35:56 -08:00
robobun
e7cf4b77ba fix(css): strip leading @layer declarations from bundled CSS output (#27131)
## Summary
- When bundling CSS with `@layer` declarations (e.g. `@layer one;`)
followed by `@import` rules with `layer()`, the bundler left the bare
`@layer` statements and `@import` lines in the output even though their
content was already inlined into `@layer` blocks
- The fix adds `.layer_statement` to the leading-rule filter in
`prepareCssAstsForChunk`, which already stripped `@import` and
`.ignored` rules but missed `@layer` statement rules

Closes #20546

## Test plan
- [x] New regression test in `test/regression/issue/20546.test.ts`
covers both separate `@layer` statements and comma syntax
- [x] Test fails with system bun (`USE_SYSTEM_BUN=1`) confirming the bug
- [x] Test passes with debug build (`bun bd test`)
- [x] All 53 existing CSS bundler tests pass
(`test/bundler/esbuild/css.test.ts`)

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

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-19 12:31:47 -08:00
robobun
2e5e21015f fix(bundler): emit valid JS for unused dynamic imports (#27176)
## Summary

- Fixes `bun build` producing syntactically invalid JavaScript
(`Promise.resolve().then(() => )`) for unused dynamic imports like `void
import("./dep.ts")` or bare `import("./dep.ts")` expression statements
- When `exports_ref` is cleared for unused results but the `.then(() =>
...)` wrapper was still emitted, the arrow function body was empty. Now
skips the `.then()` wrapper entirely when there's nothing to execute
inside the callback, producing just `Promise.resolve()`
- The bug only affected cases where the import result was unused —
`const x = import(...)`, `await import(...)`, and `.then()` chains were
already correct

Closes #24709

## Test plan

- [x] Added regression test in `test/regression/issue/24709.test.ts`
that validates both `void import()` and bare `import()` statement cases
- [x] Verified test fails with system bun (reproduces the bug) and
passes with debug build (fix works)
- [x] Verified used dynamic imports (`const m = await import(...)`)
still produce correct `.then(() => exports)` output

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

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-19 12:30:15 -08:00
SUZUKI Sosuke
b04303cb23 fix(gc): fix three GC safety issues (speculative fix for BUN-Q81) (#27190)
## Speculative fix for
[BUN-Q81](https://bun-p9.sentry.io/issues/BUN-Q81)

BUN-Q81 is a long-standing `SlotVisitor::drain` segfault during GC
marking (150 occurrences since July 2025, across v1.1.10 through
v1.3.10). A full audit of the codebase for GC safety issues found three
bugs:

### 1. `JSCommonJSModule::m_overriddenCompile` not visited in
`visitChildren`

`m_overriddenCompile` is a `WriteBarrier<Unknown>` that stores the
overridden `module._compile` function (used by `ts-node`, `pirates`,
`@swc-node/register`, etc.). It was the only WriteBarrier field in the
class not visited by `visitChildrenImpl`, making it invisible to the GC.
The pointed-to function could be prematurely collected, and subsequent
GC marking would follow the dangling WriteBarrier pointer into freed
memory.

**This is the strongest candidate for BUN-Q81.**

### 2. `JSSQLStatement::userPrototype` — wrong owner in
`WriteBarrier::set()`

```cpp
// Before (wrong):
castedThis->userPrototype.set(vm, classObject, prototype.getObject());
// After (correct):
castedThis->userPrototype.set(vm, castedThis, prototype.getObject());
```

The owner parameter must be the object containing the WriteBarrier so
the GC's remembered set is updated correctly. All other `.set()` calls
in the same file correctly use `castedThis`.

### 3. `NodeVMSpecialSandbox` — missing `visitChildren` entirely

`NodeVMSpecialSandbox` has a `WriteBarrier<NodeVMGlobalObject>
m_parentGlobal` member but had no `visitChildren` implementation. Added
the standard boilerplate.
2026-02-19 12:28:49 -08:00
SUZUKI Sosuke
b6eaa96e56 fix: release ReadableStream Strong ref on fetch body cancel (#27191)
## Summary

When a streaming HTTP response body is cancelled via `reader.cancel()`
or `body.cancel()`, `FetchTasklet.readable_stream_ref` (a
`ReadableStream.Strong` GC root) was never released. This caused
ReadableStream objects, associated Promises, and Uint8Array buffers to
be retained indefinitely — leaking ~260KB per cancelled streaming
request.

## Root Cause

`ByteStream.onCancel()` cleaned up its own state (`done = true`, buffer
freed, pending promise resolved) but **did not notify the
FetchTasklet**. The Strong ref was only released when:
- `has_more` became `false` (HTTP response fully received) — but the
server may keep the connection open
- `Bun__FetchResponse_finalize` — but this checks
`readable_stream_ref.held.has()` and **skips cleanup when the Strong ref
is set** (line 958)

This created a circular dependency: the Strong ref prevented GC, and the
finalizer skipped cleanup because the Strong ref existed.

## Fix

Add a `cancel_handler` callback to `NewSource` (`ReadableStream.zig`)
that propagates cancel events to the data producer. `FetchTasklet`
registers this callback via `Body.PendingValue.onStreamCancelled`. When
the stream is cancelled, the handler calls
`ignoreRemainingResponseBody()` to release the Strong ref, stop
processing further HTTP data, and unref the event loop.

To prevent use-after-free when `FetchTasklet` is freed before `cancel()`
is called (e.g., HTTP response completes normally, then user cancels the
orphaned stream), `clearStreamCancelHandler()` nulls the
`cancel_handler` on the `ByteStream.Source` at all 3 sites where
`readable_stream_ref` is released.

## Test

Added `test/js/web/fetch/fetch-stream-cancel-leak.test.ts` — uses a raw
TCP server (`Bun.listen`) that sends one HTTP chunk then keeps the
connection open. Client fetches 30 times, reads one chunk, cancels, then
asserts `heapStats().objectTypeCounts.ReadableStream` does not
accumulate. Before the fix, all 30 ReadableStreams leaked; after the
fix, 0 leak.
2026-02-19 12:22:43 -08:00
robobun
6a8f33e7b1 fix(windows): close libuv pipes before freeing to prevent handle_queue corruption (#27124) 2026-02-19 00:29:43 -08:00
Jarred Sumner
c3ae343fc9 fix(windows): use-after-free in WindowsStreamingWriter (#27122) 2026-02-19 00:29:15 -08:00
robobun
1eef4368ea fix: increase robobun PR query limit from 200 to 1000 (#27126) 2026-02-19 00:20:50 -08:00
robobun
6e240de4e2 Add workflow to close stale robobun PRs older than 90 days (#27125) 2026-02-19 00:16:38 -08:00
SUZUKI Sosuke
e216be966e fix: avoid GC allocation inside ObjectInitializationScope (#27111)
## Summary
- Pre-convert strings to JSValues using `MarkedArgumentBuffer` before
entering `ObjectInitializationScope` in `JSC__JSObject__putRecord` and
`JSC__JSValue__putRecord`, since `jsString()` allocates GC cells which
is not allowed inside the scope
- Remove unused `ObjectInitializationScope` declaration in
`JSSQLStatement.cpp`'s `initializeColumnNames`

## Test plan
- [ ] Verify `bun bd test` passes for existing tests that exercise
`putRecord` paths (e.g., HTTP header handling, SQLite column names)
- [ ] Run with `BUN_JSC_validateExceptionChecks=1` to confirm no
exception scope violations

## Changelog
<!-- CHANGELOG:START -->
<!-- CHANGELOG:END -->

🤖 Generated with [Claude Code](https://claude.com/claude-code) (0%
9-shotted by claude-opus-4-6)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
2026-02-18 22:54:59 -08:00
Jarred Sumner
e84bee5d58 Fixes #26979 (#27118)
### What does this PR do?

Fixes https://github.com/oven-sh/bun/issues/26979


### How did you verify your code works?

The test in https://github.com/oven-sh/bun/issues/26979 successfully
reproduced the issue. Thank you!
2026-02-18 21:44:57 -08:00
SUZUKI Sosuke
fb2f304100 fix(node:fs): remove unnecessary path buffer pool alloc on Windows (#27115)
## Summary

- Removes an unnecessary 64KB `path_buffer_pool` allocation in
`PathLike.sliceZWithForceCopy` on Windows for paths that already have a
drive letter
- For drive-letter paths (e.g. `C:\foo\bar`),
`resolveCWDWithExternalBufZ` just does a memcpy, so the intermediate
buffer is unnecessary — we can pass the input slice directly to
`normalizeBuf`
- Eliminates an OOM crash path where `ObjectPool.get()` would panic via
`catch unreachable` when the allocator fails

## Test plan

- [ ] Verify Windows CI passes (this code path is Windows-only)
- [ ] Verify node:fs operations with absolute Windows paths still work
correctly
- [ ] Monitor BUN-Z4V crash reports after deployment to confirm fix

## Context

Speculative fix for BUN-Z4V (124 occurrences on Windows) showing `Panic:
attempt to unwrap error: OutOfMemory` in `sliceZWithForceCopy` →
`path_buffer_pool.get()` → `allocBytesWithAlignment`. We have not been
able to reproduce the crash locally, but the code analysis shows the
allocation is unnecessary for the drive-letter path case.

## Changelog
<!-- CHANGELOG:START -->
Fixed a crash on Windows (`OutOfMemory` panic) in `node:fs` path
handling when the system is under memory pressure.
<!-- CHANGELOG:END -->

🤖 Generated with [Claude Code](https://claude.com/claude-code) (0%
8-shotted by claude-opus-4-6)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:16:16 -08:00
36 changed files with 1680 additions and 710 deletions

View File

@@ -0,0 +1,30 @@
name: Close stale robobun PRs
on:
schedule:
- cron: "30 0 * * *"
workflow_dispatch:
jobs:
close-stale-robobun-prs:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
pull-requests: write
steps:
- name: Close stale robobun PRs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
ninety_days_ago=$(date -u -d '90 days ago' +%Y-%m-%dT%H:%M:%SZ)
gh pr list \
--author robobun \
--state open \
--json number,updatedAt \
--limit 1000 \
--jq ".[] | select(.updatedAt < \"$ninety_days_ago\") | .number" |
while read -r pr_number; do
echo "Closing PR #$pr_number (last updated before $ninety_days_ago)"
gh pr close "$pr_number" --comment "Closing this PR because it has been inactive for more than 90 days."
done

View File

@@ -1088,7 +1088,7 @@ pub const WindowsSpawnOptions = struct {
pub fn deinit(this: *const Stdio) void {
if (this.* == .buffer) {
bun.default_allocator.destroy(this.buffer);
this.buffer.closeAndDestroy();
}
}
};

View File

@@ -1,5 +1,6 @@
const WindowsNamedPipeContext = @This();
ref_count: RefCount,
named_pipe: uws.WindowsNamedPipe,
socket: SocketType,
@@ -10,6 +11,14 @@ task: jsc.AnyTask,
task_event: EventState = .none,
is_open: bool = false,
const RefCount = bun.ptr.RefCount(@This(), "ref_count", scheduleDeinit, .{});
pub const ref = RefCount.ref;
pub const deref = RefCount.deref;
fn scheduleDeinit(this: *WindowsNamedPipeContext) void {
this.deinitInNextTick();
}
pub const EventState = enum(u8) {
deinit,
none,
@@ -148,7 +157,7 @@ fn onClose(this: *WindowsNamedPipeContext) void {
.none => {},
}
this.deinitInNextTick();
this.deref();
}
fn runEvent(this: *WindowsNamedPipeContext) void {
@@ -169,6 +178,7 @@ fn deinitInNextTick(this: *WindowsNamedPipeContext) void {
pub fn create(globalThis: *jsc.JSGlobalObject, socket: SocketType) *WindowsNamedPipeContext {
const vm = globalThis.bunVM();
const this = WindowsNamedPipeContext.new(.{
.ref_count = .init(),
.vm = vm,
.globalThis = globalThis,
.task = undefined,
@@ -179,6 +189,8 @@ pub fn create(globalThis: *jsc.JSGlobalObject, socket: SocketType) *WindowsNamed
// named_pipe owns the pipe (PipeWriter owns the pipe and will close and deinit it)
this.named_pipe = uws.WindowsNamedPipe.from(bun.handleOom(bun.default_allocator.create(uv.Pipe)), .{
.ctx = this,
.ref_ctx = @ptrCast(&WindowsNamedPipeContext.ref),
.deref_ctx = @ptrCast(&WindowsNamedPipeContext.deref),
.onOpen = @ptrCast(&WindowsNamedPipeContext.onOpen),
.onData = @ptrCast(&WindowsNamedPipeContext.onData),
.onHandshake = @ptrCast(&WindowsNamedPipeContext.onHandshake),
@@ -218,7 +230,7 @@ pub fn open(globalThis: *jsc.JSGlobalObject, fd: bun.FileDescriptor, ssl_config:
},
.none => {},
}
this.deinitInNextTick();
this.deref();
}
try this.named_pipe.open(fd, ssl_config).unwrap();
return &this.named_pipe;
@@ -238,7 +250,7 @@ pub fn connect(globalThis: *jsc.JSGlobalObject, path: []const u8, ssl_config: ?j
},
.none => {},
}
this.deinitInNextTick();
this.deref();
}
if (path[path.len - 1] == 0) {

View File

@@ -1164,6 +1164,7 @@ void JSCommonJSModule::visitChildrenImpl(JSCell* cell, Visitor& visitor)
visitor.appendHidden(thisObject->m_dirname);
visitor.appendHidden(thisObject->m_paths);
visitor.appendHidden(thisObject->m_overriddenParent);
visitor.appendHidden(thisObject->m_overriddenCompile);
visitor.appendHidden(thisObject->m_childrenValue);
visitor.appendValues(thisObject->m_children.begin(), thisObject->m_children.size());
}

View File

@@ -703,6 +703,17 @@ void NodeVMSpecialSandbox::finishCreation(VM& vm)
const JSC::ClassInfo NodeVMSpecialSandbox::s_info = { "NodeVMSpecialSandbox"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(NodeVMSpecialSandbox) };
template<typename Visitor>
void NodeVMSpecialSandbox::visitChildrenImpl(JSCell* cell, Visitor& visitor)
{
auto* thisObject = jsCast<NodeVMSpecialSandbox*>(cell);
ASSERT_GC_OBJECT_INHERITS(thisObject, info());
Base::visitChildren(thisObject, visitor);
visitor.append(thisObject->m_parentGlobal);
}
DEFINE_VISIT_CHILDREN(NodeVMSpecialSandbox);
NodeVMGlobalObject::NodeVMGlobalObject(JSC::VM& vm, JSC::Structure* structure, NodeVMContextOptions contextOptions, JSValue importer)
: Base(vm, structure, &globalObjectMethodTable())
, m_dynamicImportCallback(vm, this, importer)

View File

@@ -85,6 +85,7 @@ public:
static NodeVMSpecialSandbox* create(VM& vm, Structure* structure, NodeVMGlobalObject* globalObject);
DECLARE_INFO;
DECLARE_VISIT_CHILDREN;
template<typename, JSC::SubspaceAccess mode> static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm);
static Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype);

View File

@@ -2450,13 +2450,20 @@ void JSC__JSObject__putRecord(JSC::JSObject* object, JSC::JSGlobalObject* global
descriptor.setValue(JSC::jsString(global->vm(), Zig::toStringCopy(values[0])));
} else {
// Pre-convert all strings to JSValues before entering ObjectInitializationScope,
// since jsString() allocates GC cells which is not allowed inside the scope.
MarkedArgumentBuffer strings;
for (size_t i = 0; i < valuesLen; ++i) {
strings.append(JSC::jsString(global->vm(), Zig::toStringCopy(values[i])));
}
JSC::JSArray* array = nullptr;
{
JSC::ObjectInitializationScope initializationScope(global->vm());
if ((array = JSC::JSArray::tryCreateUninitializedRestricted(initializationScope, nullptr, global->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), valuesLen))) {
for (size_t i = 0; i < valuesLen; ++i) {
array->initializeIndexWithoutBarrier(initializationScope, i, JSC::jsString(global->vm(), Zig::toStringCopy(values[i])));
array->initializeIndexWithoutBarrier(initializationScope, i, strings.at(i));
}
}
}
@@ -2490,6 +2497,13 @@ void JSC__JSValue__putRecord(JSC::EncodedJSValue objectValue, JSC::JSGlobalObjec
descriptor.setValue(JSC::jsString(global->vm(), Zig::toString(values[0])));
} else {
// Pre-convert all strings to JSValues before entering ObjectInitializationScope,
// since jsString() allocates GC cells which is not allowed inside the scope.
MarkedArgumentBuffer strings;
for (size_t i = 0; i < valuesLen; ++i) {
strings.append(JSC::jsString(global->vm(), Zig::toString(values[i])));
}
JSC::JSArray* array = nullptr;
{
JSC::ObjectInitializationScope initializationScope(global->vm());
@@ -2500,7 +2514,7 @@ void JSC__JSValue__putRecord(JSC::EncodedJSValue objectValue, JSC::JSGlobalObjec
for (size_t i = 0; i < valuesLen; ++i) {
array->initializeIndexWithoutBarrier(
initializationScope, i, JSC::jsString(global->vm(), Zig::toString(values[i])));
initializationScope, i, strings.at(i));
}
}
}

View File

@@ -759,8 +759,6 @@ static void initializeColumnNames(JSC::JSGlobalObject* lexicalGlobalObject, JSSQ
// Slow path:
JSC::ObjectInitializationScope initializationScope(vm);
// 64 is the maximum we can preallocate here
// see https://github.com/oven-sh/bun/issues/987
JSObject* prototype = castedThis->userPrototype ? castedThis->userPrototype.get() : lexicalGlobalObject->objectPrototype();
@@ -2022,7 +2020,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementSetPrototypeFunction, (JSGlobalObject * l
return {};
}
castedThis->userPrototype.set(vm, classObject, prototype.getObject());
castedThis->userPrototype.set(vm, castedThis, prototype.getObject());
// Force the prototypes to be re-created
if (castedThis->version_db) {

View File

@@ -929,7 +929,7 @@ pub const SendQueue = struct {
return err;
};
ipc_pipe.open(pipe_fd).unwrap() catch |err| {
bun.default_allocator.destroy(ipc_pipe);
ipc_pipe.closeAndDestroy();
return err;
};
ipc_pipe.unref();

View File

@@ -587,12 +587,12 @@ pub const PathLike = union(enum) {
if (std.fs.path.isAbsolute(sliced)) {
if (sliced.len > 2 and bun.path.isDriveLetter(sliced[0]) and sliced[1] == ':' and bun.path.isSepAny(sliced[2])) {
// Add the long path syntax. This affects most of node:fs
const drive_resolve_buf = bun.path_buffer_pool.get();
defer bun.path_buffer_pool.put(drive_resolve_buf);
const rest = path_handler.PosixToWinNormalizer.resolveCWDWithExternalBufZ(drive_resolve_buf, sliced) catch @panic("Error while resolving path.");
// Normalize the path directly into buf without an intermediate
// buffer. The input (sliced) already has a drive letter, so
// resolveCWDWithExternalBufZ would just memcpy it, making the
// temporary allocation unnecessary.
buf[0..4].* = bun.windows.long_path_prefix_u8;
// When long path syntax is used, the entire string should be normalized
const n = bun.path.normalizeBuf(rest, buf[4..], .windows).len;
const n = bun.path.normalizeBuf(sliced, buf[4..], .windows).len;
buf[4 + n] = 0;
return buf[0 .. 4 + n :0];
}

View File

@@ -75,6 +75,7 @@ pub const PendingValue = struct {
onStartBuffering: ?*const fn (ctx: *anyopaque) void = null,
onStartStreaming: ?*const fn (ctx: *anyopaque) jsc.WebCore.DrainResult = null,
onReadableStreamAvailable: ?*const fn (ctx: *anyopaque, globalThis: *jsc.JSGlobalObject, readable: jsc.WebCore.ReadableStream) void = null,
onStreamCancelled: ?*const fn (ctx: ?*anyopaque) void = null,
size_hint: Blob.SizeType = 0,
deinit: bool = false,
@@ -495,6 +496,13 @@ pub const Value = union(Tag) {
.globalThis = globalThis,
});
if (locked.onStreamCancelled) |onCancelled| {
if (locked.task) |task| {
reader.cancel_handler = onCancelled;
reader.cancel_ctx = task;
}
}
reader.context.setup();
if (drain_result == .estimated_size) {
@@ -815,16 +823,10 @@ pub const Value = union(Tag) {
}
pub fn tryUseAsAnyBlob(this: *Value) ?AnyBlob {
if (this.* == .WTFStringImpl) {
if (this.WTFStringImpl.canUseAsUTF8()) {
return AnyBlob{ .WTFStringImpl = this.WTFStringImpl };
}
}
const any_blob: AnyBlob = switch (this.*) {
.Blob => AnyBlob{ .Blob = this.Blob },
.InternalBlob => AnyBlob{ .InternalBlob = this.InternalBlob },
// .InlineBlob => AnyBlob{ .InlineBlob = this.InlineBlob },
.Blob => .{ .Blob = this.Blob },
.InternalBlob => .{ .InternalBlob = this.InternalBlob },
.WTFStringImpl => |str| if (str.canUseAsUTF8()) .{ .WTFStringImpl = str } else return null,
.Locked => this.Locked.toAnyBlobAllowPromise() orelse return null,
else => return null,
};

View File

@@ -442,6 +442,8 @@ pub fn NewSource(
close_handler: ?*const fn (?*anyopaque) void = null,
close_ctx: ?*anyopaque = null,
close_jsvalue: jsc.Strong.Optional = .empty,
cancel_handler: ?*const fn (?*anyopaque) void = null,
cancel_ctx: ?*anyopaque = null,
globalThis: *JSGlobalObject = undefined,
this_jsvalue: jsc.JSValue = .zero,
is_closed: bool = false,
@@ -493,6 +495,10 @@ pub fn NewSource(
this.cancelled = true;
onCancel(&this.context);
if (this.cancel_handler) |handler| {
this.cancel_handler = null;
handler(this.cancel_ctx);
}
}
pub fn onClose(this: *This) void {

View File

@@ -231,6 +231,7 @@ pub const FetchTasklet = struct {
response.unref();
}
this.clearStreamCancelHandler();
this.readable_stream_ref.deinit();
this.scheduled_response_buffer.deinit();
@@ -363,6 +364,7 @@ pub const FetchTasklet = struct {
bun.default_allocator,
);
} else {
this.clearStreamCancelHandler();
var prev = this.readable_stream_ref;
this.readable_stream_ref = .{};
defer prev.deinit();
@@ -865,6 +867,25 @@ pub const FetchTasklet = struct {
};
}
/// Clear the cancel_handler on the ByteStream.Source to prevent use-after-free.
/// Must be called before releasing readable_stream_ref, while the Strong ref
/// still keeps the ReadableStream (and thus the ByteStream.Source) alive.
fn clearStreamCancelHandler(this: *FetchTasklet) void {
if (this.readable_stream_ref.get(this.global_this)) |readable| {
if (readable.ptr == .Bytes) {
const source = readable.ptr.Bytes.parent();
source.cancel_handler = null;
source.cancel_ctx = null;
}
}
}
fn onStreamCancelledCallback(ctx: ?*anyopaque) void {
const this = bun.cast(*FetchTasklet, ctx.?);
if (this.ignore_data) return;
this.ignoreRemainingResponseBody();
}
fn toBodyValue(this: *FetchTasklet) Body.Value {
if (this.getAbortError()) |err| {
return .{ .Error = err };
@@ -877,6 +898,7 @@ pub const FetchTasklet = struct {
.global = this.global_this,
.onStartStreaming = FetchTasklet.onStartStreamingHTTPResponseBodyCallback,
.onReadableStreamAvailable = FetchTasklet.onReadableStreamAvailable,
.onStreamCancelled = FetchTasklet.onStreamCancelledCallback,
},
};
return response;
@@ -930,7 +952,8 @@ pub const FetchTasklet = struct {
// we should not keep the process alive if we are ignoring the body
const vm = this.javascript_vm;
this.poll_ref.unref(vm);
// clean any remaining refereces
// clean any remaining references
this.clearStreamCancelHandler();
this.readable_stream_ref.deinit();
this.response.deinit();

View File

@@ -154,10 +154,8 @@ fn prepareCssAstsForChunkImpl(c: *LinkerContext, chunk: *Chunk, allocator: std.m
filter: {
// Filter out "@charset", "@import", and leading "@layer" rules
// TODO: we are doing simple version rn, only @import
for (ast.rules.v.items, 0..) |*rule, ruleidx| {
// if ((rule.* == .import and import_records[source_index.get()].at(rule.import.import_record_idx).flags.is_internal) or rule.* == .ignored) {} else {
if (rule.* == .import or rule.* == .ignored) {} else {
if (rule.* == .import or rule.* == .ignored or rule.* == .layer_statement) {} else {
// It's okay to do this because AST is allocated into arena
const reslice = ast.rules.v.items[ruleidx..];
ast.rules.v = .{

View File

@@ -452,6 +452,14 @@ pub fn CssRuleList(comptime AtRule: type) type {
}
bun.handleOom(rules.append(context.allocator, rule.*));
moved_rule = true;
// Non-style rules (e.g. @property, @keyframes) act as a barrier for
// style rule deduplication. We cannot safely merge identical style rules
// across such boundaries because the intervening at-rule may affect how
// the declarations are interpreted (e.g. @property defines a custom
// property that a :root rule above may set differently than one below).
style_rules.clearRetainingCapacity();
}
// MISSING SHIT HERE

View File

@@ -1414,6 +1414,17 @@ pub const Pipe = extern struct {
pub fn asStream(this: *@This()) *uv_stream_t {
return @ptrCast(this);
}
/// Close the pipe handle and then free it in the close callback.
/// Use this when a pipe has been init'd but needs to be destroyed
/// (e.g. when open() fails after init() succeeded).
pub fn closeAndDestroy(this: *@This()) void {
this.close(&onCloseDestroy);
}
fn onCloseDestroy(handle: *@This()) callconv(.c) void {
bun.default_allocator.destroy(handle);
}
};
const union_unnamed_416 = extern union {
fd: c_int,

View File

@@ -51,6 +51,8 @@ pub const Flags = packed struct(u8) {
};
pub const Handlers = struct {
ctx: *anyopaque,
ref_ctx: *const fn (*anyopaque) void,
deref_ctx: *const fn (*anyopaque) void,
onOpen: *const fn (*anyopaque) void,
onHandshake: *const fn (*anyopaque, bool, uws.us_bun_verify_error_t) void,
onData: *const fn (*anyopaque, []const u8) void,
@@ -271,7 +273,16 @@ pub fn from(
.handlers = handlers,
};
}
pub fn ref(this: *WindowsNamedPipe) void {
this.handlers.ref_ctx(this.handlers.ctx);
}
pub fn deref(this: *WindowsNamedPipe) void {
this.handlers.deref_ctx(this.handlers.ctx);
}
fn onConnect(this: *WindowsNamedPipe, status: uv.ReturnCode) void {
defer this.deref();
if (this.pipe) |pipe| {
_ = pipe.unref();
}
@@ -376,6 +387,7 @@ pub fn open(this: *WindowsNamedPipe, fd: bun.FileDescriptor, ssl_options: ?jsc.A
return openResult;
}
this.ref();
onConnect(this, uv.ReturnCode.zero);
return .success;
}
@@ -410,7 +422,12 @@ pub fn connect(this: *WindowsNamedPipe, path: []const u8, ssl_options: ?jsc.API.
}
this.connect_req.data = this;
return this.pipe.?.connect(&this.connect_req, path, this, onConnect);
const result = this.pipe.?.connect(&this.connect_req, path, this, onConnect);
if (result.asErr() != null) {
return result;
}
this.ref();
return result;
}
pub fn startTLS(this: *WindowsNamedPipe, ssl_options: jsc.API.ServerConfig.SSLConfig, is_client: bool) !void {
this.flags.is_ssl = true;

View File

@@ -694,8 +694,8 @@ pub fn PosixStreamingWriter(comptime Parent: type, comptime function_table: anyt
}
pub fn deinit(this: *PosixWriter) void {
this.outgoing.deinit();
this.closeWithoutReporting();
this.outgoing.deinit();
}
pub fn hasRef(this: *PosixWriter) bool {
@@ -815,29 +815,39 @@ fn BaseWindowsPipeWriter(
pub fn close(this: *WindowsPipeWriter) void {
this.is_done = true;
if (this.source) |source| {
switch (source) {
.sync_file, .file => |file| {
// Use state machine to handle close after operation completes
if (this.owns_fd) {
file.detach();
} else {
// Don't own fd, just stop operations and detach parent
file.stop();
file.fs.data = null;
}
},
.pipe => |pipe| {
pipe.data = pipe;
pipe.close(onPipeClose);
},
.tty => |tty| {
tty.data = tty;
tty.close(onTTYClose);
},
}
this.source = null;
this.onCloseSource();
const source = this.source orelse return;
// Check for in-flight file write before detaching. detach()
// nulls fs.data so onFsWriteComplete can't recover the writer
// to call deref(). We must balance processSend's ref() here.
const has_inflight_write = if (@hasField(WindowsPipeWriter, "current_payload")) switch (source) {
.sync_file, .file => |file| file.state == .operating or file.state == .canceling,
else => false,
} else false;
switch (source) {
.sync_file, .file => |file| {
// Use state machine to handle close after operation completes
if (this.owns_fd) {
file.detach();
} else {
// Don't own fd, just stop operations and detach parent
file.stop();
file.fs.data = null;
}
},
.pipe => |pipe| {
pipe.data = pipe;
pipe.close(onPipeClose);
},
.tty => |tty| {
tty.data = tty;
tty.close(onTTYClose);
},
}
this.source = null;
this.onCloseSource();
// Deref last — this may free the parent and `this`.
if (has_inflight_write) {
this.parent.deref();
}
}
@@ -1298,6 +1308,10 @@ pub fn WindowsStreamingWriter(comptime Parent: type, function_table: anytype) ty
}
fn onWriteComplete(this: *WindowsWriter, status: uv.ReturnCode) void {
// Deref the parent at the end to balance the ref taken in
// processSend before submitting the async write request.
defer this.parent.deref();
if (status.toError(.write)) |err| {
this.last_write_result = .{ .err = err };
log("onWrite() = {s}", .{err.name()});
@@ -1347,7 +1361,8 @@ pub fn WindowsStreamingWriter(comptime Parent: type, function_table: anytype) ty
// ALWAYS complete first
file.complete(was_canceled);
// If detached, file may be closing (owned fd) or just stopped (non-owned fd)
// If detached, file may be closing (owned fd) or just stopped (non-owned fd).
// The deref to balance processSend's ref was already done in close().
if (parent_ptr == null) {
return;
}
@@ -1355,17 +1370,21 @@ pub fn WindowsStreamingWriter(comptime Parent: type, function_table: anytype) ty
const this = bun.cast(*WindowsWriter, parent_ptr);
if (was_canceled) {
// Canceled write - reset buffers
// Canceled write - reset buffers and deref to balance processSend ref
this.current_payload.reset();
this.parent.deref();
return;
}
if (result.toError(.write)) |err| {
// deref to balance processSend ref
defer this.parent.deref();
this.close();
onError(this.parent, err);
return;
}
// onWriteComplete handles the deref
this.onWriteComplete(.zero);
}
@@ -1428,6 +1447,10 @@ pub fn WindowsStreamingWriter(comptime Parent: type, function_table: anytype) ty
}
},
}
// Ref the parent to prevent it from being freed while the async
// write is in flight. The matching deref is in onWriteComplete
// or onFsWriteComplete.
this.parent.ref();
this.last_write_result = .{ .pending = 0 };
}
@@ -1442,10 +1465,11 @@ pub fn WindowsStreamingWriter(comptime Parent: type, function_table: anytype) ty
}
pub fn deinit(this: *WindowsWriter) void {
// clean both buffers if needed
// Close the pipe first to cancel any in-flight writes before
// freeing the buffers they reference.
this.closeWithoutReporting();
this.outgoing.deinit();
this.current_payload.deinit();
this.closeWithoutReporting();
}
fn writeInternal(this: *WindowsWriter, buffer: anytype, comptime writeFn: anytype) WriteResult {

View File

@@ -222,7 +222,7 @@ pub const Source = union(enum) {
switch (pipe.open(fd)) {
.err => |err| {
bun.default_allocator.destroy(pipe);
pipe.closeAndDestroy();
return .{
.err = err,
};

View File

@@ -499,6 +499,23 @@ class Database implements SqliteTypes.Database {
close(throwOnError = false) {
this.clearQueryCache();
// Finalize any prepared statements created by db.transaction()
if (controllers) {
const controller = controllers.get(this);
if (controller) {
controllers.delete(this);
const seen = new Set();
for (const ctrl of [controller.default, controller.deferred, controller.immediate, controller.exclusive]) {
if (!ctrl) continue;
for (const stmt of [ctrl.begin, ctrl.commit, ctrl.rollback, ctrl.savepoint, ctrl.release, ctrl.rollbackTo]) {
if (stmt && !seen.has(stmt)) {
seen.add(stmt);
stmt.finalize?.();
}
}
}
}
}
this.#hasClosed = true;
return SQL.close(this.#handle, throwOnError);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1708,13 +1708,19 @@ fn NewPrinter(
}
// Internal "require()" or "import()"
const has_side_effects = meta.wrapper_ref.isValid() or
meta.exports_ref.isValid() or
meta.was_unwrapped_require or
p.options.input_files_for_dev_server != null;
if (record.kind == .dynamic) {
p.printSpaceBeforeIdentifier();
p.print("Promise.resolve()");
level = p.printDotThenPrefix();
if (has_side_effects) {
level = p.printDotThenPrefix();
}
}
defer if (record.kind == .dynamic) p.printDotThenSuffix();
defer if (record.kind == .dynamic and has_side_effects) p.printDotThenSuffix();
// Make sure the comma operator is properly wrapped
const wrap_comma_operator = meta.exports_ref.isValid() and

View File

@@ -731,23 +731,22 @@ pub const Route = struct {
if (abs_path_str.len == 0) {
var file: std.fs.File = undefined;
var needs_close = false;
var needs_close = true;
defer if (needs_close) file.close();
if (entry.cache.fd.unwrapValid()) |valid| {
file = valid.stdFile();
needs_close = false;
} else {
var parts = [_]string{ entry.dir, entry.base() };
abs_path_str = FileSystem.instance.absBuf(&parts, &route_file_buf);
route_file_buf[abs_path_str.len] = 0;
const buf = route_file_buf[0..abs_path_str.len :0];
file = std.fs.openFileAbsoluteZ(buf, .{ .mode = .read_only }) catch |err| {
needs_close = false;
log.addErrorFmt(null, Logger.Loc.Empty, allocator, "{s} opening route: {s}", .{ @errorName(err), abs_path_str }) catch unreachable;
return null;
};
FileSystem.setMaxFd(file.handle);
needs_close = FileSystem.instance.fs.needToCloseFiles();
if (!needs_close) entry.cache.fd = .fromStdFile(file);
}
const _abs = bun.getFdPath(.fromStdFile(file), &route_file_buf) catch |err| {

View File

@@ -12,27 +12,79 @@ state: union(enum) {
pub fn start(this: *Echo) Yield {
var args = this.bltn().argsSlice();
const no_newline = args.len >= 1 and std.mem.eql(u8, bun.sliceTo(args[0], 0), "-n");
args = args[if (no_newline) 1 else 0..];
// Parse flags: echo accepts -n, -e, -E in any combination.
// Flag parsing stops at the first arg that doesn't start with '-'
// or contains an invalid flag character.
var no_newline = false;
var escape_sequences = false;
var flags_done = false;
var args_start: usize = 0;
for (args) |arg| {
if (flags_done) break;
const flag = std.mem.span(arg);
if (flag.len < 2 or flag[0] != '-') {
flags_done = true;
break;
}
// Validate all characters are valid echo flags
var valid = true;
for (flag[1..]) |c| {
switch (c) {
'n', 'e', 'E' => {},
else => {
valid = false;
break;
},
}
}
if (!valid) {
flags_done = true;
break;
}
// Apply flags (last -e/-E wins)
for (flag[1..]) |c| {
switch (c) {
'n' => no_newline = true,
'e' => escape_sequences = true,
'E' => escape_sequences = false,
else => unreachable,
}
}
args_start += 1;
}
args = args[args_start..];
const args_len = args.len;
var has_leading_newline: bool = false;
var stop_output = false;
// TODO: Should flush buffer after it gets to a certain size
for (args, 0..) |arg, i| {
if (stop_output) break;
const thearg = std.mem.span(arg);
if (i < args_len - 1) {
bun.handleOom(this.output.appendSlice(thearg));
bun.handleOom(this.output.append(' '));
const is_last = i == args_len - 1;
if (escape_sequences) {
stop_output = appendWithEscapes(&this.output, thearg);
} else {
if (thearg.len > 0 and thearg[thearg.len - 1] == '\n') {
has_leading_newline = true;
if (is_last) {
if (thearg.len > 0 and thearg[thearg.len - 1] == '\n') {
has_leading_newline = true;
}
bun.handleOom(this.output.appendSlice(bun.strings.trimSubsequentLeadingChars(thearg, '\n')));
} else {
bun.handleOom(this.output.appendSlice(thearg));
}
bun.handleOom(this.output.appendSlice(bun.strings.trimSubsequentLeadingChars(thearg, '\n')));
}
if (!stop_output and !is_last) {
bun.handleOom(this.output.append(' '));
}
}
if (!has_leading_newline and !no_newline) bun.handleOom(this.output.append('\n'));
if (!stop_output and !has_leading_newline and !no_newline) bun.handleOom(this.output.append('\n'));
if (this.bltn().stdout.needsIO()) |safeguard| {
this.state = .waiting;
@@ -43,6 +95,109 @@ pub fn start(this: *Echo) Yield {
return this.bltn().done(0);
}
/// Appends `input` to `output`, interpreting backslash escape sequences.
/// Returns true if a \c escape was encountered (meaning stop all output).
fn appendWithEscapes(output: *std.array_list.Managed(u8), input: []const u8) bool {
var i: usize = 0;
while (i < input.len) {
if (input[i] == '\\' and i + 1 < input.len) {
switch (input[i + 1]) {
'\\' => {
bun.handleOom(output.append('\\'));
i += 2;
},
'a' => {
bun.handleOom(output.append('\x07'));
i += 2;
},
'b' => {
bun.handleOom(output.append('\x08'));
i += 2;
},
'c' => {
// \c: produce no further output
return true;
},
'e', 'E' => {
bun.handleOom(output.append('\x1b'));
i += 2;
},
'f' => {
bun.handleOom(output.append('\x0c'));
i += 2;
},
'n' => {
bun.handleOom(output.append('\n'));
i += 2;
},
'r' => {
bun.handleOom(output.append('\r'));
i += 2;
},
't' => {
bun.handleOom(output.append('\t'));
i += 2;
},
'v' => {
bun.handleOom(output.append('\x0b'));
i += 2;
},
'0' => {
// \0nnn: octal value (up to 3 octal digits)
i += 2; // skip \0
var val: u8 = 0;
var digits: usize = 0;
while (digits < 3 and i < input.len and input[i] >= '0' and input[i] <= '7') {
val = val *% 8 +% (input[i] - '0');
i += 1;
digits += 1;
}
bun.handleOom(output.append(val));
},
'x' => {
// \xHH: hex value (up to 2 hex digits)
i += 2; // skip \x
var val: u8 = 0;
var digits: usize = 0;
while (digits < 2 and i < input.len) {
const hex_val = hexDigitValue(input[i]);
if (hex_val) |hv| {
val = val *% 16 +% hv;
i += 1;
digits += 1;
} else {
break;
}
}
if (digits > 0) {
bun.handleOom(output.append(val));
} else {
// No valid hex digits: output \x literally
bun.handleOom(output.appendSlice("\\x"));
}
},
else => {
// Unknown escape: output backslash and the character as-is
bun.handleOom(output.append('\\'));
bun.handleOom(output.append(input[i + 1]));
i += 2;
},
}
} else {
bun.handleOom(output.append(input[i]));
i += 1;
}
}
return false;
}
fn hexDigitValue(c: u8) ?u8 {
if (c >= '0' and c <= '9') return c - '0';
if (c >= 'a' and c <= 'f') return c - 'a' + 10;
if (c >= 'A' and c <= 'F') return c - 'A' + 10;
return null;
}
pub fn onIOWriterChunk(this: *Echo, _: usize, e: ?jsc.SystemError) Yield {
if (comptime bun.Environment.allow_assert) {
assert(this.state == .waiting or this.state == .waiting_write_err);

View File

@@ -3251,11 +3251,15 @@ pub fn NewLexer(comptime encoding: StringEncoding) type {
self.chars.current = .{ .char = cur_ascii_char };
return;
}
// Set the cursor to decode the codepoint at new_idx.
// Use width=0 so that nextCursor (which computes pos = width + i)
// starts reading from exactly new_idx.
self.chars.src.cursor = CodepointIterator.Cursor{
.i = @intCast(new_idx),
.c = cur_ascii_char,
.width = 1,
.c = 0,
.width = 0,
};
SrcUnicode.nextCursor(&self.chars.src.iter, &self.chars.src.cursor);
self.chars.src.next_cursor = self.chars.src.cursor;
SrcUnicode.nextCursor(&self.chars.src.iter, &self.chars.src.next_cursor);
if (prev_ascii_char) |pc| self.chars.prev = .{ .char = pc };
@@ -3602,13 +3606,13 @@ pub fn ShellCharIter(comptime encoding: StringEncoding) type {
return bytes[self.src.i..];
}
if (self.src.iter.i >= bytes.len) return "";
return bytes[self.src.iter.i..];
if (self.src.cursor.i >= bytes.len) return "";
return bytes[self.src.cursor.i..];
}
pub fn cursorPos(self: *@This()) usize {
if (comptime encoding == .ascii) return self.src.i;
return self.src.iter.i;
return self.src.cursor.i;
}
pub fn eat(self: *@This()) ?InputChar {

View File

@@ -61,11 +61,7 @@ describe("echo error handling", async () => {
});
describe("echo special cases", async () => {
TestBuilder.command`echo -n -n hello`
.exitCode(0)
.stdout("-n hello")
.stderr("")
.runAsTest("-n flag with -n as argument");
TestBuilder.command`echo -n -n hello`.exitCode(0).stdout("hello").stderr("").runAsTest("-n flag with -n as argument");
TestBuilder.command`echo -- -n hello`
.exitCode(0)

View File

@@ -0,0 +1,140 @@
import { heapStats } from "bun:jsc";
import { expect, test } from "bun:test";
// Test that ReadableStream objects from cancelled fetch responses are properly GC'd.
//
// When a streaming HTTP response body is cancelled mid-stream, FetchTasklet's
// readable_stream_ref (a Strong GC root) is not released because:
// 1. ByteStream.onCancel() doesn't notify the FetchTasklet
// 2. The HTTP connection stays open, so has_more never becomes false
// 3. Bun__FetchResponse_finalize sees the Strong ref and skips cleanup
//
// This creates a circular dependency where the Strong ref prevents GC,
// and the GC finalizer skips cleanup because the Strong ref exists.
test("ReadableStream from fetch should be GC'd after reader.cancel()", async () => {
// Use a raw TCP server to avoid server-side JS ReadableStream objects
// that would add noise to objectTypeCounts.
// The server sends one HTTP chunk immediately, then keeps the connection open.
using server = Bun.listen({
port: 0,
hostname: "127.0.0.1",
socket: {
data(socket) {
socket.write(
"HTTP/1.1 200 OK\r\n" +
"Transfer-Encoding: chunked\r\n" +
"Connection: keep-alive\r\n" +
"\r\n" +
"400\r\n" +
Buffer.alloc(0x400, "x").toString() +
"\r\n",
);
// Don't send terminal chunk "0\r\n\r\n" — keep connection open
},
open() {},
close() {},
error() {},
},
});
const url = `http://127.0.0.1:${server.port}/`;
const N = 30;
// Warmup: ensure JIT, lazy init, and connection pool are warmed up
for (let i = 0; i < 5; i++) {
const response = await fetch(url);
const reader = response.body!.getReader();
await reader.read();
await reader.cancel();
}
Bun.gc(true);
await Bun.sleep(10);
Bun.gc(true);
const baseline = heapStats().objectTypeCounts.ReadableStream ?? 0;
// Main test: fetch, read one chunk, cancel, repeat N times
for (let i = 0; i < N; i++) {
const response = await fetch(url);
const reader = response.body!.getReader();
await reader.read();
await reader.cancel();
}
// Allow finalizers to run, then GC aggressively
Bun.gc(true);
await Bun.sleep(50);
Bun.gc(true);
await Bun.sleep(50);
Bun.gc(true);
const after = heapStats().objectTypeCounts.ReadableStream ?? 0;
const leaked = after - baseline;
// With the bug: leaked ≈ N (each cancelled stream's Strong ref prevents GC)
// When fixed: leaked should be near 0 (Strong ref released on cancel)
expect(leaked).toBeLessThanOrEqual(5);
});
test("ReadableStream from fetch should be GC'd after body.cancel()", async () => {
using server = Bun.listen({
port: 0,
hostname: "127.0.0.1",
socket: {
data(socket) {
socket.write(
"HTTP/1.1 200 OK\r\n" +
"Transfer-Encoding: chunked\r\n" +
"Connection: keep-alive\r\n" +
"\r\n" +
"400\r\n" +
Buffer.alloc(0x400, "x").toString() +
"\r\n",
);
},
open() {},
close() {},
error() {},
},
});
const url = `http://127.0.0.1:${server.port}/`;
const N = 30;
// Warmup
for (let i = 0; i < 5; i++) {
const response = await fetch(url);
const reader = response.body!.getReader();
await reader.read();
reader.releaseLock();
await response.body!.cancel();
}
Bun.gc(true);
await Bun.sleep(10);
Bun.gc(true);
const baseline = heapStats().objectTypeCounts.ReadableStream ?? 0;
// Main test: fetch, read, releaseLock, cancel body directly
for (let i = 0; i < N; i++) {
const response = await fetch(url);
const reader = response.body!.getReader();
await reader.read();
reader.releaseLock();
await response.body!.cancel();
}
Bun.gc(true);
await Bun.sleep(50);
Bun.gc(true);
await Bun.sleep(50);
Bun.gc(true);
const after = heapStats().objectTypeCounts.ReadableStream ?? 0;
const leaked = after - baseline;
expect(leaked).toBeLessThanOrEqual(5);
});

View File

@@ -430,3 +430,5 @@ test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts
# Need to run the event loop once more to ensure sockets close
test/js/node/test/parallel/test-https-localaddress-bind-error.js
test/js/node/test/parallel/test-crypto-op-during-process-exit.js
test/js/third_party/prisma/prisma.test.ts

View File

@@ -0,0 +1,51 @@
import { Database } from "bun:sqlite";
import { expect, test } from "bun:test";
test("db.close(true) works after db.transaction()", () => {
const db = new Database(":memory:");
db.transaction(() => {})();
expect(() => db.close(true)).not.toThrow();
});
test("db.close(true) works after db.transaction() with actual work", () => {
const db = new Database(":memory:");
db.run("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)");
const insert = db.transaction((items: string[]) => {
const stmt = db.query("INSERT INTO test (value) VALUES (?)");
for (const item of items) {
stmt.run(item);
}
});
insert(["a", "b", "c"]);
expect(db.query("SELECT COUNT(*) as count FROM test").get()).toEqual({ count: 3 });
expect(() => db.close(true)).not.toThrow();
});
test("using declaration works with db.transaction()", () => {
using db = new Database(":memory:");
db.transaction(() => {})();
// Symbol.dispose calls close(true), should not throw
});
test("db.close(true) works after multiple transaction types", () => {
const db = new Database(":memory:");
db.transaction(() => {})();
db.transaction(() => {}).deferred();
db.transaction(() => {}).immediate();
db.transaction(() => {}).exclusive();
expect(() => db.close(true)).not.toThrow();
});
test("db.close(true) works after nested transactions", () => {
const db = new Database(":memory:");
db.run("CREATE TABLE test (id INTEGER PRIMARY KEY)");
const outer = db.transaction(() => {
db.run("INSERT INTO test (id) VALUES (1)");
const inner = db.transaction(() => {
db.run("INSERT INTO test (id) VALUES (2)");
});
inner();
});
outer();
expect(() => db.close(true)).not.toThrow();
});

View File

@@ -0,0 +1,43 @@
import { $ } from "bun";
import { expect, test } from "bun:test";
// https://github.com/oven-sh/bun/issues/17244
// Shell template literals leaked __bunstr_N when the first interpolated value
// contained a space and a subsequent value contained a multi-byte UTF-8 character.
test("shell interpolation with space and multi-byte UTF-8", async () => {
const a = " ";
const b = "Í";
const result = await $`echo ${a} ${b}`.text();
expect(result.trim()).toBe("Í");
expect(result).not.toContain("__bunstr");
});
test("shell interpolation with trailing-space string and 2-byte UTF-8", async () => {
const a = "a ";
const b = "Í";
const result = await $`echo ${a} ${b}`.text();
// "a " (with trailing space preserved) + " " (template separator) + "Í"
expect(result.trim()).toBe("a Í");
expect(result).not.toContain("__bunstr");
});
test("shell interpolation with space and 3-byte UTF-8", async () => {
const a = " ";
const b = "€";
const result = await $`echo ${a} ${b}`.text();
expect(result.trim()).toBe("€");
expect(result).not.toContain("__bunstr");
});
test("shell interpolation with embedded space and multi-byte UTF-8", async () => {
const a = "a b";
const b = "Í";
const result = await $`echo ${a} ${b}`.text();
expect(result.trim()).toBe("a b Í");
expect(result).not.toContain("__bunstr");
});

View File

@@ -0,0 +1,117 @@
import { $ } from "bun";
import { describe, expect, test } from "bun:test";
describe("echo -e flag support", () => {
test("echo -e does not output -e as literal text", async () => {
const result = await $`echo -e hello`.text();
expect(result).toBe("hello\n");
});
test("echo -e interprets backslash-n", async () => {
const result = await $`echo -e ${"hello\\nworld"}`.text();
expect(result).toBe("hello\nworld\n");
});
test("echo -e interprets backslash-t", async () => {
const result = await $`echo -e ${"hello\\tworld"}`.text();
expect(result).toBe("hello\tworld\n");
});
test("echo -e interprets backslash-backslash", async () => {
const result = await $`echo -e ${"hello\\\\world"}`.text();
expect(result).toBe("hello\\world\n");
});
test("echo -e interprets \\a (bell)", async () => {
const result = await $`echo -e ${"\\a"}`.text();
expect(result).toBe("\x07\n");
});
test("echo -e interprets \\b (backspace)", async () => {
const result = await $`echo -e ${"a\\bb"}`.text();
expect(result).toBe("a\bb\n");
});
test("echo -e interprets \\r (carriage return)", async () => {
const result = await $`echo -e ${"hello\\rworld"}`.text();
expect(result).toBe("hello\rworld\n");
});
test("echo -e interprets \\f (form feed)", async () => {
const result = await $`echo -e ${"\\f"}`.text();
expect(result).toBe("\f\n");
});
test("echo -e interprets \\v (vertical tab)", async () => {
const result = await $`echo -e ${"\\v"}`.text();
expect(result).toBe("\v\n");
});
test("echo -e interprets \\0nnn (octal)", async () => {
// \0101 = 'A' (65 decimal)
const result = await $`echo -e ${"\\0101"}`.text();
expect(result).toBe("A\n");
});
test("echo -e interprets \\xHH (hex)", async () => {
// \x41 = 'A'
const result = await $`echo -e ${"\\x41\\x42\\x43"}`.text();
expect(result).toBe("ABC\n");
});
test("echo -e \\c stops output", async () => {
const result = await $`echo -e ${"hello\\cworld"}`.text();
expect(result).toBe("hello");
});
test("echo -e with \\e (escape character)", async () => {
const result = await $`echo -e ${"\\e"}`.text();
expect(result).toBe("\x1b\n");
});
test("echo -E disables escape interpretation", async () => {
const result = await $`echo -E ${"hello\\nworld"}`.text();
expect(result).toBe("hello\\nworld\n");
});
test("echo -eE (last wins: -E disables)", async () => {
const result = await $`echo -eE ${"hello\\tworld"}`.text();
expect(result).toBe("hello\\tworld\n");
});
test("echo -Ee (last wins: -e enables)", async () => {
const result = await $`echo -Ee ${"hello\\tworld"}`.text();
expect(result).toBe("hello\tworld\n");
});
test("echo -ne (no newline + escapes)", async () => {
const result = await $`echo -ne ${"hello\\tworld"}`.text();
expect(result).toBe("hello\tworld");
});
test("echo -en (same as -ne)", async () => {
const result = await $`echo -en ${"hello\\tworld"}`.text();
expect(result).toBe("hello\tworld");
});
test("echo -n still works (no newline)", async () => {
const result = await $`echo -n hello`.text();
expect(result).toBe("hello");
});
test("echo with invalid flag outputs literally", async () => {
const result = await $`echo -x hello`.text();
expect(result).toBe("-x hello\n");
});
test("echo -e piped to cat (original issue scenario)", async () => {
const pw = "mypassword";
const result = await $`echo -e ${pw} | cat`.text();
expect(result).toBe("mypassword\n");
});
test("echo without -e still works normally", async () => {
const result = await $`echo hello world`.text();
expect(result).toBe("hello world\n");
});
});

View File

@@ -0,0 +1,62 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
test("Bun.build works multiple times after FileSystemRouter is created", async () => {
using dir = tempDir("issue-18242", {
"pages/index.ts": `console.log("Hello via Bun!");`,
"build.ts": `
import path from "path";
const PAGES_DIR = path.resolve(process.cwd(), "pages");
const srcRouter = new Bun.FileSystemRouter({
dir: PAGES_DIR,
style: "nextjs",
});
const entrypoints = Object.values(srcRouter.routes);
const result1 = await Bun.build({
entrypoints,
outdir: "dist/browser",
});
const result2 = await Bun.build({
entrypoints,
outdir: "dist/bun",
target: "bun",
});
const result3 = await Bun.build({
entrypoints,
outdir: "dist/third",
});
console.log(JSON.stringify({
build1: result1.success,
build2: result2.success,
build3: result3.success,
build2Logs: result2.logs.map(String),
build3Logs: result3.logs.map(String),
}));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const result = JSON.parse(stdout.trim());
expect(result.build1).toBe(true);
expect(result.build2).toBe(true);
expect(result.build3).toBe(true);
expect(result.build2Logs).toEqual([]);
expect(result.build3Logs).toEqual([]);
expect(exitCode).toBe(0);
});

View File

@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
describe("issue #20546 - CSS @layer declarations should be stripped from source files", () => {
test("separate @layer statements with @import layer()", async () => {
using dir = tempDir("css-layer-20546", {
"main.css": /* css */ `
@layer one;
@layer two;
@layer three;
@import url('./a.css') layer(one);
@import url('./b.css') layer(two);
@import url('./c.css') layer(three);
`,
"a.css": /* css */ `body { margin: 0; }`,
"b.css": /* css */ `h1 { font-family: sans-serif; }`,
"c.css": /* css */ `.text-centered { text-align: center; }`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "./main.css", "--outdir=out"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
const outCss = await Bun.file(`${dir}/out/main.css`).text();
// @layer declarations should appear at the top (hoisted or as part of the layer blocks)
// @import statements should NOT appear in the output (they've been inlined)
expect(outCss).not.toContain("@import");
// The bare @layer declarations should not be duplicated at the bottom
// They should either be hoisted to the top or removed entirely since
// the layer blocks establish the same ordering
const layerOneStatements = outCss.match(/@layer one;/g);
const layerTwoStatements = outCss.match(/@layer two;/g);
const layerThreeStatements = outCss.match(/@layer three;/g);
// Each @layer declaration should appear at most once (hoisted)
expect((layerOneStatements ?? []).length).toBeLessThanOrEqual(1);
expect((layerTwoStatements ?? []).length).toBeLessThanOrEqual(1);
expect((layerThreeStatements ?? []).length).toBeLessThanOrEqual(1);
// The actual layer block content should be present
expect(outCss).toContain("margin: 0");
expect(outCss).toContain("font-family: sans-serif");
expect(outCss).toContain("text-align: center");
expect(exitCode).toBe(0);
});
test("comma syntax @layer statement with @import layer()", async () => {
using dir = tempDir("css-layer-20546-comma", {
"main.css": /* css */ `
@layer one, two, three;
@import url('./a.css') layer(one);
@import url('./b.css') layer(two);
@import url('./c.css') layer(three);
`,
"a.css": /* css */ `body { margin: 0; }`,
"b.css": /* css */ `h1 { font-family: sans-serif; }`,
"c.css": /* css */ `.text-centered { text-align: center; }`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "./main.css", "--outdir=out"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
const outCss = await Bun.file(`${dir}/out/main.css`).text();
// @import statements should NOT appear in the output
expect(outCss).not.toContain("@import");
// The actual layer block content should be present
expect(outCss).toContain("margin: 0");
expect(outCss).toContain("font-family: sans-serif");
expect(outCss).toContain("text-align: center");
expect(exitCode).toBe(0);
});
});

View File

@@ -0,0 +1,49 @@
import { expect, test } from "bun:test";
import { tempDir } from "harness";
test("bun build produces valid JS for unused dynamic imports", async () => {
using dir = tempDir("issue-24709", {
"void-import.ts": `
export function main() {
void import("./dep.ts");
}
`,
"bare-import.ts": `
export function main() {
import("./dep.ts");
}
`,
"dep.ts": `export const x = 1;`,
});
const transpiler = new Bun.Transpiler();
// Test void import("...")
{
const result = await Bun.build({
entrypoints: [`${dir}/void-import.ts`],
});
expect(result.success).toBe(true);
const output = await result.outputs[0].text();
// The output must not contain an empty arrow function body like "() => )"
expect(output).not.toContain("() => )");
// Validate the output is syntactically valid JS by scanning it
expect(() => transpiler.scanImports(output)).not.toThrow();
}
// Test bare import("...")
{
const result = await Bun.build({
entrypoints: [`${dir}/bare-import.ts`],
});
expect(result.success).toBe(true);
const output = await result.outputs[0].text();
expect(output).not.toContain("() => )");
expect(() => transpiler.scanImports(output)).not.toThrow();
}
});

View File

@@ -0,0 +1,28 @@
import { test } from "bun:test";
test("issue #27099", async () => {
// Run it twice to trigger ASAN.
await run();
await run();
});
async function run() {
const fileOps = Array.from({ length: 10 }, () => Bun.file("/tmp/nope").exists());
const outer = Bun.spawn(["bash", "-c", 'for j in $(seq 1 100); do echo "padding padding padding"; done'], {
stdout: "pipe",
stderr: "pipe",
});
const outerText = new Response(outer.stdout as ReadableStream).text();
const inner = Bun.spawn(["cat"], {
stdin: new Response(Buffer.allocUnsafe(20000).fill("a").toString()),
stdout: "pipe",
});
await new Response(inner.stdout as ReadableStream).text();
await inner.exited;
await outerText;
await outer.exited;
await Promise.all(fileOps);
}

View File

@@ -0,0 +1,39 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
test("CSS bundler should not drop :root rule before @property", async () => {
using dir = tempDir("css-property-root-dedup", {
"input.css": `:root {
--bar: 1;
}
@property --foo {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
:root {
--baz: 2;
}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "input.css", "--outdir", "out"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const output = await Bun.file(`${dir}/out/input.css`).text();
// Both :root blocks must be preserved — they cannot be merged across the @property boundary
expect(output).toContain("--bar: 1");
expect(output).toContain("--baz: 2");
expect(output).toContain("@property --foo");
expect(exitCode).toBe(0);
});