diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c202e968edd..2322250042cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -255,3 +255,5 @@ - Name package-target resolution options explicitly to satisfy the Rust Mordant lint without changing resolution behavior. - Destroy Duplex-backed TLS transports without calling `end()`, preserve wrapped-socket close ordering, and honor half-open shutdown like Node.js. Ports HTTP/2 teardown from [oven-sh/bun#38195](https://github.com/oven-sh/bun/pull/38195) and adapts coverage from [oven-sh/bun#38154](https://github.com/oven-sh/bun/pull/38154); thanks @robobun! + +- Verify dead-realm FinalizationRegistry cancellation only after observing collection, while requiring a collected realm and preserving zero callbacks for every collected context. Adapts [oven-sh/bun#44544](https://github.com/oven-sh/bun/pull/44544); thanks @robobun! diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts index ecf124460208..bed9e7a478dc 100644 --- a/test/js/node/vm/vm.test.ts +++ b/test/js/node/vm/vm.test.ts @@ -2895,32 +2895,44 @@ test.concurrent("a FinalizationRegistry cleanup job is dropped when its context const nextTurn = () => new Promise(resolve => setImmediate(resolve)); const liveContextCleanedUp = Promise.withResolvers(); let liveContext; - let deadContextCleanups = 0; + const cleanups = [0, 0, 0, 0]; + const dropped = []; function setup() { // A collection sweeps the first 8 cells of a type itself and leaves the rest for later. ~JSGlobalObject // cancels the job too, so these contexts are past the first 8 and their registries are not. const swept = Array.from({ length: 8 }, () => vm.createContext({})); - const contexts = Array.from({ length: 4 }, () => vm.createContext({ onCleanup: () => deadContextCleanups++ })); + const contexts = Array.from(cleanups, (_, index) => vm.createContext({ onCleanup: () => cleanups[index]++ })); + // Each realm strongly holds its sandbox, so a cleared sandbox witness proves that realm was unmarked. + for (const context of contexts) dropped.push(new WeakRef(context)); liveContext = vm.createContext({ onCleanup: liveContextCleanedUp.resolve }); contexts.push(liveContext); for (const context of contexts) vm.runInContext("globalThis.registry = new FinalizationRegistry(onCleanup)", context); edenGC(); // Old generation now: the next eden collection leaves them marked. for (const context of contexts) for (let i = 0; i < 5; i++) context.registry.register({ i }, i); + contexts.length = 0; // A stale reference to this array must not retain every dropped context. } await nextTurn().then(setup); await nextTurn(); + const cleanupsBeforeCollection = cleanups.slice(); edenGC(); // The registered objects are dead: every registry posts its cleanup job. - fullGC(); // All contexts but one are dead, and their registries are destroyed. + fullGC(); // Cancel jobs for realms this collection finds dead, before yielding to their cleanup jobs. + const collected = dropped.map(context => context.deref() === undefined); await liveContextCleanedUp.promise; await nextTurn(); // A job posted after the live context's has run by now too. - console.log({ deadContextCleanups }); + console.log({ + cleanupsBeforeCollection, + // One collected realm exercises cancellation; requiring all four assumes a particular stack layout. + someContextCollected: collected.includes(true), + collectedContextsHadNoCleanups: cleanups.every((count, index) => !collected[index] || count === 0), + }); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", fixture], env: bunEnv, stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout, stderr, exitCode }).toEqual({ - stdout: "{\n deadContextCleanups: 0,\n}\n", + stdout: + "{\n cleanupsBeforeCollection: [ 0, 0, 0, 0 ],\n someContextCollected: true,\n collectedContextsHadNoCleanups: true,\n}\n", stderr: "", exitCode: 0, });