Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,9 @@ jobs:

- name: Validate README documentation
run: pnpm run test:validate

- name: Run memory tests (Node, --expose-gc)
run: pnpm run test:memory:node

- name: Run memory tests (browser, Chromium gc())
run: pnpm run test:memory:browser
13 changes: 9 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
"scripts": {
"dev": "vitepress dev docs",
"test": "vitest",
"test:memory": "pnpm run test:memory:node && pnpm run test:memory:browser",
"test:memory:node": "vitest run --config vitest.memory.node.config.ts",
"test:memory:browser": "vitest run --config vitest.memory.browser.config.ts",
"test:memory:soak": "vitest run --config vitest.memory.soak.config.ts",
"test:validate": "pnpm --filter @supergrain/doc-tests run test:validate",
"build": "pnpm --filter=\"@supergrain/kernel\" --filter=\"@supergrain/mill\" --filter=\"@supergrain/silo\" --workspace-concurrency=1 run build",
"bench:core": "vitest bench --root packages/kernel",
Expand All @@ -23,7 +27,8 @@
"release": "pnpm -r publish",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
"docs:preview": "vitepress preview docs",
"playwright:install": "playwright install chromium"
},
"dependencies": {
"alien-signals": "^2.0.7"
Expand All @@ -34,8 +39,8 @@
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitest/browser": "^4.1.0",
"@vitest/browser-playwright": "^4.1.0",
"@vitest/browser": "^4.1.5",
"@vitest/browser-playwright": "^4.1.5",
"jsdom": "^26.1.0",
"oxfmt": "^0.41.0",
"oxlint": "^1.15.0",
Expand All @@ -48,7 +53,7 @@
"vite": "^7.1.5",
"vite-plugin-dts": "^4.5.4",
"vitepress": "^1.6.4",
"vitest": "^4.1.0"
"vitest": "^4.1.5"
},
"packageManager": "pnpm@10.6.3"
}
4 changes: 2 additions & 2 deletions packages/comparisons/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"zustand": "^5.0.8"
},
"devDependencies": {
"@vitest/browser": "4.1.0",
"vitest": "4.1.0"
"@vitest/browser": "4.1.5",
"vitest": "4.1.5"
}
}
4 changes: 2 additions & 2 deletions packages/doc-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/browser": "4.1.0",
"@vitest/browser": "4.1.5",
"playwright": "^1.55.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5",
"vitest": "4.1.0"
"vitest": "4.1.5"
}
}
5 changes: 3 additions & 2 deletions packages/husk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,19 @@
"@supergrain/kernel": "workspace:*"
},
"devDependencies": {
"@supergrain/test-utils": "workspace:*",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/browser": "4.1.0",
"@vitest/browser": "4.1.5",
"playwright": "^1.55.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5",
"vitest": "4.1.0"
"vitest": "4.1.5"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.0.0"
Expand Down
121 changes: 121 additions & 0 deletions packages/husk/tests/memory/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { signal } from "@supergrain/kernel";
import { delay } from "@supergrain/test-utils/memory";

import { defineResource, dispose, reactivePromise, reactiveTask, resource } from "../../src";

export interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}

export interface HuskPayload {
id: number;
values: Array<number>;
}

export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

export function makePayload(seed: number, width = 24): Array<HuskPayload> {
return Array.from({ length: width }, (_, index) => ({
id: seed * 1_000 + index,
values: Array.from({ length: 18 }, (__, offset) => seed + index + offset),
}));
}

export async function runHuskCycle(seed: number): Promise<void> {
const resourceTrigger = signal(0);
const resourceDeferreds: Array<Deferred<number>> = [];
const asyncResource = resource(
{ value: 0, payload: makePayload(seed) },
async (state, { abortSignal }) => {
const current = resourceTrigger();
const run = deferred<number>();
resourceDeferreds.push(run);
const value = await run.promise;
if (abortSignal.aborted) return;
state.value = current + value;
state.payload = makePayload(seed + value);
},
);

resourceTrigger(1);

const promiseTrigger = signal(0);
const promiseDeferreds: Array<Deferred<{ value: number; payload: Array<HuskPayload> }>> = [];
const reactive = reactivePromise(async (abortSignal) => {
const current = promiseTrigger();
const run = deferred<{ value: number; payload: Array<HuskPayload> }>();
abortSignal.addEventListener("abort", () =>
run.resolve({ value: current, payload: makePayload(seed) }),
);
promiseDeferreds.push(run);
return run.promise;
});

promiseTrigger(1);
promiseTrigger(2);

const task = reactiveTask(async (mode: "ok" | "fail", value: number) => {
if (mode === "fail") throw new Error(`task-${value}`);
return { value, payload: makePayload(seed + value, 12) };
});

const okRun = task.run("ok", seed);
const failedRun = task.run("fail", seed).catch(() => undefined);

dispose(asyncResource);
dispose(reactive as object);

for (const run of resourceDeferreds) {
run.resolve(seed);
}
for (const run of promiseDeferreds) {
run.resolve({ value: seed, payload: makePayload(seed + 1, 16) });
}

await Promise.allSettled([
okRun,
failedRun,
...resourceDeferreds.map((run) => run.promise),
...promiseDeferreds.map((run) => run.promise),
]);
await delay();
}

/**
* Exercises defineResource — reusable factory that creates multiple
* independent instances and disposes them all.
*/
export async function runDefineResourceCycle(seed: number): Promise<void> {
const fetchData = defineResource<number, { value: number; payload: Array<HuskPayload> }>(
() => ({ value: 0, payload: makePayload(seed) }),
async (state, url, { abortSignal }) => {
const run = deferred<Array<HuskPayload>>();
abortSignal.addEventListener("abort", () => run.resolve([]));
const result = await run.promise;
if (abortSignal.aborted) return;
state.value = url + result.length;
state.payload = makePayload(seed + url);
},
);

const trigger = signal(seed);
const instances = Array.from({ length: 5 }, () => fetchData(() => trigger()));

trigger(seed + 1);
trigger(seed + 2);

for (const instance of instances) {
dispose(instance);
}
await delay();
}
29 changes: 29 additions & 0 deletions packages/husk/tests/memory/husk.memory.soak.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
HAS_GC,
assertGcAvailable,
collectHeapSamples,
expectTrendToFlatten,
} from "@supergrain/test-utils/memory";
import { describe, it } from "vitest";

import { runHuskCycle } from "./fixtures";

it("GC is exposed (required for husk soak)", () => {
assertGcAvailable();
});

describe.runIf(HAS_GC)("husk memory soak", () => {
it("stays flat during extended async rerun churn", async () => {
const samples = await collectHeapSamples(10, async (round) => {
for (let index = 0; index < 100; index++) {
await runHuskCycle(round * 10_000 + index);
}
});

expectTrendToFlatten(samples, {
maxGrowthBytes: 5_000_000,
maxLastDeltaBytes: 850_000,
maxTailHeadRatio: 2.0,
});
});
});
Loading
Loading