diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index c6e0193955dc..b7ca88a318e8 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "fb1167ebf2cb9edc1f6771a2c11771b024693ae0"; +export const WEBKIT_VERSION = "autobuild-preview-pr-758-47ef27fd"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/src/jsc/bindings/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index aa2ecaefb495..0a7fb77bac7c 100644 --- a/src/jsc/bindings/ModuleLoader.cpp +++ b/src/jsc/bindings/ModuleLoader.cpp @@ -432,15 +432,7 @@ static JSValue handleVirtualModuleResult( } } - JSC::ensureStillAliveHere(object); - auto function = generateObjectModuleSourceCode( - globalObject, - object); - auto source = JSC::SourceCode( - JSC::SyntheticSourceProvider::create(WTF::move(function), - JSC::SourceOrigin(), specifier->toWTFString(BunString::ZeroCopy))); - JSC::ensureStillAliveHere(object); - RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(globalObject->vm(), WTF::move(source)))); + RELEASE_AND_RETURN(scope, rejectOrResolve(createObjectModuleSourceCode(vm, object, specifier->toWTFString(BunString::ZeroCopy)))); } case OnLoadResultTypePromise: { @@ -1077,12 +1069,7 @@ static JSValue fetchESMSourceCode( if (!value) { RELEASE_AND_RETURN(scope, reject(JSC::createSyntaxError(globalObject, "Failed to parse Object"_s))); } - auto function = generateJSValueExportDefaultObjectSourceCode(globalObject, value); - auto source = JSC::SourceCode( - JSC::SyntheticSourceProvider::create(WTF::move(function), - JSC::SourceOrigin(), WTF::move(moduleKey))); - JSC::ensureStillAliveHere(value); - RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(vm, WTF::move(source)))); + RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueExportDefaultObjectSourceCode(vm, value, WTF::move(moduleKey)))); } // CommonJS modules from src/js/* @@ -1191,14 +1178,7 @@ static JSValue fetchESMSourceCode( } // JSON can become strings, null, numbers, booleans so we must handle "export default 123" - auto function = generateJSValueModuleSourceCode( - globalObject, - value); - auto source = JSC::SourceCode( - JSC::SyntheticSourceProvider::create(WTF::move(function), - JSC::SourceOrigin(), specifier->toWTFString(BunString::ZeroCopy))); - JSC::ensureStillAliveHere(value); - RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(globalObject->vm(), WTF::move(source)))); + RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueModuleSourceCode(vm, value, specifier->toWTFString(BunString::ZeroCopy)))); } // TOML and JSONC may go through here else if (res->result.value.tag == SyntheticModuleType::ExportsObject) { @@ -1208,14 +1188,7 @@ static JSValue fetchESMSourceCode( } // JSON can become strings, null, numbers, booleans so we must handle "export default 123" - auto function = generateJSValueModuleSourceCode( - globalObject, - value); - auto source = JSC::SourceCode( - JSC::SyntheticSourceProvider::create(WTF::move(function), - JSC::SourceOrigin(), specifier->toWTFString(BunString::ZeroCopy))); - JSC::ensureStillAliveHere(value); - RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(globalObject->vm(), WTF::move(source)))); + RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueModuleSourceCode(vm, value, specifier->toWTFString(BunString::ZeroCopy)))); } else if (res->result.value.tag == SyntheticModuleType::ExportDefaultObject) { JSC::JSValue value = JSC::JSValue::decode(res->result.value.jsvalue_for_export); if (!value) { @@ -1223,14 +1196,7 @@ static JSValue fetchESMSourceCode( } // JSON can become strings, null, numbers, booleans so we must handle "export default 123" - auto function = generateJSValueExportDefaultObjectSourceCode( - globalObject, - value); - auto source = JSC::SourceCode( - JSC::SyntheticSourceProvider::create(WTF::move(function), - JSC::SourceOrigin(), specifier->toWTFString(BunString::ZeroCopy))); - JSC::ensureStillAliveHere(value); - RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(globalObject->vm(), WTF::move(source)))); + RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueExportDefaultObjectSourceCode(vm, value, specifier->toWTFString(BunString::ZeroCopy)))); } auto provider = Zig::SourceProvider::create(globalObject, res->result.value); diff --git a/src/jsc/modules/ObjectModule.cpp b/src/jsc/modules/ObjectModule.cpp index 4311d5bcbf56..3ad5f0ece49f 100644 --- a/src/jsc/modules/ObjectModule.cpp +++ b/src/jsc/modules/ObjectModule.cpp @@ -1,104 +1,95 @@ #include "ObjectModule.h" namespace Zig { -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateObjectModuleSourceCode(JSC::JSGlobalObject* globalObject, - JSC::JSObject* object) + +static void generateObjectModuleSourceCode(JSC::JSGlobalObject* lexicalGlobalObject, + JSC::Identifier, + JSC::JSValue payload, + Vector& exportNames, + JSC::MarkedArgumentBuffer& exportValues) { - gcProtectNullTolerant(object); - return [object](JSC::JSGlobalObject* lexicalGlobalObject, - JSC::Identifier moduleKey, - Vector& exportNames, - JSC::MarkedArgumentBuffer& exportValues) -> void { - auto& vm = JSC::getVM(lexicalGlobalObject); - auto throwScope = DECLARE_THROW_SCOPE(vm); - GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); - JSC::EnsureStillAliveScope stillAlive(object); - - PropertyNameArrayBuilder properties(vm, PropertyNameMode::Strings, - PrivateSymbolMode::Exclude); - object->methodTable()->getOwnPropertyNames(object, globalObject, properties, DontEnumPropertiesMode::Exclude); - RETURN_IF_EXCEPTION(throwScope, void()); - gcUnprotectNullTolerant(object); + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + JSC::JSObject* object = payload.getObject(); + JSC::EnsureStillAliveScope stillAlive(object); - for (auto& entry : properties.releaseData()->propertyNameVector()) { - JSValue value = object->get(globalObject, entry); - RETURN_IF_EXCEPTION(throwScope, void()); - exportNames.append(entry); - exportValues.append(value); - } - }; + PropertyNameArrayBuilder properties(vm, PropertyNameMode::Strings, + PrivateSymbolMode::Exclude); + object->methodTable()->getOwnPropertyNames(object, globalObject, properties, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(throwScope, void()); + + for (auto& entry : properties.releaseData()->propertyNameVector()) { + JSValue value = object->get(globalObject, entry); + RETURN_IF_EXCEPTION(throwScope, void()); + exportNames.append(entry); + exportValues.append(value); + } } -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateObjectModuleSourceCodeForJSON(JSC::JSGlobalObject* globalObject, - JSC::JSObject* object) +static void generateObjectModuleSourceCodeForJSON(JSC::JSGlobalObject* lexicalGlobalObject, + JSC::Identifier, + JSC::JSValue payload, + Vector& exportNames, + JSC::MarkedArgumentBuffer& exportValues) { - gcProtectNullTolerant(object); - return [object](JSC::JSGlobalObject* lexicalGlobalObject, - JSC::Identifier moduleKey, - Vector& exportNames, - JSC::MarkedArgumentBuffer& exportValues) -> void { - auto& vm = JSC::getVM(lexicalGlobalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - GlobalObject* globalObject = reinterpret_cast(lexicalGlobalObject); - JSC::EnsureStillAliveScope stillAlive(object); - - PropertyNameArrayBuilder properties(vm, PropertyNameMode::Strings, - PrivateSymbolMode::Exclude); - object->getPropertyNames(globalObject, properties, DontEnumPropertiesMode::Exclude); - RETURN_IF_EXCEPTION(scope, void()); - gcUnprotectNullTolerant(object); - - exportNames.append(vm.propertyNames->defaultKeyword); - exportValues.append(object); + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + GlobalObject* globalObject = reinterpret_cast(lexicalGlobalObject); + JSC::JSObject* object = payload.getObject(); + JSC::EnsureStillAliveScope stillAlive(object); - for (auto& entry : properties.releaseData()->propertyNameVector()) { - if (entry == vm.propertyNames->defaultKeyword) { - continue; - } + PropertyNameArrayBuilder properties(vm, PropertyNameMode::Strings, + PrivateSymbolMode::Exclude); + object->getPropertyNames(globalObject, properties, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, void()); - exportNames.append(entry); + exportNames.append(vm.propertyNames->defaultKeyword); + exportValues.append(object); - JSValue value = object->get(globalObject, entry); - RETURN_IF_EXCEPTION(scope, void()); - exportValues.append(value); + for (auto& entry : properties.releaseData()->propertyNameVector()) { + if (entry == vm.propertyNames->defaultKeyword) { + continue; } - }; -} -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateJSValueModuleSourceCode(JSC::JSGlobalObject* globalObject, - JSC::JSValue value) -{ + exportNames.append(entry); - if (value.isObject() && !JSC::isJSArray(value)) { - return generateObjectModuleSourceCodeForJSON(globalObject, - value.getObject()); + JSValue value = object->get(globalObject, entry); + RETURN_IF_EXCEPTION(scope, void()); + exportValues.append(value); } +} - return generateJSValueExportDefaultObjectSourceCode(globalObject, value); +static void generateJSValueExportDefaultObjectSourceCode(JSC::JSGlobalObject* lexicalGlobalObject, + JSC::Identifier, + JSC::JSValue payload, + Vector& exportNames, + JSC::MarkedArgumentBuffer& exportValues) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + exportNames.append(vm.propertyNames->defaultKeyword); + exportValues.append(payload); + const Identifier& esModuleMarker = vm.propertyNames->__esModule; + exportNames.append(esModuleMarker); + exportValues.append(jsBoolean(true)); } -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateJSValueExportDefaultObjectSourceCode(JSC::JSGlobalObject* globalObject, - JSC::JSValue value) +JSC::JSSourceCode* createObjectModuleSourceCode(JSC::VM& vm, JSC::JSObject* exports, WTF::String&& sourceURL) { - if (value.isCell()) - gcProtectNullTolerant(value.asCell()); - return [value](JSC::JSGlobalObject* lexicalGlobalObject, - JSC::Identifier moduleKey, - Vector& exportNames, - JSC::MarkedArgumentBuffer& exportValues) -> void { - auto& vm = JSC::getVM(lexicalGlobalObject); - exportNames.append(vm.propertyNames->defaultKeyword); - exportValues.append(value); - const Identifier& esModuleMarker = vm.propertyNames->__esModule; - exportNames.append(esModuleMarker); - exportValues.append(jsBoolean(true)); + return JSC::JSSourceCode::createWithPayload(vm, generateObjectModuleSourceCode, exports, JSC::SourceOrigin(), WTF::move(sourceURL)); +} - if (value.isCell()) - gcUnprotectNullTolerant(value.asCell()); - }; +JSC::JSSourceCode* createJSValueModuleSourceCode(JSC::VM& vm, JSC::JSValue value, WTF::String&& sourceURL) +{ + if (value.isObject() && !JSC::isJSArray(value)) + return JSC::JSSourceCode::createWithPayload(vm, generateObjectModuleSourceCodeForJSON, value, JSC::SourceOrigin(), WTF::move(sourceURL)); + + return createJSValueExportDefaultObjectSourceCode(vm, value, WTF::move(sourceURL)); } + +JSC::JSSourceCode* createJSValueExportDefaultObjectSourceCode(JSC::VM& vm, JSC::JSValue value, WTF::String&& sourceURL) +{ + return JSC::JSSourceCode::createWithPayload(vm, generateJSValueExportDefaultObjectSourceCode, value, JSC::SourceOrigin(), WTF::move(sourceURL)); +} + } // namespace Zig diff --git a/src/jsc/modules/ObjectModule.h b/src/jsc/modules/ObjectModule.h index 9e4807a8c4d9..db99f0b56314 100644 --- a/src/jsc/modules/ObjectModule.h +++ b/src/jsc/modules/ObjectModule.h @@ -2,22 +2,21 @@ #include "../bindings/ZigGlobalObject.h" #include +#include namespace Zig { -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateObjectModuleSourceCode(JSC::JSGlobalObject* globalObject, - JSC::JSObject* object); -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateObjectModuleSourceCodeForJSON(JSC::JSGlobalObject* globalObject, - JSC::JSObject* object); +// The source of a synthetic module that exports a JS value. The loader reads the exports from the value each time +// it makes a module from the source, which it does never, once, or more than once, so the JSSourceCode holds the value +// (JSC::JSSourceCode::createWithPayload). -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateJSValueModuleSourceCode(JSC::JSGlobalObject* globalObject, - JSC::JSValue value); +// The own enumerable string-keyed properties of `exports` are the exports. +JSC::JSSourceCode* createObjectModuleSourceCode(JSC::VM&, JSC::JSObject* exports, WTF::String&& sourceURL); -JSC::SyntheticSourceProvider::SyntheticSourceGenerator -generateJSValueExportDefaultObjectSourceCode(JSC::JSGlobalObject* globalObject, - JSC::JSValue value); +// `value` is the default export. When it is an object that is not an array, its properties are exports as well. +JSC::JSSourceCode* createJSValueModuleSourceCode(JSC::VM&, JSC::JSValue value, WTF::String&& sourceURL); + +// `value` is the default export, and the only one. +JSC::JSSourceCode* createJSValueExportDefaultObjectSourceCode(JSC::VM&, JSC::JSValue value, WTF::String&& sourceURL); } // namespace Zig diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index 8360d7bb4d98..153a93890145 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -1635,6 +1635,12 @@ describe.concurrent("--isolate: a finished file's late completions do not run in // - monitorEventLoopDelay().enable(): the per-thread monitor holds the file's // histogram. It is disabled at the swap and only ever holds the histogram // weakly, so an enabled monitor must not keep the file's global alive. +// - a module source that the loader drops: the source of a data or object +// module (JSON, a mock, a plugin's loader "object") kept a GC root on the +// value the module exports until a module was made from it. require() of a +// mocked module, two import() of one file at once, and require() of a graph +// that imports one data file twice each fetch a source that no module is +// made from, so its root stayed, and the value reaches its global. // // Each fixture runs 8 isolated files that leak one handle apiece, forces a // full GC, and counts live GlobalObject cells. Pinned globals accumulate @@ -1773,6 +1779,42 @@ describe.concurrent("--isolate: collects globals pinned by leaked handles", () = ); expect(await maxLiveGlobals(String(dir))).toBeLessThanOrEqual(4); }); + + test("require() of a mocked module", async () => { + using dir = tempDir( + "isolate-leak-mock-require", + makeLeakFixture(` + import { mock } from "bun:test"; + mock.module("./mocked-dep", () => ({ default: 1 })); + require("./mocked-dep"); + `), + ); + expect(await maxLiveGlobals(String(dir))).toBeLessThanOrEqual(4); + }); + + test("two import() of one data file at once", async () => { + using dir = tempDir("isolate-leak-data-import", { + ...makeLeakFixture(` + await Promise.all([import("./data.json"), import("./data.json")]); + `), + "data.json": `{ "value": 1 }`, + }); + expect(await maxLiveGlobals(String(dir))).toBeLessThanOrEqual(4); + }); + + // The shape of #39941. A synchronous load fetches the data file once for each module that imports it. + test("require() of an ES module graph that imports one data file twice", async () => { + using dir = tempDir("isolate-leak-require-graph", { + ...makeLeakFixture(` + require("./graph/entry.mjs"); + `), + "graph/entry.mjs": `import { a } from "./a.mjs";\nimport { b } from "./b.mjs";\nexport const sum = a + b;\n`, + "graph/a.mjs": `import data from "./data.json";\nexport const a = data.value;\n`, + "graph/b.mjs": `import data from "./data.json";\nexport const b = data.value;\n`, + "graph/data.json": `{ "value": 1 }`, + }); + expect(await maxLiveGlobals(String(dir))).toBeLessThanOrEqual(4); + }); }); // fs.watchFile's StatWatcher is thread-safe-refcounted: the scheduler queue diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index 70fd04dc8fcd..45a99337164f 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -1155,3 +1155,141 @@ it.concurrent( }); }, ); + +// The loader makes a module from one source never, once, or more than once, and an object module reads its exports +// object each time. `allocate` makes objects of the size of that exports object, which take its cell if it was freed. +describe.concurrent("object loader: the exports object lives as long as its source", () => { + const setup = ` + const exports = () => ({ exports: { v: 1, list: [1, 2, 3] }, loader: "object" }); + const register = id => Bun.plugin({ name: "virtual", setup(build) { build.module(id, exports); } }); + const allocate = () => Array.from({ length: 1000 }, (_, i) => ({ other: i, object: [i] })); + `; + + async function run(entry: string) { + using dir = tempDir("plugin-object-loader-source", { "entry.ts": setup + entry }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + // An import() that joined a loaded module gets that module's source again when the module is removed before the + // import() settles. + it.each([ + ["delete require.cache", `delete require.cache["virtual:x"]`], + ["a second build.module()", `register("virtual:x")`], + ])("import() in flight while %s removes the module", async (_, remove) => { + expect( + await run(` + register("virtual:x"); + const seen = new Set(); + for (let round = 0; round < 10; round++) { + await import("virtual:x"); + const inFlight = import("virtual:x"); + ${remove}; + Bun.gc(true); + const others = allocate(); + seen.add(JSON.stringify(await inFlight)); + others.length = 0; + } + console.log([...seen].join("\\n")); + `), + ).toEqual({ stdout: `{"list":[1,2,3],"v":1}\n`, stderr: "", exitCode: 0 }); + }); + + // require() of a module whose import() has fetched its source and not made its module yet makes the module at + // once, and the import() makes it again. How many microtasks that takes is the loader's business, so try a few. + it("require() of the module while its import() is in flight", async () => { + expect( + await run(` + const seen = new Set(); + for (let microtasks = 0; microtasks < 4; microtasks++) { + for (let round = 0; round < 3; round++) { + const id = "virtual:" + microtasks + ":" + round; + register(id); + const inFlight = import(id); + let wait = Promise.resolve(); + for (let i = 0; i < microtasks; i++) wait = wait.then(() => {}); + let required, others; + const later = wait.then(() => { + Bun.gc(true); + others = allocate(); + required = require(id); + }); + const imported = await inFlight; + await later; + seen.add(JSON.stringify([imported, required])); + others.length = 0; + } + } + console.log([...seen].join("\\n")); + `), + ).toEqual({ stdout: `[{"list":[1,2,3],"v":1},{"list":[1,2,3],"v":1}]\n`, stderr: "", exitCode: 0 }); + }); + + // A source that never becomes a module, or whose module cannot be made, must not keep its exports object forever. + it.each([ + [ + "two import() of the module at once", + `register("virtual:x");`, + ` + for (let round = 0; round < 20; round++) { + await Promise.all([import("virtual:x"), import("virtual:x")]); + delete require.cache["virtual:x"]; + } + `, + "", + ], + [ + "import() and then require() of the module", + `for (let round = 0; round < 20; round++) register("virtual:" + round);`, + ` + for (let round = 0; round < 20; round++) { + await import("virtual:" + round); + require("virtual:" + round); + } + `, + "", + ], + [ + "an exports object whose ownKeys trap throws", + ` + Bun.plugin({ + name: "throws", + setup(build) { + build.module("virtual:throws", () => ({ + exports: new Proxy({}, { ownKeys() { throw new Error("ownKeys threw"); } }), + loader: "object", + })); + }, + }); + `, + ` + const errors = new Set(); + for (let round = 0; round < 20; round++) { + await import("virtual:throws").then(() => errors.add("no error"), error => errors.add(error.message)); + delete require.cache["virtual:throws"]; + } + console.log([...errors].join("\\n")); + `, + "ownKeys threw\n", + ], + ])("%s leaves nothing protected", async (_, prepare, rounds, output) => { + expect( + await run(` + import { heapStats } from "bun:jsc"; + ${prepare} + Bun.gc(true); + const before = heapStats().protectedObjectCount; + ${rounds} + Bun.gc(true); + console.log("protected:", heapStats().protectedObjectCount - before); + `), + ).toEqual({ stdout: output + "protected: 0\n", stderr: "", exitCode: 0 }); + }); +}); diff --git a/test/js/bun/resolve/concurrent-dynamic-import.test.ts b/test/js/bun/resolve/concurrent-dynamic-import.test.ts index b21ead8ddde6..24578e3f60d2 100644 --- a/test/js/bun/resolve/concurrent-dynamic-import.test.ts +++ b/test/js/bun/resolve/concurrent-dynamic-import.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; // Two dynamic imports of the same specifier issued before the first async @@ -30,3 +30,91 @@ test("concurrent dynamic imports of the same module both resolve", async () => { expect(stdout.trim()).toBe("ok"); expect(exitCode).toBe(0); }); + +// A data module exports the value its file parsed to. The loader makes a module from one source never, once, or more +// than once, and reads the value each time. +describe.concurrent("the value of a data module lives as long as its source", () => { + const object = { default: { v: 1, list: [1, 2, 3] }, v: 1, list: [1, 2, 3] }; + const files = { + "object.json": { text: `{ "v": 1, "list": [1, 2, 3] }`, namespace: object }, + "object.toml": { text: `v = 1\nlist = [1, 2, 3]\n`, namespace: object }, + "object.yaml": { text: `v: 1\nlist:\n - 1\n - 2\n - 3\n`, namespace: object }, + "array.json": { text: `[1, 2, 3]`, namespace: { __esModule: true, default: [1, 2, 3] } }, + }; + + async function run(entry: string) { + using dir = tempDir("data-module-source", { + ...Object.fromEntries(Object.entries(files).map(([name, { text }]) => [name, text])), + "entry.ts": entry, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + // An import() that joined a loaded module gets that module's source again when the module is removed before the + // import() settles. The new macrotask lets go of the first namespace, which held the value until then. `allocate` + // makes values of the kinds a data file parses to, which take the cell of the file's value if it was freed. + test.each(Object.keys(files) as (keyof typeof files)[])( + "%s removed from require.cache while an import() of it is in flight", + async file => { + const { stdout, stderr, exitCode } = await run(` + import { join } from "node:path"; + const path = join(import.meta.dir, ${JSON.stringify(file)}); + const allocate = () => + Array.from({ length: 300 }, (_, i) => [{ other: i, object: [i] }, JSON.parse(\`{ "other": \${i}, "object": [\${i}] }\`), [i, i, i]]); + const load = async () => void (await import(path)); + + const seen = new Set(); + for (let round = 0; round < 3; round++) { + await load(); + await new Promise(resolve => setImmediate(resolve)); + const inFlight = import(path); + delete require.cache[path]; + Bun.gc(true); + const others = allocate(); + seen.add(JSON.stringify(await inFlight)); + others.length = 0; + } + console.log(JSON.stringify([...seen].map(namespace => JSON.parse(namespace)))); + `); + expect({ stdout: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + stdout: [files[file].namespace], + stderr: "", + exitCode: 0, + }); + }, + ); + + // Each import() of a module that is not loaded yet fetches it, and the loader makes a module from one of the sources. + test("two import() of a data file at once leave nothing protected", async () => { + const { stdout, stderr, exitCode } = await run(` + import { heapStats } from "bun:jsc"; + import { join } from "node:path"; + const protectedBy = {}; + for (const file of ${JSON.stringify(Object.keys(files))}) { + const path = join(import.meta.dir, file); + Bun.gc(true); + const before = heapStats().protectedObjectCount; + for (let round = 0; round < 10; round++) { + await Promise.all([import(path), import(path)]); + delete require.cache[path]; + } + Bun.gc(true); + protectedBy[file] = heapStats().protectedObjectCount - before; + } + console.log(JSON.stringify(protectedBy)); + `); + expect({ stdout: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + stdout: { "object.json": 0, "object.toml": 0, "object.yaml": 0, "array.json": 0 }, + stderr: "", + exitCode: 0, + }); + }); +}); diff --git a/test/js/bun/test/mock/mock-module.test.ts b/test/js/bun/test/mock/mock-module.test.ts index 42dc5b227826..f7c5c74837e9 100644 --- a/test/js/bun/test/mock/mock-module.test.ts +++ b/test/js/bun/test/mock/mock-module.test.ts @@ -540,3 +540,32 @@ test.concurrent("mock.module() of a module whose import() is still loading its d expect(stderr).toContain(" 1 pass"); expect(exitCode).toBe(0); }); + +// require() of a mocked module fetches the source of the mock twice, and the loader makes a module from one of the two. +test.concurrent("mock.module() and then require() of the mock leave nothing protected", async () => { + using dir = tempDir("mock-module-require-protected", { + "protected.test.ts": ` + import { expect, mock, test } from "bun:test"; + import { heapStats } from "bun:jsc"; + + test("require() of 20 mocked modules", () => { + for (let i = 0; i < 20; i++) mock.module("mocked-" + i, () => ({ value: i })); + Bun.gc(true); + const before = heapStats().protectedObjectCount; + for (let i = 0; i < 20; i++) expect(require("mocked-" + i).value).toBe(i); + Bun.gc(true); + expect(heapStats().protectedObjectCount - before).toBe(0); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "./protected.test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain(" 1 pass"); + expect(exitCode).toBe(0); +});