From c3bdbc8eecc08204b62b86657e01a742860e9a26 Mon Sep 17 00:00:00 2001 From: jp-knj Date: Wed, 28 Oct 2026 15:32:45 +0900 Subject: [PATCH 1/2] test(indexed-db): resume retirement oracle cuts at native events The retirement oracle waited for native admission with vi.waitFor. The condition was false on the synchronous first check and true one macrotask later, so 1233 of 2655 waits slept the full 50ms default interval. That made 64s of the file's 64s test time (23s CPU). observeTransactions now exposes until(ready), which resumes at the native admission or outcome event that makes the cut true. The file runs in 2.3s. Resuming at admission removed an accidental 50ms quiescence before the mixed matrix's "active caller still pending" check. A success-before-commit mutant survived 252 mixed cells under the earlier cut alone. The mixed matrix now records native status at caller settlement, as the paired matrix already does, so caller success requires a native commit regardless of when the test resumes. --- packages/db/tests/indexed-db/idb-driver.ts | 21 ++++++++ .../indexed-db/retirement-oracle.test.ts | 51 ++++++++++++------- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/packages/db/tests/indexed-db/idb-driver.ts b/packages/db/tests/indexed-db/idb-driver.ts index dd80525a70..51606c3df7 100644 --- a/packages/db/tests/indexed-db/idb-driver.ts +++ b/packages/db/tests/indexed-db/idb-driver.ts @@ -38,12 +38,26 @@ export function holdStore(db: IDBDatabase, stores = ['items', '_versions']) { } } +/** + * Records native transaction admission and outcome. `until` resumes a test at + * the native event that makes its condition true, so a schedule cut depends on + * provider events rather than on elapsed time or a polling interval. + */ export function observeTransactions(db: IDBDatabase) { const entries: Array<{ mode: IDBTransactionMode status: 'pending' | 'complete' | 'abort' transaction: IDBTransaction }> = [] + const waiters = new Set<{ ready: () => boolean; resolve: () => void }>() + function notify() { + for (const waiter of waiters) { + if (waiter.ready()) { + waiters.delete(waiter) + waiter.resolve() + } + } + } const original = db.transaction db.transaction = function (...args) { const transaction = original.apply(this, args) @@ -55,14 +69,21 @@ export function observeTransactions(db: IDBDatabase) { entries.push(entry) transaction.addEventListener('complete', () => { entry.status = 'complete' + notify() }) transaction.addEventListener('abort', () => { entry.status = 'abort' + notify() }) + notify() return transaction } return { entries, + until: (ready: () => boolean) => + ready() + ? Promise.resolve() + : new Promise((resolve) => waiters.add({ ready, resolve })), restore: () => { db.transaction = original }, diff --git a/packages/db/tests/indexed-db/retirement-oracle.test.ts b/packages/db/tests/indexed-db/retirement-oracle.test.ts index 795eac8a2f..ddfbfd90b0 100644 --- a/packages/db/tests/indexed-db/retirement-oracle.test.ts +++ b/packages/db/tests/indexed-db/retirement-oracle.test.ts @@ -14,6 +14,8 @@ * Additional histories put closure before admission, between native commit and * confirmation, after sync acceptance, and during initial/replacement/key reads. * Native gates issue real requests; no IndexedDB transaction awaits a test gate. + * Each schedule cut resumes at the native admission or outcome event that + * establishes it, not after a polling interval. * These finite schedules cover the semantic cuts, not every TLC interleaving. */ import { forceCloseDatabase } from 'fake-indexeddb' @@ -185,8 +187,8 @@ describe('admitted native writes retain their actual outcome after closure', () return sorted(subjects[slot]!.base.values()) }, ) - await vi.waitFor(() => - expect(native.entries.length).toBe(callers.filter(Boolean).length), + await native.until( + () => native.entries.length === callers.filter(Boolean).length, ) } expect( @@ -390,19 +392,15 @@ for (const reason of reasons) { h.disposers.push(() => native.restore()) const loading = read === 'initial' ? observe(c.preload()) : undefined while (Channel.pending.length) Channel.dispatch() - await vi.waitFor(() => - expect( - native.entries.some((entry) => entry.mode === 'readonly'), - ).toBe(true), + await native.until(() => + native.entries.some((entry) => entry.mode === 'readonly'), ) await close(h, reason) closed(c, statuses) await gate.release() await Channel.drain() - await vi.waitFor(() => - expect( - native.entries.every((entry) => entry.status === 'complete'), - ).toBe(true), + await native.until(() => + native.entries.every((entry) => entry.status === 'complete'), ) await loading?.done if (loading) expect(loading.result.state).toBe('rejected') @@ -566,19 +564,28 @@ it.each(mixedCells)( h.disposers.push(() => gate.release()) await gate.started const beforeAdmission = native.entries.length + // At most one operation is active, so its native transaction is the first + // one admitted after the gate. Its caller settlement is checked against + // that native outcome at the settlement itself, independent of when the + // test resumes after admission. + const nativeAtCaller: Array = [] for (const slot of order) { const op = operations[slot]! if (op.prefix === 'active' || op.prefix === 'deciding') callers[slot] = observe( start(subjects[slot]!, op.kind, slot + 1), - capture(slot), + () => { + nativeAtCaller[slot] = + native.entries[beforeAdmission]?.status ?? 'notAdmitted' + return capture(slot)() + }, ) } const activeCount = operations.filter( (op) => op.prefix === 'active', ).length - await vi.waitFor(() => - expect(native.entries.length).toBe(beforeAdmission + activeCount), + await native.until( + () => native.entries.length === beforeAdmission + activeCount, ) for (const slot of [0, 1]) expect(callers[slot]!.result.state).toBe( @@ -615,6 +622,16 @@ it.each(mixedCells)( expect(callers[slot]!.result.state).toBe( success ? 'fulfilled' : 'rejected', ) + // As in the paired matrix, a rejection may precede the abort event. + if (op.prefix === 'active' && success) + expect(nativeAtCaller[slot], 'native commit at caller success').toBe( + 'complete', + ) + else if (op.prefix === 'active') + expect( + nativeAtCaller[slot], + 'rejected caller cannot report a commit', + ).not.toBe('complete') const rows = success ? apply(initial, op.kind, slot + 1) : initial assertRows( callers[slot]!.snapshot()!, @@ -697,10 +714,8 @@ for (const reason of reasons) const caller = observe(c.insert(row).isPersisted.promise, () => sorted(c.base.values()), ) - await vi.waitFor(() => - expect(native.entries.some((entry) => entry.mode === 'readwrite')).toBe( - true, - ), + await native.until(() => + native.entries.some((entry) => entry.mode === 'readwrite'), ) const inspector = await close(h, reason) await gate.release() @@ -805,7 +820,7 @@ for (const phase of ['idle', 'ready', 'loading', ...kinds] as const) { ? undefined : observe(start(c, phase, 1)) if (pending) - await vi.waitFor(() => expect(native.entries.length).toBeGreaterThan(0)) + await native.until(() => native.entries.length > 0) const terminated = new Promise((resolve) => h.db.db.addEventListener('close', () => resolve(), { once: true }), ) From ae3798b6b1a25c56999865d6aac159490b0f6eac Mon Sep 17 00:00:00 2001 From: jp-knj Date: Fri, 30 Oct 2026 10:25:09 +0900 Subject: [PATCH 2/2] test(indexed-db): bound native waits so failures release the harness Reject unreachable native cuts after the same one-second bound as the old vi.waitFor calls. Remove expired waiters and clear fulfilled waiters' timers. Five driver tests exercise immediate readiness, admission, complete, abort, and an unreachable cut followed by later events and harness cleanup. Review evidence for this commit, after merging upstream/main at 9cfa11fc7: Law: Closure preserves admitted native outcomes, and caller success requires native commit. Authority: docs/collections/indexed-db-collection.md, Connection Lifecycle and Blocked Operations, and the retirement owner in the coverage map. Driver failure must release resources (oracle rubric ORC-010). Reach: The unchanged bounded matrices run in 1592 retirement oracle tests; 31 scenarios moved by upstream run in retirement.test.ts. Five new driver tests cover the waiter's event and deadline boundaries. No generated property or new product law is introduced, so ORC-015 does not require a reach counter. RED: On parent a97b5d02d, pnpm exec vitest run --coverage.enabled=false tests/indexed-db/idb-driver.test.ts -t "rejects an unreachable cut" fails at "native cut rejects at its own deadline": the waiter is still pending after 1000ms of fake time. This is an assertion failure, not a suite timeout. GREEN: pnpm exec vitest run --coverage.enabled=false tests/indexed-db passes 2478 tests in 20 files. A final run selecting idb-driver.test.ts, retirement-oracle.test.ts and retirement.test.ts passes all 1628 tests. ESLint, Prettier, and pnpm lint:changed with those four changed test/support paths pass. Mutant: Resolving executeTransaction's completed promise before the native complete event fails 827 oracle tests (including 672 mixed-prefix tests) and three moved scenarios. Failures are assertions, including "native commit at caller success", not setup failures or timeouts. The temporary production mutation was removed; production is identical to upstream/main. Limits: fake-indexeddb 6.2.5, Node 24.8.0 and Vitest 4.1.12. No browser run. These finite schedules do not cover every native interleaving. The default pnpm lint:changed also reports one inherited removed-budget notification: seed 271828 in the OP-SQLite replay-configuration unit test. Upstream #2278 moved that fixed-value assertion into op-sqlite-driver.test.ts; it is not a removed generated campaign. This branch does not modify that package or its budgets. All oracle lint rules pass. --- .../db/tests/indexed-db/idb-driver.test.ts | 88 +++++++++++++++++++ packages/db/tests/indexed-db/idb-driver.ts | 18 +++- 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 packages/db/tests/indexed-db/idb-driver.test.ts diff --git a/packages/db/tests/indexed-db/idb-driver.test.ts b/packages/db/tests/indexed-db/idb-driver.test.ts new file mode 100644 index 0000000000..cc17675a89 --- /dev/null +++ b/packages/db/tests/indexed-db/idb-driver.test.ts @@ -0,0 +1,88 @@ +// These tests check the native waiter's timing and cleanup mechanics, not +// product results. Retirement laws stay in retirement-oracle.test.ts. +import { expect, it, vi } from 'vitest' +import { completed, withHarness } from './harness' +import { observeTransactions } from './idb-driver' + +it('resolves an already established native cut without scheduling a timer', async () => { + await withHarness(async (h) => { + const native = observeTransactions(h.db.db) + h.disposers.push(() => native.restore()) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + await native.until(() => true) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) + +it.each(['admission', 'complete', 'abort'] as const)( + 'resumes at native %s and clears the timeout', + async (cut) => { + await withHarness(async (h) => { + const native = observeTransactions(h.db.db) + h.disposers.push(() => native.restore()) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const waiting = native.until(() => + cut === 'admission' + ? native.entries.length === 1 + : native.entries[0]?.status === cut, + ) + const transaction = h.db.db.transaction('items') + const finished = completed(transaction).catch(() => undefined) + if (cut === 'abort') transaction.abort() + await waiting + expect(native.entries[0]?.status).toBe( + cut === 'admission' ? 'pending' : cut, + ) + expect(vi.getTimerCount()).toBe(0) + await finished + } finally { + vi.useRealTimers() + } + }) + }, +) + +it('rejects an unreachable cut, removes its waiter and lets the harness clean up', async () => { + const originalChannel = globalThis.BroadcastChannel + let disposed = false + let waiterRemoved = false + let timersRemaining: number | undefined + const failure = await withHarness(async (h) => { + const native = observeTransactions(h.db.db) + h.disposers.push(() => { + native.restore() + disposed = true + }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const ready = vi.fn(() => false) + const waiting = native.until(ready).catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(1000) + // Record a broken, unbounded waiter without hanging the test itself. + const error = await Promise.race([waiting, Promise.resolve('pending')]) + if (error === 'pending') return + const calls = ready.mock.calls.length + await completed(h.db.db.transaction('items')) + waiterRemoved = ready.mock.calls.length === calls + timersRemaining = vi.getTimerCount() + throw error + } finally { + vi.useRealTimers() + } + }).catch((error: unknown) => error) + expect(failure, 'native cut rejects at its own deadline').toBeInstanceOf( + Error, + ) + expect(failure).toMatchObject({ + message: 'Timed out waiting for native transaction state', + }) + expect(waiterRemoved).toBe(true) + expect(timersRemaining).toBe(0) + expect(disposed).toBe(true) + expect(globalThis.BroadcastChannel).toBe(originalChannel) +}) diff --git a/packages/db/tests/indexed-db/idb-driver.ts b/packages/db/tests/indexed-db/idb-driver.ts index 51606c3df7..ffc8e442fc 100644 --- a/packages/db/tests/indexed-db/idb-driver.ts +++ b/packages/db/tests/indexed-db/idb-driver.ts @@ -42,6 +42,7 @@ export function holdStore(db: IDBDatabase, stores = ['items', '_versions']) { * Records native transaction admission and outcome. `until` resumes a test at * the native event that makes its condition true, so a schedule cut depends on * provider events rather than on elapsed time or a polling interval. + * An unreachable cut rejects after one second so the harness can clean up. */ export function observeTransactions(db: IDBDatabase) { const entries: Array<{ @@ -83,7 +84,22 @@ export function observeTransactions(db: IDBDatabase) { until: (ready: () => boolean) => ready() ? Promise.resolve() - : new Promise((resolve) => waiters.add({ ready, resolve })), + : new Promise((resolve, reject) => { + const waiter = { + ready, + resolve: () => { + clearTimeout(timer) + resolve() + }, + } + const timer = setTimeout(() => { + waiters.delete(waiter) + reject( + new Error('Timed out waiting for native transaction state'), + ) + }, 1000) + waiters.add(waiter) + }), restore: () => { db.transaction = original },