initial commit
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe, tempDir } from "harness";
|
||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// https://github.com/oven-sh/bun/issues/30887
|
||||
//
|
||||
// Under `--parallel` / `--isolate`, `bun test` reads transpiled preloads from
|
||||
// the isolation source provider cache. That path dropped the `has_tla` flag
|
||||
// on the cached module record, so JSC treated TLA preloads as having none
|
||||
// and let their evaluation promise resolve before the top-level-await
|
||||
// actually completed. The preload's side effects (like `Bun.env.X = ...`)
|
||||
// then happened *after* the test file started running.
|
||||
//
|
||||
// These tests assert that the preload's top-level await is awaited to
|
||||
// completion before the test file runs, across all three modes, and that
|
||||
// the on-disk runtime transpiler cache survives a round-trip with TLA
|
||||
// intact.
|
||||
|
||||
async function runTest(cwd: string, extraArgs: string[], extraEnv?: Record<string, string>) {
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "test", ...extraArgs],
|
||||
env: { ...bunEnv, ...extraEnv },
|
||||
cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
|
||||
return { stdout, stderr, exitCode };
|
||||
}
|
||||
|
||||
test.concurrent.each([
|
||||
["sequential (default)", []],
|
||||
["--isolate", ["--isolate"]],
|
||||
["--parallel", ["--parallel"]],
|
||||
])("async preload is awaited before tests run (%s)", async (_name, flags) => {
|
||||
using dir = tempDir("bun-test-30887-", {
|
||||
"preload.ts": `
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
Bun.env.MY_ENV = "MUST_NOT_BE_UNDEFINED";
|
||||
`,
|
||||
"my.test.ts": `
|
||||
import { test, expect } from "bun:test";
|
||||
test("MY_ENV must not be undefined", () => {
|
||||
expect(Bun.env.MY_ENV).toBe("MUST_NOT_BE_UNDEFINED");
|
||||
});
|
||||
`,
|
||||
"bunfig.toml": `
|
||||
[test]
|
||||
preload = ["./preload.ts"]
|
||||
`,
|
||||
});
|
||||
|
||||
const { stderr, exitCode } = await runTest(String(dir), flags);
|
||||
|
||||
expect(stderr).toContain("1 pass");
|
||||
expect(stderr).toContain("0 fail");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
// Runtime transpiler cache round-trip: a ≥4 KiB preload with top-level-await
|
||||
// is cached on the first run, then read back from the on-disk cache on the
|
||||
// second run. The cache-HIT path deserializes `module_info` straight from the
|
||||
// stored `esm_record` without touching the AST, so if the serializer or the
|
||||
// cache version doesn't preserve the `has_tla` flag, the second run will
|
||||
// fail exactly like the original #30887 repro.
|
||||
test("async preload survives runtime transpiler cache round-trip (--isolate)", async () => {
|
||||
// Pad the preload past MINIMUM_CACHE_SIZE (4 KiB). Declared variables are
|
||||
// enough — we just need byte-length, not any particular JS behavior.
|
||||
const padding = "\n" + Array.from({ length: 400 }, (_, i) => `const pad_${i} = ${i};`).join("\n");
|
||||
|
||||
using dir = tempDir("bun-test-30887-cache-", {
|
||||
"preload.ts": `
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
Bun.env.MY_ENV = "MUST_NOT_BE_UNDEFINED";
|
||||
${padding}
|
||||
`,
|
||||
"my.test.ts": `
|
||||
import { test, expect } from "bun:test";
|
||||
test("MY_ENV must not be undefined", () => {
|
||||
expect(Bun.env.MY_ENV).toBe("MUST_NOT_BE_UNDEFINED");
|
||||
});
|
||||
`,
|
||||
"bunfig.toml": `
|
||||
[test]
|
||||
preload = ["./preload.ts"]
|
||||
`,
|
||||
});
|
||||
|
||||
const cachePath = join(String(dir), ".bun-cache");
|
||||
// `BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE=1` is required on debug
|
||||
// builds so `RuntimeTranspilerCache::get()` actually returns the loaded
|
||||
// entry instead of discarding it; without it the cache-HIT branch in
|
||||
// `RuntimeTranspilerStore` is never taken (precedent:
|
||||
// `test/cli/run/transpiler-cache.test.ts`).
|
||||
const extraEnv = {
|
||||
BUN_RUNTIME_TRANSPILER_CACHE_PATH: cachePath,
|
||||
BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE: "1",
|
||||
};
|
||||
|
||||
// First run populates the cache.
|
||||
const first = await runTest(String(dir), ["--isolate"], extraEnv);
|
||||
expect(first.stderr).toContain("1 pass");
|
||||
expect(first.stderr).toContain("0 fail");
|
||||
expect(first.exitCode).toBe(0);
|
||||
|
||||
// Second run reads from the cache. The cache-HIT branch builds
|
||||
// `module_info` via `create_from_cached_record`, never touching the AST —
|
||||
// so `has_tla` here comes entirely from the serialized bytes.
|
||||
const second = await runTest(String(dir), ["--isolate"], extraEnv);
|
||||
expect(second.stderr).toContain("1 pass");
|
||||
expect(second.stderr).toContain("0 fail");
|
||||
expect(second.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
// On-disk cache entries written before #30888 have `has_tla=false` baked
|
||||
// into their serialized ESM record; without bumping `EXPECTED_VERSION`, the
|
||||
// post-fix binary would happily read them back and reinstate the original
|
||||
// bug. Simulate the stale-entry scenario by populating the cache, rewriting
|
||||
// the 4-byte LE version header to the prior version (20), and asserting the
|
||||
// next run overwrites it with the current version rather than silently
|
||||
// accepting the stale entry.
|
||||
test("stale transpiler cache entries are rejected (version bump)", async () => {
|
||||
const padding = "\n" + Array.from({ length: 400 }, (_, i) => `const pad_${i} = ${i};`).join("\n");
|
||||
|
||||
using dir = tempDir("bun-test-30887-stale-", {
|
||||
"preload.ts": `
|
||||
await Promise.resolve();
|
||||
Bun.env.MY_ENV = "MUST_NOT_BE_UNDEFINED";
|
||||
${padding}
|
||||
`,
|
||||
"my.test.ts": `
|
||||
import { test, expect } from "bun:test";
|
||||
test("MY_ENV must not be undefined", () => {
|
||||
expect(Bun.env.MY_ENV).toBe("MUST_NOT_BE_UNDEFINED");
|
||||
});
|
||||
`,
|
||||
"bunfig.toml": `
|
||||
[test]
|
||||
preload = ["./preload.ts"]
|
||||
`,
|
||||
});
|
||||
|
||||
const cachePath = join(String(dir), ".bun-cache");
|
||||
const extraEnv = {
|
||||
BUN_RUNTIME_TRANSPILER_CACHE_PATH: cachePath,
|
||||
BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE: "1",
|
||||
};
|
||||
|
||||
// First run populates the cache (so the filename and path layout exist).
|
||||
const first = await runTest(String(dir), ["--isolate"], extraEnv);
|
||||
expect(first.stderr).toContain("1 pass");
|
||||
expect(first.exitCode).toBe(0);
|
||||
|
||||
// Locate the written `.pile` file(s) (covers `.debug.pile` on debug builds
|
||||
// and `.pile` on release builds). Read the current cache version from the
|
||||
// first file, then downgrade every entry's 4-byte LE version header to
|
||||
// `current - 1`. A binary that reverted the #30888 bump (back to 20)
|
||||
// would accept a `has_tla=false` entry verbatim; the fix requires the
|
||||
// decode to fail and the file to be re-transpiled and rewritten at the
|
||||
// current version. Reading the current version from the file (rather
|
||||
// than hardcoding 21) means this test doesn't need editing on every
|
||||
// future `EXPECTED_VERSION` bump — it still fails iff the version is
|
||||
// ever reverted to ≤ 20.
|
||||
const entries = readdirSync(cachePath).filter(n => n.endsWith(".pile"));
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
const firstBytes = readFileSync(join(cachePath, entries[0]));
|
||||
expect(firstBytes.length).toBeGreaterThan(4);
|
||||
const currentVersion = firstBytes.readUInt32LE(0);
|
||||
// Guard against an accidental revert of the #30888 bump (20 → 21).
|
||||
expect(currentVersion).toBeGreaterThan(20);
|
||||
for (const name of entries) {
|
||||
const p = join(cachePath, name);
|
||||
const bytes = readFileSync(p);
|
||||
expect(bytes.readUInt32LE(0)).toBe(currentVersion);
|
||||
bytes.writeUInt32LE(currentVersion - 1, 0);
|
||||
writeFileSync(p, bytes);
|
||||
}
|
||||
|
||||
// Second run must reject the stale entry, re-transpile, and overwrite.
|
||||
const second = await runTest(String(dir), ["--isolate"], extraEnv);
|
||||
expect(second.stderr).toContain("1 pass");
|
||||
expect(second.stderr).toContain("0 fail");
|
||||
expect(second.exitCode).toBe(0);
|
||||
|
||||
// The cache file must now carry the current version byte, proving the
|
||||
// decode path rejected the stale header and forced a rewrite.
|
||||
for (const name of entries) {
|
||||
const bytes = readFileSync(join(cachePath, name));
|
||||
expect(bytes.readUInt32LE(0)).toBe(currentVersion);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user