[tracking] Zig/C++ fork: Rust→Zig port progress #31136

ClosedMaurerAnton wants to merge 8 commits into oven-sh:main from MaurerAnton:zig-fork

Pull Request Toolbar

Closed
[tracking] Zig/C++ fork: Rust→Zig port progress#31136
622 rendered rows
0 / 16 viewed
Rows: ...DOM nodes: ...Delegated listeners: 1No line selected
4

src/install/PackageManager/PackageJSONEditor.zig

@@ -459,7 +459,9 @@ pub fn edit(
459459 }
460460 break;
461461 } else {
462- if (request.version.tag == .github or request.version.tag == .git) {
462+ if (request.e_string == null and !request.is_aliased and
463+ (request.version.tag == .github or request.version.tag == .git or request.version.tag == .tarball or request.version.tag == .folder or request.version.tag == .symlink))
464+ {
463465 for (query.expr.data.e_object.properties.slice()) |item| {
464466 if (item.value) |v| {
465467 const url = request.version.literal.slice(request.version_buf);
4

src/js/node/events.ts

@@ -436,7 +436,9 @@ EventEmitterPrototype.eventNames = function eventNames() {
436436
437437 EventEmitterPrototype[kCapture] = false;
438438
439-function once(emitter, type, options = kEmptyObject) {
439+// `async` so validation/already-aborted `throw`s surface as rejected
440+// promise instead of synchronous throw — matches Node's `events.once`.
441+async function once(emitter, type, options = kEmptyObject) {
440442 validateObject(options, "options");
441443 var signal = options?.signal;
442444 validateAbortSignal(signal, "options.signal");
2

src/js_parser/ast/parseProperty.zig

@@ -318,7 +318,7 @@ pub fn ParseProperty(
318318 },
319319 }
320320 }
321- } else if (p.lexer.token == .t_open_brace and strings.eqlComptime(name, "static")) {
321+ } else if (opts.is_class and p.lexer.token == .t_open_brace and strings.eqlComptime(name, "static")) {
322322 const loc = p.lexer.loc();
323323 try p.lexer.next();
324324
2

src/js_parser/lexer.zig

@@ -1778,6 +1778,7 @@ fn NewLexer_(
17781778
17791779 lexer.end = lexer.current;
17801780 lexer.token = T.t_syntax_error;
1781+ lexer.step();
17811782 },
17821783 }
17831784
@@ -2318,6 +2319,7 @@ fn NewLexer_(
23182319
23192320 lexer.end = lexer.current;
23202321 lexer.token = .t_syntax_error;
2322+ lexer.step();
23212323 },
23222324 }
23232325
20

test/bundler/bundler_naming.test.ts

@@ -302,4 +302,24 @@ describe("bundler", () => {
302302 },
303303 ],
304304 });
305+ // A non-ASCII basename char must collapse to a single "_" in the generated
306+ // CommonJS wrapper symbol, not one "_" per UTF-8 byte. Regressed in the
307+ // Rust port; Zig's identifier formatter already decodes multi-byte codepoints.
308+ itBundled("naming/NonAsciiSourceFilenameSymbol", {
309+ files: {
310+ "/entry.js": /* js */ `
311+ const u = require("./café-utils.js");
312+ console.log(u.hi);
313+ `,
314+ "/café-utils.js": /* js */ `
315+ module.exports = { hi: 1 };
316+ `,
317+ },
318+ target: "bun",
319+ onAfterBundle(api) {
320+ api.expectFile("/out.js").toContain("require_caf_utils");
321+ api.expectFile("/out.js").not.toContain("require_caf__utils");
322+ },
323+ run: { stdout: "1" },
324+ });
305325 });
136

test/cli/install/bun-add.test.ts

@@ -2323,6 +2323,142 @@ it("should add local tarball dependency", async () => {
23232323 await access(join(package_dir, "bun.lockb")));
23242324 });
23252325
2326+it("should not add duplicate package.json entries when installing the same local folder twice (#30933)", async () => {
2327+ setHandler(dummyRegistry([]));
2328+ await writeFile(
2329+ join(add_dir, "package.json"),
2330+ JSON.stringify({
2331+ name: "myproject",
2332+ version: "1.0.0",
2333+ bin: { myproject: "./index.js" },
2334+ }),
2335+ );
2336+ await writeFile(join(add_dir, "index.js"), 'console.log("hi")');
2337+
2338+ await writeFile(
2339+ join(package_dir, "package.json"),
2340+ JSON.stringify({
2341+ name: "host",
2342+ version: "0.0.1",
2343+ }),
2344+ );
2345+
2346+ const local_path = resolve(add_dir);
2347+ const stored_path = local_path.replace(/\\/g, "/");
2348+
2349+ {
2350+ const { stdout, stderr, exited } = spawn({
2351+ cmd: [bunExe(), "add", local_path],
2352+ cwd: package_dir,
2353+ stdout: "pipe",
2354+ stdin: "pipe",
2355+ stderr: "pipe",
2356+ env,
2357+ });
2358+ const err = await stderr.text();
2359+ expect(err).not.toContain("error:");
2360+ expect(err).toContain("Saved lockfile");
2361+ const out = await stdout.text();
2362+ expect(out).toContain("installed myproject@");
2363+ expect(await exited).toBe(0);
2364+ }
2365+
2366+ {
2367+ const { stdout, stderr, exited } = spawn({
2368+ cmd: [bunExe(), "add", local_path],
2369+ cwd: package_dir,
2370+ stdout: "pipe",
2371+ stdin: "pipe",
2372+ stderr: "pipe",
2373+ env,
2374+ });
2375+ const err = await stderr.text();
2376+ expect(err).not.toContain("error:");
2377+ expect(err).toContain("Saved lockfile");
2378+ const out = await stdout.text();
2379+ expect(out).toContain("installed myproject@");
2380+ expect(await exited).toBe(0);
2381+ }
2382+
2383+ const raw = await file(join(package_dir, "package.json")).text();
2384+ expect(raw.match(/"myproject"\s*:/g) ?? []).toHaveLength(1);
2385+ expect(JSON.parse(raw)).toStrictEqual({
2386+ name: "host",
2387+ version: "0.0.1",
2388+ dependencies: {
2389+ myproject: stored_path,
2390+ },
2391+ });
2392+});
2393+
2394+it("should not add duplicate package.json entries when installing the same tarball URL twice (#30499)", async () => {
2395+ using server = Bun.serve({
2396+ port: 0,
2397+ fetch() {
2398+ return new Response(Bun.file(join(__dirname, "baz-0.0.3.tgz")));
2399+ },
2400+ });
2401+ const tarball_url = `${server.url.href.replace(/\/+$/, "")}/baz-0.0.3.tgz`;
2402+ setHandler(dummyRegistry([]));
2403+ await writeFile(
2404+ join(package_dir, "package.json"),
2405+ JSON.stringify({
2406+ name: "foo",
2407+ version: "0.0.1",
2408+ }),
2409+ );
2410+
2411+ {
2412+ const { stdout, stderr, exited } = spawn({
2413+ cmd: [bunExe(), "add", tarball_url],
2414+ cwd: package_dir,
2415+ stdout: "pipe",
2416+ stdin: "pipe",
2417+ stderr: "pipe",
2418+ env,
2419+ });
2420+ const err = await stderr.text();
2421+ expect(err).toContain("Saved lockfile");
2422+ const out = await stdout.text();
2423+ expect(out).toContain("installed baz@");
2424+ expect(await exited).toBe(0);
2425+ }
2426+ expect(await file(join(package_dir, "package.json")).json()).toStrictEqual({
2427+ name: "foo",
2428+ version: "0.0.1",
2429+ dependencies: {
2430+ baz: tarball_url,
2431+ },
2432+ });
2433+
2434+ {
2435+ const { stdout, stderr, exited } = spawn({
2436+ cmd: [bunExe(), "add", tarball_url],
2437+ cwd: package_dir,
2438+ stdout: "pipe",
2439+ stdin: "pipe",
2440+ stderr: "pipe",
2441+ env,
2442+ });
2443+ const err = await stderr.text();
2444+ expect(err).not.toContain("error:");
2445+ expect(err).toContain("Saved lockfile");
2446+ const out = await stdout.text();
2447+ expect(out).toContain("installed baz@");
2448+ expect(await exited).toBe(0);
2449+ }
2450+
2451+ const raw = await file(join(package_dir, "package.json")).text();
2452+ expect(raw.match(/"baz"\s*:/g) ?? []).toHaveLength(1);
2453+ expect(JSON.parse(raw)).toStrictEqual({
2454+ name: "foo",
2455+ version: "0.0.1",
2456+ dependencies: {
2457+ baz: tarball_url,
2458+ },
2459+ });
2460+});
2461+
23262462 it("should add multiple dependencies specified on command line", async () => {
23272463 expect(check_npm_auth_type.check).toBe(true);
23282464 using server = Bun.serve({
65

test/cli/install/bun-lockb.test.ts

@@ -1,7 +1,7 @@
11 import { file, spawn, write } from "bun";
22 import { afterAll, beforeAll, expect, it } from "bun:test";
3-import { copyFile, exists, open, writeFile } from "fs/promises";
4-import { bunExe, bunEnv as env, isWindows, runBunInstall, VerdaccioRegistry } from "harness";
3+import { copyFile, exists, open, rm, writeFile } from "fs/promises";
4+import { bunExe, bunEnv as env, isWindows, runBunInstall, stderrForInstall, VerdaccioRegistry } from "harness";
55 import { join } from "path";
66
77 const registry = new VerdaccioRegistry();
@@ -114,3 +114,64 @@ it("should continue using a binary lockfile if it exists", async () => {
114114 const thirdLockfile = await file(join(packageDir, "bun.lockb")).text();
115115 expect(thirdLockfile).not.toBe(secondLockfile);
116116 });
117+
118+it("recovers from a corrupted binary lockfile instead of panicking", async () => {
119+ const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: false } });
120+
121+ await write(
122+ packageJson,
123+ JSON.stringify({
124+ name: "corrupt-lockb",
125+ version: "1.0.0",
126+ dependencies: {
127+ "no-deps": "1.0.0",
128+ "a-dep": "1.0.1",
129+ },
130+ }),
131+ );
132+
133+ // Generate a valid bun.lockb against the local registry.
134+ await runBunInstall(env, packageDir);
135+ const lockbPath = join(packageDir, "bun.lockb");
136+ expect(await exists(lockbPath)).toBe(true);
137+
138+ // Corrupt `meta[1].id` to an out-of-range value. Packages are stored
139+ // SoA; the `meta` column sits after name (8), name_hash (8), resolution
140+ // (72 for format v3, 64 for v2), dependencies (8) and resolutions (8)
141+ // per package, and `id` is at +8 within each 88-byte Meta.
142+ const lockb = Buffer.from(await file(lockbPath).arrayBuffer());
143+ const fmt = lockb.readUInt32LE(42);
144+ const N = Number(lockb.readBigUInt64LE(86));
145+ const begin = Number(lockb.readBigUInt64LE(110));
146+ const resolutionSize = fmt === 2 ? 64 : 72;
147+ const metaStart = begin + N * (8 + 8 + resolutionSize + 8 + 8);
148+ expect(N).toBeGreaterThan(1);
149+ // Sanity: in a well-formed lockfile meta[i].id == i.
150+ expect(lockb.readUInt32LE(metaStart + 0 * 88 + 8)).toBe(0);
151+ expect(lockb.readUInt32LE(metaStart + 1 * 88 + 8)).toBe(1);
152+ lockb.writeUInt32LE(0x7fffffff, metaStart + 1 * 88 + 8);
153+ await write(lockbPath, lockb);
154+
155+ await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
156+
157+ const { stdout, stderr, exited } = spawn({
158+ cmd: [bunExe(), "install", "--no-progress"],
159+ cwd: packageDir,
160+ stdout: "pipe",
161+ stderr: "pipe",
162+ env,
163+ });
164+ const [out, rawErr, code] = await Promise.all([stdout.text(), stderr.text(), exited]);
165+ const err = stderrForInstall(rawErr);
166+
167+ // The garbage `meta.id` deserialized from the corrupt lockfile used to
168+ // panic_bounds_check in Package::clone. Released Bun tolerates it: it
169+ // re-resolves and completes the install. The fix matches that.
170+ expect(err).toContain("Ignoring lockfile");
171+ expect(err).not.toContain("error:");
172+ expect(out).toContain("[email protected]");
173+ expect(out).toContain("[email protected]");
174+ expect(code).toBe(0);
175+ expect(await exists(join(packageDir, "node_modules", "no-deps"))).toBe(true);
176+ expect(await exists(join(packageDir, "node_modules", "a-dep"))).toBe(true);
177+});
56

test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts

@@ -0,0 +1,56 @@
1+import { expect, test } from "bun:test";
2+import { bunEnv, bunExe, tempDir } from "harness";
3+
4+// When the runtime resolver's auto-install path lazily creates the
5+// PackageManager, it snapshots `resolver.log` into `pm.log`. If the
6+// PackageManager is created while the resolver log is pointed at a
7+// stack-local `Log` inside `resolveMaybeNeedsTrailingSlash`, `pm.log` must be
8+// restored to the VM log before returning, otherwise the next resolve at a
9+// different stack depth dereferences dead stack memory when the HTTP task
10+// fails and calls `manager.log.add_error_fmt(...)`.
11+//
12+// The Zig original already swaps and restores `linker.log` and
13+// `package_manager.log`; this test guards against regression.
14+test("repeated failing auto-install resolves at varying stack depth don't read a dangling pm.log", async () => {
15+ using dir = tempDir("resolve-autoinstall-log", {});
16+
17+ const src = `
18+ const realm = new ShadowRealm();
19+ const variants = [
20+ () => realm.importValue("pkg-not-found-a", "x"),
21+ () => (() => realm.importValue("pkg-not-found-b", "x"))(),
22+ () => (() => (() => realm.importValue("pkg-not-found-c", "x"))())(),
23+ () => { try { return realm.importValue("pkg-not-found-d", "x"); } finally {} },
24+ () => [realm.importValue("pkg-not-found-e", "x")][0],
25+ () => import("pkg-not-found-f"),
26+ () => (async () => realm.importValue("pkg-not-found-g", "x"))(),
27+ ];
28+ for (let i = 0; i < 100; i++) {
29+ for (const v of variants) {
30+ try {
31+ const p = v();
32+ if (p && p.catch) p.catch(() => {});
33+ } catch {}
34+ }
35+ }
36+ Bun.gc(true);
37+ console.log("ok");
38+ `;
39+
40+ await using proc = Bun.spawn({
41+ cmd: [bunExe(), "--install=fallback", "-e", src],
42+ cwd: String(dir),
43+ env: {
44+ ...bunEnv,
45+ BUN_CONFIG_REGISTRY: "http://127.0.0.1:1",
46+ },
47+ stdout: "pipe",
48+ stderr: "pipe",
49+ });
50+
51+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
52+
53+ expect(stderr).toBe("");
54+ expect(stdout.trim()).toBe("ok");
55+ expect(exitCode).toBe(0);
56+});
27

test/js/bun/shell/brace.test.ts

@@ -1,5 +1,6 @@
11 import { $ } from "bun";
22 import { describe, expect, test } from "bun:test";
3+import { tempDir } from "harness";
34
45 describe("$.braces", () => {
56 test("no-op", () => {
@@ -77,3 +78,29 @@ describe("$.braces", () => {
7778 expect(result).toEqual(["lol 😂", "lol 🫵", "lol 🤣"]);
7879 });
7980 });
81+
82+describe("brace + glob composition", () => {
83+ test("src/*.{ts,tsx} globs after brace expansion", async () => {
84+ using dir = tempDir("shell-brace-glob", {
85+ "src/app.ts": "",
86+ "src/util.tsx": "",
87+ });
88+ const out = (await $`echo src/*.{ts,tsx}`.cwd(String(dir)).text()).trim().replaceAll("\\", "/");
89+ const words = out.split(" ");
90+ expect(words).toContain("src/app.ts");
91+ expect(words).toContain("src/util.tsx");
92+ expect(words).toContain("src/*.ts");
93+ expect(words).toContain("src/*.tsx");
94+ });
95+
96+ test("{src,lib}/*.ts composes a brace prefix with a glob", async () => {
97+ using dir = tempDir("shell-brace-glob2", {
98+ "src/a.ts": "",
99+ "lib/b.ts": "",
100+ });
101+ const out = (await $`echo {src,lib}/*.ts`.cwd(String(dir)).text()).trim().replaceAll("\\", "/");
102+ const words = out.split(" ");
103+ expect(words).toContain("src/a.ts");
104+ expect(words).toContain("lib/b.ts");
105+ });
106+});
31

test/js/bun/spawn/spawn-kill-signal.test.ts

@@ -50,4 +50,35 @@ describe("subprocess.kill", () => {
5050 });
5151 }
5252 });
53+
54+ test("invalid signal name lists the valid signal names", async () => {
55+ const proc = Bun.spawn({
56+ cmd: [shellExe(), "-c", "sleep 1000"],
57+ stdio: ["inherit", "inherit", "inherit"],
58+ });
59+
60+ let err: any;
61+ try {
62+ proc.kill("SIGGOD");
63+ } catch (e) {
64+ err = e;
65+ }
66+
67+ expect(err).toBeInstanceOf(TypeError);
68+ expect(err.code).toBe("ERR_INVALID_ARG_TYPE");
69+ // The message must enumerate the real signal names, not a static
70+ // "the SignalCode names" placeholder (regressed in the Rust port;
71+ // Zig already builds the list at comptime).
72+ expect(err.message).toContain("'SIGHUP'");
73+ expect(err.message).toContain("'SIGTERM'");
74+ expect(err.message).toContain("'SIGKILL'");
75+ expect(err.message).toContain("or 'SIGSYS'");
76+ expect(err.message).not.toContain("the SignalCode names");
77+
78+ const { promise, resolve, reject } = Promise.withResolvers();
79+ proc.exited.then(resolve, reject);
80+ proc.kill();
81+ await promise;
82+ expect(proc.signalCode).toBe("SIGTERM");
83+ });
5384 });
10

test/js/bun/util/filesink.test.ts

@@ -268,3 +268,13 @@ it.skipIf(!isPosix)("does not leak native FileSink when a pending write fails (E
268268 // more than that indicates a native leak.
269269 expect(fileSinkInternals.liveCount()).toBeLessThanOrEqual(baseline + 1);
270270 });
271+
272+it("start() without path/fd on an already-open writer does not crash", async () => {
273+ const path = join(tmpdirSync(), "filesink-restart.txt");
274+ const writer = Bun.file(path).writer();
275+ expect(() => writer.start({})).not.toThrow();
276+ expect(() => writer.start({ highWaterMark: 1024 })).not.toThrow();
277+ writer.write("hello");
278+ await writer.end();
279+ expect(await Bun.file(path).text()).toBe("hello");
280+});
30

test/js/node/events/event-emitter.test.ts

@@ -65,6 +65,36 @@ describe("node:events", () => {
6565 await promise;
6666 expect(emitter.listenerCount("hey")).toBe(0);
6767 });
68+
69+ // `events.once()` is an `async function` in Node: a bad `options`, a bad
70+ // `options.signal`, or an already-aborted signal must produce a *rejected
71+ // promise*, never a synchronous throw.
72+ test("once is an async function", () => {
73+ expect(EventEmitter.once.constructor.name).toBe("AsyncFunction");
74+ });
75+
76+ test("once with already-aborted signal rejects (not a synchronous throw)", async () => {
77+ const ee = new EventEmitter();
78+ const p = EventEmitter.once(ee, "foo", { signal: AbortSignal.abort() });
79+ expect(p).toBeInstanceOf(Promise);
80+ await expect(p).rejects.toMatchObject({ name: "AbortError", code: "ABORT_ERR" });
81+ });
82+
83+ test("once with invalid options.signal rejects (not a synchronous throw)", async () => {
84+ for (const signal of [1, {}, "hi", null, false]) {
85+ const ee = new EventEmitter();
86+ const p = EventEmitter.once(ee, "foo", { signal } as any);
87+ expect(p).toBeInstanceOf(Promise);
88+ await expect(p).rejects.toMatchObject({ code: "ERR_INVALID_ARG_TYPE" });
89+ }
90+ });
91+
92+ test("once with non-object options rejects (not a synchronous throw)", async () => {
93+ const ee = new EventEmitter();
94+ const p = EventEmitter.once(ee, "foo", "hi" as any);
95+ expect(p).toBeInstanceOf(Promise);
96+ await expect(p).rejects.toMatchObject({ code: "ERR_INVALID_ARG_TYPE" });
97+ });
6898 });
6999
70100 describe("EventEmitter", () => {
31

test/js/node/fs/fs-readfile-fifo-fixture.js

@@ -0,0 +1,31 @@
1+// Spawned by fs.test.ts. Reads a FIFO whose content (>256 KB) far exceeds the
2+// 0-byte size `fstat` reports for a pipe. The "stat size is wrong" grow path in
3+// readFileSync reset buf.items.len = total before each reserve so growth is
4+// incremental. Prints a parseable line so the parent can assert.
5+const fs = require("fs");
6+const cp = require("child_process");
7+const path = require("path");
8+
9+const dir = process.argv[2];
10+const fifo = path.join(dir, "thefifo");
11+try {
12+ fs.unlinkSync(fifo);
13+} catch {}
14+cp.execFileSync("mkfifo", [fifo]);
15+if (!fs.statSync(fifo).isFIFO()) throw new Error(`not a FIFO: ${fifo}`);
16+
17+const SIZE = 400 * 1024;
18+cp.spawn(
19+ process.execPath,
20+ [
21+ "-e",
22+ `const fs=require("fs");const fd=fs.openSync(process.argv[1],"w");` +
23+ `const b=Buffer.alloc(${SIZE},0x61);let o=0;` +
24+ `while(o<b.length){o+=fs.writeSync(fd,b,o,Math.min(16384,b.length-o))}fs.closeSync(fd);`,
25+ fifo,
26+ ],
27+ { stdio: "inherit" },
28+);
29+
30+const data = fs.readFileSync(fifo);
31+process.stdout.write(`len=${data.length} allA=${data.every(x => x === 0x61)}`);
23

test/js/node/fs/fs.test.ts

@@ -3968,3 +3968,26 @@ describe("synchronous I/O string flags", () => {
39683968 expect(buf.toString("utf8", 0, bytesRead)).toBe("hello");
39693969 });
39703970 });
3971+
3972+// POSIX-only: readFileSync on a FIFO whose fstat size is 0 but actual
3973+// content >256 KB. Zig resets buf.items.len = total before each reserve,
3974+// so growth is incremental; the test guard is that it returns promptly
3975+// instead of ballooning RSS.
3976+if (isPosix) {
3977+ describe("readFileSync on a FIFO larger than the stat size", () => {
3978+ it("does not balloon the read buffer", async () => {
3979+ using dir = tempDir("fs-readfile-fifo", {});
3980+ const fixture = join(import.meta.path, "../fs-readfile-fifo-fixture.js");
3981+ await using proc = Bun.spawn({
3982+ cmd: [bunExe(), fixture, String(dir)],
3983+ env: bunEnv,
3984+ stdout: "pipe",
3985+ stderr: "pipe",
3986+ });
3987+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
3988+ expect(stderr).toBe("");
3989+ expect(stdout.trim()).toBe("len=409600 allA=true");
3990+ expect(exitCode).toBe(0);
3991+ });
3992+ });
3993+}
37

test/regression/issue/30963.test.ts

@@ -0,0 +1,37 @@
1+// https://github.com/oven-sh/bun/issues/30963
2+//
3+// Parsing `[{static{}` (a `static { ... }` block inside an object literal
4+// instead of a class body) tripped a debug_assert in the object-literal
5+// parser: the class-static-block branch in `parseProperty.zig` fired on
6+// identifier + `{` alone, without checking that the enclosing context was
7+// actually a class, and returned a property with neither key nor
8+// value, violating the assertion in the caller.
9+//
10+// Run the parse in a subprocess so a reintroduction (debug-build abort)
11+// doesn't tear down the in-process test runner.
12+
13+import { expect, test } from "bun:test";
14+import { bunEnv, bunExe } from "harness";
15+
16+test.concurrent.each(["[{static{}", "({static{}})", "({static{};})", "({static{},})"])(
17+ "`static { ... }` in an object-literal position (%s) is a syntax error, not a parser crash",
18+ async source => {
19+ await using proc = Bun.spawn({
20+ cmd: [bunExe(), "-e", source],
21+ env: bunEnv,
22+ stdin: "ignore",
23+ stdout: "pipe",
24+ stderr: "pipe",
25+ timeout: 10_000,
26+ });
27+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
28+
29+ // Before the fix, the debug build aborted inside the
30+ // parser. After the fix the parser emits the
31+ // normal user-facing syntax error.
32+ expect(stderr).toContain('Expected "}" but found "{"');
33+ expect(proc.signalCode).toBeNull();
34+ expect(stdout).toBe("");
35+ expect(exitCode).not.toBe(0);
36+ },
37+);
43

test/regression/issue/jsx-template-string-crash.test.ts

@@ -17,14 +17,49 @@ test("JSX lexer should not crash with slice bounds issues", async () => {
1717 expect(exitCode).toBe(1);
1818 expect(normalizeBunSnapshot(stderr.toString().replace(/(Bun v.*)$/gm, ""))).toMatchInlineSnapshot(`
1919 "1 | export function x(){return<div a=\`\`/>}
20- ^
21- error: Expected "{" but found "\`"
20+ ^
21+ error: Expecting Unicode escape sequence \\uXXXX
2222 at <cwd>/[eval]:1:34
2323
2424 1 | export function x(){return<div a=\`\`/>}
2525 ^
26- error: Unexpected >
27- at <cwd>/[eval]:1:37"
26+ error: Expected "{" but found "\`"
27+ at <cwd>/[eval]:1:34
28+
29+ 1 | export function x(){return<div a=\`\`/>}
30+ ^
31+ error: Unterminated string literal
32+ at <cwd>/[eval]:1:35"
2833 `);
2934 expect(normalizeBunSnapshot(stdout.toString())).toMatchInlineSnapshot(`""`);
3035 });
36+
37+test.concurrent("#30959 JSX attribute with invalid '(' value parses cleanly in debug builds", async () => {
38+ // Previously, parsing `<r L=((` in a debug build panicked with
39+ // `Scope location must be greater than previous 6 must be greater than 6`
40+ // in push_scope_for_parse_pass. `nextInsideJSXElement`'s syntax-error
41+ // branch set `end = current` on the first `(` without stepping past it,
42+ // causing re-dispatch on the same byte and duplicate scope offsets.
43+ await using proc = Bun.spawn({
44+ cmd: [bunExe(), "-e", "export function x(){return<r L=((}"],
45+ env: bunEnv,
46+ stderr: "pipe",
47+ stdout: "pipe",
48+ });
49+
50+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
51+
52+ expect(normalizeBunSnapshot(stderr.replace(/(Bun v.*)$/gm, ""))).toMatchInlineSnapshot(`
53+ "1 | export function x(){return<r L=((}
54+ ^
55+ error: Expected "{" but found "("
56+ at <cwd>/[eval]:1:32
57+
58+ 1 | export function x(){return<r L=((}
59+ ^
60+ error: Unexpected }
61+ at <cwd>/[eval]:1:34"
62+ `);
63+ expect(stdout).toBe("");
64+ expect(exitCode).toBe(1);
65+});