test(ui): keep WeakRef-collection proofs on Node (#163353)

JavaScriptCore scans native stacks and registers conservatively, so a
forced Bun.gc(true) cannot prove an unreachable WeakRef target is
collected. After #162667 moved the Control UI retention files onto Bun,
the usage overview and chat thread proofs failed intermittently in the
bun:ui checks-ui shards; a Bun heap snapshot (#159991) showed the
leftover object's only retainer was the test's own WeakRef.

Route the five Control UI files that call collectGarbageForTest to the
Node lane through the ui/vitest.config.ts nodeRequired partition, make
the helper throw under Bun with a pointer to that owner, and restore the
pre-#163018 overview collection proof, which is deterministic on Node.
Routing pins and CI docs follow the mixed Node/Bun UI partition.
This commit is contained in:
Peter Steinberger 2026-10-02 11:54:40 -07:00 • committed by GitHub
parent fecba819ef
commit 525433006d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 93 additions and 61 deletions

View file

@ -179,11 +179,14 @@ The Code Mode executor runs with Vitest on Bun using the fork's
copy-on-write diagnostics-channel subscriber handling. Markdown render-aware
chunking stays on Node because the pinned WebKit lacks the `Intl.Segmenter`
surrogate-boundary fix needed by that suite.
The complete fake-timer lane, plugin and proxy retention tests, and Control UI
support Bun. UI retains its six GC assertions in `chat-pane-retention.test.ts`,
`chat-thread-retention.test.ts`, and `usage-page-retention.test.ts`; they use
runtime-neutral collection and WeakRef checks. V8-specific heap and worker-limit
assertions and the remaining qualified Node-only selections still run on Node.
The complete fake-timer lane, plugin and proxy retention tests, and other
Control UI tests support Bun. Control UI WeakRef-collection proofs in
`desktop-mobile-keyboard.test.ts`, `chat-pane-retention.test.ts`,
`chat-thread-retention.test.ts`, `session-snapshot-store.test.ts`, and
`usage-page-retention.test.ts` stay on Node because JavaScriptCore's
conservative stack scanning can keep an unreachable target alive after a forced
collection. V8-specific heap and worker-limit assertions and the remaining
qualified Node-only selections still run on Node.
The missing-Docker test also runs on Bun, using an empty executable directory
instead of an empty `PATH`, which Bun resolves through its default search path.
Other families retain Node until they pass on the pinned fork within their

View file

@ -210,7 +210,15 @@ const runtimePartitions = new Map<
globSync(controlUiTestGlobs, { cwd, exclude: controlUiE2eTestGlobs })
.map((file) => file.replaceAll("\\", "/"))
.toSorted(),
nodeRequired: new Set(),
// collectGarbageForTest needs V8's precise collection: JavaScriptCore's
// conservative stack scanning can retain unreachable WeakRef targets.
nodeRequired: new Set([
"ui/src/components/desktop/desktop-mobile-keyboard.test.ts",
"ui/src/pages/chat/chat-pane-retention.test.ts",
"ui/src/pages/chat/chat-thread-retention.test.ts",
"ui/src/pages/chat/session-snapshot-store.test.ts",
"ui/src/pages/usage/usage-page-retention.test.ts",
]),
includeAfterShard: true,
},
],

View file

@ -89,6 +89,13 @@ const gatewayClientTarget = "src/gateway/talk/handlers/client-native-control.tes
const memoryConfig = "test/vitest/vitest.extension-memory.config.ts";
const memoryTarget = "extensions/memory-lancedb/config.test.ts";
const memoryIncludes = ["extensions/memory-lancedb", "extensions/memory-wiki"];
const uiNodeFiles = [
"ui/src/components/desktop/desktop-mobile-keyboard.test.ts",
"ui/src/pages/chat/chat-pane-retention.test.ts",
"ui/src/pages/chat/chat-thread-retention.test.ts",
"ui/src/pages/chat/session-snapshot-store.test.ts",
"ui/src/pages/usage/usage-page-retention.test.ts",
];
function makeScratchDir(): string {
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-shard-test-"));
@ -270,12 +277,8 @@ describe("scripts/ci-run-node-test-shard.mts", () => {
"preserves selected UI discovery before runtime partitioning under %s",
async (policy) => {
vi.spyOn(groupOwner, "shouldUseDetachedVitestProcessGroup").mockReturnValue(true);
const includePatterns = [
"ui/src/pages/chat/chat-pane-history.test.ts",
"ui/src/pages/chat/chat-pane-retention.test.ts",
"ui/src/pages/chat/chat-thread-retention.test.ts",
"ui/src/pages/usage/usage-page-retention.test.ts",
];
const bunFile = "ui/src/pages/chat/chat-pane-history.test.ts";
const includePatterns = [bunFile, ...uiNodeFiles];
const seen: Array<{ runtime: string | undefined; membership?: string[] }> = [];
await expect(
runShardPlans(
@ -307,10 +310,11 @@ describe("scripts/ci-run-node-test-shard.mts", () => {
},
),
).resolves.toBe(0);
expect(seen).toEqual([
...(policy === "dual" ? [{ runtime: "node", membership: undefined }] : []),
{ runtime: "bun", membership: includePatterns },
]);
const expected = [
{ runtime: "node", membership: policy === "dual" ? undefined : uiNodeFiles },
{ runtime: "bun", membership: [bunFile] },
];
expect(seen).toEqual(policy === "dual" ? expected : expected.toReversed());
},
);
@ -1298,11 +1302,11 @@ describe("scripts/ci-run-node-test-shard.mts", () => {
allocatorInterval: "1000",
},
];
expect(seen).toEqual(policy === "dual" ? expected : expected.slice(1));
expect(seen).toEqual(policy === "dual" ? expected : expected.toReversed());
},
);
it("runs qualified UI only on Bun without joined process ownership", async () => {
it("retains Node-first UI execution without joined process ownership", async () => {
vi.spyOn(groupOwner, "shouldUseDetachedVitestProcessGroup").mockReturnValue(false);
const seen: string[] = [];
await expect(
@ -1316,7 +1320,7 @@ describe("scripts/ci-run-node-test-shard.mts", () => {
},
}),
).resolves.toBe(0);
expect(seen).toEqual(["bun"]);
expect(seen).toEqual(["node", "bun"]);
});
it.each([
@ -1336,17 +1340,8 @@ describe("scripts/ci-run-node-test-shard.mts", () => {
vi.spyOn(groupOwner, "shouldUseDetachedVitestProcessGroup").mockReturnValue(true);
const seen: string[] = [];
let receiptFile: string | undefined;
const nodeFile = "ui/src/synthetic-node-only.test.ts";
const nodeFile = "ui/src/pages/usage/usage-page-retention.test.ts";
const bunFile = "ui/src/pages/chat/chat-pane-history.test.ts";
// All current UI files qualify for Bun; preserve the receipt transport's
// fallback coverage with an explicit synthetic mixed-runtime partition.
vi.spyOn(
await import("../../scripts/lib/ci-test-runtime.mts"),
"resolveCiTestRuntimeSelections",
).mockReturnValue([
{ runtime: "node", includePatterns: [nodeFile], includeAfterShard: true },
{ runtime: "bun", includePatterns: [bunFile], includeAfterShard: true },
]);
await expect(
runShardPlans([{ kind: "group", name: "ui", plan: { configs: ["ui/vitest.config.ts"] } }], {
env: { OPENCLAW_CI_TEST_RUNTIME_POLICY: "bun-compatible" },
@ -1357,7 +1352,7 @@ describe("scripts/ci-run-node-test-shard.mts", () => {
const included = JSON.parse(
readFileSync(env.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE!, "utf8"),
);
expect(included).toEqual([nodeFile]);
expect(included).toEqual(uiNodeFiles);
return 0;
}
receiptFile = env.OPENCLAW_VITEST_NATIVE_SHARD_RECEIPT;

View file

@ -5233,7 +5233,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
frozenTarget: false,
compatibilityTarget: false,
policy: "bun-compatible",
runtimes: ["bun"],
runtimes: ["bun", "node"],
shards: [1, 2, 3],
},
{
@ -5416,18 +5416,31 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
expect(childEnv.OPENCLAW_VITEST_INCLUDE_FILE).toBeUndefined();
}
const includeFile = childEnv.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE;
if (childEnv.OPENCLAW_VITEST_RUNTIME === "bun") {
if (
childEnv.OPENCLAW_VITEST_RUNTIME === "bun" ||
scenario.policy === "bun-compatible"
) {
expect(includeFile).toBeTruthy();
const included = JSON.parse(readFileSync(includeFile!, "utf8"));
const retentionFiles = [
const nodeFiles = [
"ui/src/components/desktop/desktop-mobile-keyboard.test.ts",
"ui/src/pages/chat/chat-pane-retention.test.ts",
"ui/src/pages/chat/chat-thread-retention.test.ts",
"ui/src/pages/chat/session-snapshot-store.test.ts",
"ui/src/pages/usage/usage-page-retention.test.ts",
];
expect(included.length).toBeGreaterThan(1000);
expect(included).toEqual(expect.arrayContaining(retentionFiles));
if (uiGroups[0]?.includePatterns) {
expect(included.toSorted()).toEqual(uiGroups[0].includePatterns.toSorted());
if (childEnv.OPENCLAW_VITEST_RUNTIME === "node") {
expect(included.toSorted()).toEqual(nodeFiles);
} else {
expect(included.length).toBeGreaterThan(1000);
expect(included.filter((file: string) => nodeFiles.includes(file))).toEqual([]);
if (uiGroups[0]?.includePatterns) {
expect(included.toSorted()).toEqual(
uiGroups[0].includePatterns
.filter((file) => !nodeFiles.includes(file))
.toSorted(),
);
}
}
} else {
expect(includeFile).toBeUndefined();

View file

@ -165,6 +165,13 @@ describe("ui package vitest config", () => {
projectOrder: { native: string[]; actual: string[] };
};
};
const nodeFiles = new Set([
"ui/src/components/desktop/desktop-mobile-keyboard.test.ts",
"ui/src/pages/chat/chat-pane-retention.test.ts",
"ui/src/pages/chat/chat-thread-retention.test.ts",
"ui/src/pages/chat/session-snapshot-store.test.ts",
"ui/src/pages/usage/usage-page-retention.test.ts",
]);
expect(report.discovered.length).toBeGreaterThan(1000);
// Package isolated/timing/Chromium projects retain their separate owners.
expect(report.rootNodeFiles).toEqual(report.packageNodeFiles);
@ -234,7 +241,7 @@ describe("ui package vitest config", () => {
.toSorted(),
).toEqual(report.discovered);
for (const row of report.rows) {
expect(row.receipts).toHaveLength(3);
expect(row.receipts).toHaveLength(4);
for (const { requestId, value } of row.receipts) {
expect(value).toEqual({
version: 1,
@ -247,10 +254,13 @@ describe("ui package vitest config", () => {
expect(value.files.toSorted()).toEqual(row.original);
}
const compatible = row.selected["bun-compatible"]!;
expect(compatible).toEqual([{ runtime: "bun", files: row.original }]);
expect(compatible.map((selection) => selection.runtime)).toEqual(["node", "bun"]);
expect(compatible[0]!.files).toEqual(row.original.filter((file) => nodeFiles.has(file)));
expect(compatible[1]!.files).toEqual(row.original.filter((file) => !nodeFiles.has(file)));
expect(compatible.flatMap((selection) => selection.files).toSorted()).toEqual(row.original);
expect(row.selected.dual).toEqual([
{ runtime: "node", files: row.original },
compatible[0],
compatible[1],
]);
}
}));

View file

@ -99,22 +99,32 @@ describe("UsagePage detail requests", () => {
});
it("releases a loaded overview when its Gateway identity is replaced", async () => {
const result = {
...cacheSnapshot("fresh").result,
sessions: [{ key: "agent:main:overview-lifetime", usage: null }],
class OverviewPayload {
key = "agent:main:overview-lifetime";
usage = null;
}
let payload: WeakRef<OverviewPayload> | undefined;
const snapshot = cacheSnapshot("fresh");
const request = async (method: string) => {
if (method === "sessions.usage") {
const report = new OverviewPayload();
payload = new WeakRef(report);
return { ...snapshot.result, sessions: [report] };
}
return { providers: [] };
};
const request = async (method: string) =>
method === "sessions.usage" ? result : { providers: [] };
const page = await createPage({ request } as unknown as GatewayBrowserClient);
await page.loadUsage();
expect(page).toHaveProperty("usageSnapshot.result", result);
expect(payload).toBeDefined();
page.context = contextWithClient({
request: async () => ({}),
} as unknown as GatewayBrowserClient);
page.requestUpdate();
await page.updateComplete;
// Check the display owner's strong reference, independent of GC scheduling.
expect(page).toHaveProperty("usageSnapshot", null);
const collectionControl = new WeakRef({ unowned: true });
await collectGarbageForTest();
expect(collectionControl.deref()).toBeUndefined();
expect(payload!.deref()).toBeUndefined();
expect(page.isConnected).toBe(true);
});
});

View file

@ -1,21 +1,14 @@
import { setImmediate as scheduleImmediate } from "node:timers";
import { setImmediate } from "node:timers/promises";
declare const Bun: { gc(force: boolean): void };
export async function collectGarbageForTest(): Promise<void> {
// WeakRef targets stay alive for the current job, even without a strong owner.
if (process.versions.bun) {
// Collect outside JavaScriptCore's promise-microtask drain, whose stack
// can otherwise keep settled async values alive.
await new Promise<void>((resolve) => {
scheduleImmediate(() => {
Bun.gc(true);
resolve();
});
});
return;
throw new Error(
"collectGarbageForTest needs V8's precise collection: JavaScriptCore scans stacks " +
"conservatively, so a forced Bun GC cannot prove a WeakRef target is unreachable. " +
"Add this file to the ui/vitest.config.ts nodeRequired set in scripts/lib/ci-test-runtime.mts.",
);
}
// WeakRef targets stay alive for the current job, even without a strong owner.
await setImmediate();
const { Session } = await import("node:inspector");
const session = new Session();