diff --git a/.github/OPENCLAW_CI.md b/.github/OPENCLAW_CI.md index 5307e4898df8..bd15c5777c7e 100644 --- a/.github/OPENCLAW_CI.md +++ b/.github/OPENCLAW_CI.md @@ -84,6 +84,12 @@ test/js/node/process/process.test.js test/js/bun/sqlite/column-types.test.js ``` +The `vm.Script` leak regression checks live `Script` cells after collection and +collects between allocation batches. It measures RSS growth over all 10,000 scripts +after a separate 5,000-script cache and allocator warmup. It retains the 200 MiB release and 700 MiB ASAN limits; a deliberately +retained-script control must fail the live-cell assertion when qualifying changes +to this guard. + ## Windows release qualification The separate [`openclaw-release.yml`](workflows/openclaw-release.yml) keeps diff --git a/CHANGELOG.md b/CHANGELOG.md index a49d8a73e407..f50d50657c91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -212,3 +212,5 @@ - Publish Darwin/Linux prereleases while Windows signing is unconfigured; require an explicit signed-Windows release switch before including either Windows architecture, while preserving test-only Windows CI. - Avoid requesting executable access when renaming files on Windows, preventing unnecessary synchronous antivirus scans during compile-cache publication. + +- Check live `vm.Script` counts and full-workload RSS growth after warmup in the leak regression, preserving the 200 MiB release and 700 MiB ASAN RSS limits. Adapts [oven-sh/bun#42474](https://github.com/oven-sh/bun/pull/42474); thanks @robobun! diff --git a/test/js/node/vm/script-leak.test.ts b/test/js/node/vm/script-leak.test.ts index 586ec3d88b99..c77a091eda9b 100644 --- a/test/js/node/vm/script-leak.test.ts +++ b/test/js/node/vm/script-leak.test.ts @@ -1,30 +1,41 @@ -const vm = require("vm"); -const { describe, it, expect } = require("bun:test"); -const { isASAN, rss } = require("harness"); +import { heapStats } from "bun:jsc"; +import { describe, expect, it } from "bun:test"; +import { isASAN, rss } from "harness"; +import vm from "node:vm"; + +const iterations = 10_000; +const warmupIterations = 5_000; +const batch = 500; describe("vm.Script", () => { it("shouldn't leak memory", () => { - const initialUsage = rss(); - - { - const source = `/*\n${Buffer.alloc(10000, " * aaaaa\n").toString("utf8")}\n*/ Buffer.alloc(10, 'hello');`; - - function go(i) { - const script = new vm.Script(source + "//" + i); - script.runInThisContext(); - } + Bun.gc(true); + const initialCount = heapStats().objectTypeCounts.Script ?? 0; + let initialUsage = 0; + // The measured source payload exceeds the RSS bound if native sources are retained. + const source = `/*\n${Buffer.alloc(32_768, " * aaaaa\n").toString("utf8")}\n*/ Buffer.alloc(10, 'hello').toString();`; - for (let i = 0; i < 10000; ++i) { - go(i); - } + let result; + function go(i) { + const script = new vm.Script(source + "//" + i); + result = script.runInThisContext(); } + for (let i = 0; i < warmupIterations + iterations; ++i) { + go(i); + // Bound temporary garbage before it inflates the allocator's resident pages. + if ((i + 1) % batch === 0) Bun.gc(true); + // Measure all 10,000 scripts after a separate cache and allocator warmup. + if (i + 1 === warmupIterations) initialUsage = rss(); + } + expect(result).toBe("hellohello"); Bun.gc(true); const finalUsage = rss(); + const finalCount = heapStats().objectTypeCounts.Script ?? 0; const megabytes = Math.round(((finalUsage - initialUsage) / 1024 / 1024) * 100) / 100; - // ASAN's quarantine retains freed allocations (default 256 MB) so RSS - // deltas run far higher under bun-asan; widen the threshold there. + expect(finalCount).toBeLessThanOrEqual(initialCount + 10); + // ASAN's quarantine retains freed allocations (default 256 MB). expect(megabytes).toBeLessThan(isASAN ? 700 : 200); }); });