mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
fecba819ef
commit
525433006d
7 changed files with 93 additions and 61 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]);
|
||||
}
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue