From d653191266bd3d51eba18230588b007c712c5373 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 10:47:30 -0700 Subject: [PATCH 1/5] test(studio-server): history prune tests allow a file time a moment past Date.now() --- .../studio-server/src/history/pruneHistories.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/studio-server/src/history/pruneHistories.test.ts b/packages/studio-server/src/history/pruneHistories.test.ts index ccd83cce21..47705c0054 100644 --- a/packages/studio-server/src/history/pruneHistories.test.ts +++ b/packages/studio-server/src/history/pruneHistories.test.ts @@ -31,6 +31,8 @@ function tempDir(prefix: string): string { } const DAY_MS = 24 * 60 * 60 * 1000; +// Date.now() rounds down to the millisecond; a file written in that millisecond can carry a later mtime. +const pastKeepWindow = () => Date.now() + 1 + KEEP_GONE_PROJECT_HISTORY_MS; async function projectWithHistory( historyRoot: string, @@ -105,7 +107,7 @@ describe("pruneGoneProjectHistories", () => { expect(await prunedIds(historyRoot, { ...elsewhere, now: Date.now() + 13 * DAY_MS })).toEqual( [], ); - expect(await prunedIds(historyRoot, { ...elsewhere, now: Date.now() + 14 * DAY_MS })).toEqual([ + expect(await prunedIds(historyRoot, { ...elsewhere, now: pastKeepWindow() })).toEqual([ gone.id, ]); }); @@ -178,7 +180,7 @@ describe("pruneGoneProjectHistories", () => { chmodSync(locked, 0o000); try { - const later = Date.now() + KEEP_GONE_PROJECT_HISTORY_MS; + const later = pastKeepWindow(); const onError = (error: unknown) => errors.push(error); expect(await prunedIds(historyRoot, { now: later, onError })).toEqual([gone.id]); } finally { @@ -206,7 +208,7 @@ describe("pruneGoneProjectHistories", () => { rmSync(join(mountPoint.projectDir, ".hyperframes"), { recursive: true, force: true }); rmSync(dirname(deleted.projectDir), { recursive: true, force: true }); - const later = Date.now() + KEEP_GONE_PROJECT_HISTORY_MS; + const later = pastKeepWindow(); expect(await prunedIds(historyRoot, { tempDir: historyRoot, now: later })).toEqual([ deleted.id, ]); @@ -227,7 +229,7 @@ describe("pruneGoneProjectHistories", () => { rmSync(folderGone.projectDir, { recursive: true, force: true }); rmSync(dirname(parentGone.projectDir), { recursive: true, force: true }); - const later = Date.now() + KEEP_GONE_PROJECT_HISTORY_MS; + const later = pastKeepWindow(); expect(await prunedIds(historyRoot, { tempDir: historyRoot, now: later })).toEqual([ emptied.id, ]); From cef4f3f8bd5a4e6bcfe17392065dd2892f6e6605 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 10:47:32 -0700 Subject: [PATCH 2/5] ci: temporary probe for the history prune test flake --- .github/workflows/tmp-prune-probe.yml | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/tmp-prune-probe.yml diff --git a/.github/workflows/tmp-prune-probe.yml b/.github/workflows/tmp-prune-probe.yml new file mode 100644 index 0000000000..0b8155e7e9 --- /dev/null +++ b/.github/workflows/tmp-prune-probe.yml @@ -0,0 +1,40 @@ +name: tmp prune probe +on: pull_request +jobs: + probe: + runs-on: ubuntu-latest + timeout-minutes: 50 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - run: bash scripts/ci/install-workspace-dependencies.sh + - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build && bun run --cwd packages/core build + - name: kernel and mtime probe + run: | + uname -r + node -e ' + const fs = require("fs"); const f = "/tmp/mtime-probe"; fs.writeFileSync(f, "0"); + for (const statFirst of [false, true]) { + let ahead = 0, max = 0; const n = 20000; + for (let i = 0; i < n; i++) { + if (statFirst) fs.statSync(f); + fs.writeFileSync(f, String(i)); const now = Date.now(); const m = fs.statSync(f).mtimeMs; + if (m > now) { ahead++; max = Math.max(max, m - now); } + } + console.log(`statFirst=${statFirst}: ${ahead}/${n} writes have mtime after Date.now(), max ${max.toFixed(3)} ms`); + }' + - name: 200 runs of main's test and of the fixed test, 4 workers each + working-directory: packages/studio-server + run: | + git show origin/main:packages/studio-server/src/history/pruneHistories.test.ts > src/history/pruneHistoriesMain.test.ts + loop() { + file=$1; seq 4 | xargs -P4 -I{} bash -c ' + f=0; for i in $(seq 50); do npx vitest run '"$file"' > /tmp/out-{}-$i 2>&1 || { f=$((f+1)); grep -m1 -a "×" /tmp/out-{}-$i; }; done; echo "worker {} failed $f/50"' + } + echo "== main's test"; loop src/history/pruneHistoriesMain.test.ts + echo "== fixed test"; loop src/history/pruneHistories.test.ts From ec5cb2d1222dda0ad070426f809cfe331152d16c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 12:11:49 -0700 Subject: [PATCH 3/5] fix(studio): timeline scroll-end timer no longer fires after unmount --- bun.lock | 6 ++-- packages/studio/package.json | 2 +- .../useTimelineVirtualRows.test.tsx | 32 +++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 0bd6d3deaa..f44103e550 100644 --- a/bun.lock +++ b/bun.lock @@ -339,7 +339,7 @@ "@hyperframes/studio-server": "workspace:*", "@mcp-b/global": "^5.0.1", "@phosphor-icons/react": "^2.1.10", - "@tanstack/react-virtual": "^3.14.6", + "@tanstack/react-virtual": "^3.14.13", "bpm-detective": "^2.0.5", "cn": "0.2.4", "dockview-react": "8.3.1", @@ -1170,9 +1170,9 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], - "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.6", "", { "dependencies": { "@tanstack/virtual-core": "3.17.4" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.13", "", { "dependencies": { "@tanstack/virtual-core": "3.17.11" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.4", "", {}, "sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.11", "", {}, "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g=="], "@tootallnate/once": ["@tootallnate/once@2.0.1", "", {}, "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ=="], diff --git a/packages/studio/package.json b/packages/studio/package.json index 607909fa4d..597d4af3bb 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -103,7 +103,7 @@ "@hyperframes/studio-server": "workspace:*", "@mcp-b/global": "^5.0.1", "@phosphor-icons/react": "^2.1.10", - "@tanstack/react-virtual": "^3.14.6", + "@tanstack/react-virtual": "^3.14.13", "bpm-detective": "^2.0.5", "cn": "0.2.4", "dockview-react": "8.3.1", diff --git a/packages/studio/src/player/components/useTimelineVirtualRows.test.tsx b/packages/studio/src/player/components/useTimelineVirtualRows.test.tsx index c0be0a8ea5..41bf5c156c 100644 --- a/packages/studio/src/player/components/useTimelineVirtualRows.test.tsx +++ b/packages/studio/src/player/components/useTimelineVirtualRows.test.tsx @@ -2,6 +2,12 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; +import { + elementScroll, + observeElementOffset, + observeElementRect, + Virtualizer, +} from "@tanstack/react-virtual"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { createTimelineRowGeometry, RULER_H, TRACKS_TOP_PAD } from "./timelineLayout"; import { extractTimelineVirtualRowRange, useTimelineVirtualRows } from "./useTimelineVirtualRows"; @@ -203,4 +209,30 @@ describe("useTimelineVirtualRows", () => { expect(rows.some((row) => row.index === 500)).toBe(false); act(() => root.unmount()); }); + + // A scroll-end timer left running fires into the torn-down test window ("window is not defined"). + it("gets no virtualizer change after unmount from a scroll just before it", async () => { + const scroll = createScrollElement(0); + let changesAfterUnmount = 0; + let unmounted = false; + const virtualizer = new Virtualizer({ + count: 100, + getScrollElement: () => scroll, + estimateSize: () => 48, + scrollToFn: elementScroll, + observeElementRect, + observeElementOffset, + onChange: () => { + if (unmounted) changesAfterUnmount++; + }, + }); + const unmount = virtualizer._didMount(); + virtualizer._willUpdate(); + scroll.dispatchEvent(new Event("scroll")); + unmount(); + unmounted = true; + + await new Promise((settle) => setTimeout(settle, 300)); + expect(changesAfterUnmount).toBe(0); + }); }); From ca42d8e8c16ccabdb26c827d6b395c01a15ee2c6 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 12:11:49 -0700 Subject: [PATCH 4/5] ci: remove the temporary history prune probe --- .github/workflows/tmp-prune-probe.yml | 40 --------------------------- 1 file changed, 40 deletions(-) delete mode 100644 .github/workflows/tmp-prune-probe.yml diff --git a/.github/workflows/tmp-prune-probe.yml b/.github/workflows/tmp-prune-probe.yml deleted file mode 100644 index 0b8155e7e9..0000000000 --- a/.github/workflows/tmp-prune-probe.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: tmp prune probe -on: pull_request -jobs: - probe: - runs-on: ubuntu-latest - timeout-minutes: 50 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 22 - - run: bash scripts/ci/install-workspace-dependencies.sh - - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build && bun run --cwd packages/core build - - name: kernel and mtime probe - run: | - uname -r - node -e ' - const fs = require("fs"); const f = "/tmp/mtime-probe"; fs.writeFileSync(f, "0"); - for (const statFirst of [false, true]) { - let ahead = 0, max = 0; const n = 20000; - for (let i = 0; i < n; i++) { - if (statFirst) fs.statSync(f); - fs.writeFileSync(f, String(i)); const now = Date.now(); const m = fs.statSync(f).mtimeMs; - if (m > now) { ahead++; max = Math.max(max, m - now); } - } - console.log(`statFirst=${statFirst}: ${ahead}/${n} writes have mtime after Date.now(), max ${max.toFixed(3)} ms`); - }' - - name: 200 runs of main's test and of the fixed test, 4 workers each - working-directory: packages/studio-server - run: | - git show origin/main:packages/studio-server/src/history/pruneHistories.test.ts > src/history/pruneHistoriesMain.test.ts - loop() { - file=$1; seq 4 | xargs -P4 -I{} bash -c ' - f=0; for i in $(seq 50); do npx vitest run '"$file"' > /tmp/out-{}-$i 2>&1 || { f=$((f+1)); grep -m1 -a "×" /tmp/out-{}-$i; }; done; echo "worker {} failed $f/50"' - } - echo "== main's test"; loop src/history/pruneHistoriesMain.test.ts - echo "== fixed test"; loop src/history/pruneHistories.test.ts From 57d61f2c2a8c58f01f14cfa5392a72e5fc0812ad Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 12:50:13 -0700 Subject: [PATCH 5/5] test(studio): check the virtualizer teardown with a spy and a 200 ms wait --- .../components/useTimelineVirtualRows.test.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/studio/src/player/components/useTimelineVirtualRows.test.tsx b/packages/studio/src/player/components/useTimelineVirtualRows.test.tsx index 41bf5c156c..e4d8256cef 100644 --- a/packages/studio/src/player/components/useTimelineVirtualRows.test.tsx +++ b/packages/studio/src/player/components/useTimelineVirtualRows.test.tsx @@ -8,7 +8,7 @@ import { observeElementRect, Virtualizer, } from "@tanstack/react-virtual"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { createTimelineRowGeometry, RULER_H, TRACKS_TOP_PAD } from "./timelineLayout"; import { extractTimelineVirtualRowRange, useTimelineVirtualRows } from "./useTimelineVirtualRows"; import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; @@ -213,8 +213,7 @@ describe("useTimelineVirtualRows", () => { // A scroll-end timer left running fires into the torn-down test window ("window is not defined"). it("gets no virtualizer change after unmount from a scroll just before it", async () => { const scroll = createScrollElement(0); - let changesAfterUnmount = 0; - let unmounted = false; + const onChange = vi.fn(); const virtualizer = new Virtualizer({ count: 100, getScrollElement: () => scroll, @@ -222,17 +221,15 @@ describe("useTimelineVirtualRows", () => { scrollToFn: elementScroll, observeElementRect, observeElementOffset, - onChange: () => { - if (unmounted) changesAfterUnmount++; - }, + onChange, }); const unmount = virtualizer._didMount(); virtualizer._willUpdate(); scroll.dispatchEvent(new Event("scroll")); unmount(); - unmounted = true; + onChange.mockClear(); - await new Promise((settle) => setTimeout(settle, 300)); - expect(changesAfterUnmount).toBe(0); + await new Promise((settle) => setTimeout(settle, 200)); + expect(onChange).not.toHaveBeenCalled(); }); });