diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index ce4cd5e0b..311cc4ba8 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -35,6 +35,7 @@ import { createCancellationBenchmarkSummary, createCancellationIterationResult, } from './m3u-refresh-cancellation-report'; +import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight'; import { installMainCapture, readMainCaptureStatus, @@ -147,10 +148,11 @@ async function runIteration( server: SyntheticServer ): Promise { const iterationDirectory = join(config.outputDirectory, definition.runId); + await assertPerformanceArtifactCapacity(iterationDirectory); + await mkdir(iterationDirectory, { recursive: false }); const dataDirectory = await mkdtemp( join(tmpdir(), 'iptvnator-m3u-performance-') ); - await mkdir(iterationDirectory, { recursive: false }); let app: LaunchedElectronApp | null = null; let rendererConsoleListener: ((message: ConsoleMessage) => void) | null = null; @@ -363,6 +365,7 @@ async function resolveConfiguration(): Promise { } const outputDirectory = join(outputRoot, variant); await assertMissing(outputDirectory); + await assertPerformanceArtifactCapacity(outputDirectory); await mkdir(outputDirectory, { recursive: true }); return Object.freeze({ electronVersion: electronPackage.version, diff --git a/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.spec.ts b/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.spec.ts new file mode 100644 index 000000000..4ca080932 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.spec.ts @@ -0,0 +1,124 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { dirname, resolve } from 'node:path'; + +import { + assertPerformanceArtifactCapacity, + MIN_PERFORMANCE_ARTIFACT_FREE_BYTES, +} from './performance-artifact-preflight'; + +test('checks the nearest existing ancestor using available blocks', async () => { + const requestedPath = resolve( + '/workspace/dist/performance/2026-07-26/baseline' + ); + const checkedPaths: string[] = []; + const existingAncestor = dirname(dirname(requestedPath)); + + const result = await assertPerformanceArtifactCapacity(requestedPath, { + statfs: async (path) => { + checkedPaths.push(path); + if (path !== existingAncestor) { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + } + return { + bavail: 4_194_304n, + bsize: 1_024n, + }; + }, + }); + + assert.deepEqual(checkedPaths, [ + requestedPath, + dirname(requestedPath), + existingAncestor, + ]); + assert.deepEqual(result, { + availableBytes: 4_294_967_296n, + checkedPath: existingAncestor, + minimumBytes: MIN_PERFORMANCE_ARTIFACT_FREE_BYTES, + }); +}); + +test('accepts exactly two GiB of available capacity', async () => { + const result = await assertPerformanceArtifactCapacity('/existing', { + statfs: async () => ({ + bavail: 2_097_152n, + bsize: 1_024n, + }), + }); + + assert.equal(result.availableBytes, MIN_PERFORMANCE_ARTIFACT_FREE_BYTES); +}); + +test('fails before artifact creation when available capacity is below two GiB', async () => { + await assert.rejects( + assertPerformanceArtifactCapacity('/existing', { + statfs: async () => ({ + bavail: 2_097_151n, + bsize: 1_024n, + }), + }), + /requires at least 2147483648 available bytes.*2147482624/ + ); +}); + +test('does not substitute free blocks that are unavailable to the process', async () => { + await assert.rejects( + assertPerformanceArtifactCapacity('/existing', { + statfs: async () => ({ + bavail: 1n, + bfree: 9_999_999_999n, + bsize: 1_024n, + }), + }), + /performance-artifact-space-insufficient/ + ); +}); + +test('does not hide filesystem errors other than a missing path', async () => { + const denied = Object.assign(new Error('denied'), { code: 'EACCES' }); + let calls = 0; + + await assert.rejects( + assertPerformanceArtifactCapacity('/denied/child', { + statfs: async () => { + calls += 1; + throw denied; + }, + }), + denied + ); + assert.equal(calls, 1); +}); + +test('the benchmark preflights capacity before creating output directories', () => { + const source = readFileSync( + new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url), + 'utf8' + ); + const runIteration = source.slice( + source.indexOf('async function runIteration('), + source.indexOf('async function seedPlaylist(') + ); + const resolveConfiguration = source.slice( + source.indexOf('async function resolveConfiguration('), + source.indexOf('function readGitSourceState(') + ); + + for (const [name, section] of [ + ['iteration', runIteration], + ['variant', resolveConfiguration], + ] as const) { + const preflight = section.indexOf( + 'await assertPerformanceArtifactCapacity(' + ); + const createDirectory = section.indexOf('await mkdir('); + assert.ok(preflight >= 0, `${name} preflight is missing`); + assert.ok( + preflight < createDirectory, + `${name} preflight must run before mkdir` + ); + } +}); diff --git a/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.ts b/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.ts new file mode 100644 index 000000000..7c0433854 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.ts @@ -0,0 +1,76 @@ +import { statfs } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +export const MIN_PERFORMANCE_ARTIFACT_FREE_BYTES = + 2n * 1_024n * 1_024n * 1_024n; + +interface FileSystemCapacity { + readonly bavail: bigint; + readonly bsize: bigint; +} + +interface PerformanceArtifactPreflightDependencies { + readonly statfs?: (path: string) => Promise; +} + +export interface PerformanceArtifactCapacity { + readonly availableBytes: bigint; + readonly checkedPath: string; + readonly minimumBytes: bigint; +} + +async function readFileSystemCapacity( + path: string +): Promise { + const capacity = await statfs(path, { bigint: true }); + return { + bavail: capacity.bavail, + bsize: capacity.bsize, + }; +} + +function isMissingPath(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ); +} + +export async function assertPerformanceArtifactCapacity( + targetPath: string, + dependencies: PerformanceArtifactPreflightDependencies = {} +): Promise { + const readCapacity = dependencies.statfs ?? readFileSystemCapacity; + let checkedPath = resolve(targetPath); + + while (true) { + try { + const capacity = await readCapacity(checkedPath); + const availableBytes = capacity.bavail * capacity.bsize; + if (availableBytes < MIN_PERFORMANCE_ARTIFACT_FREE_BYTES) { + throw new Error( + `performance-artifact-space-insufficient: benchmark requires at least ${MIN_PERFORMANCE_ARTIFACT_FREE_BYTES} available bytes; ${availableBytes} available at ${checkedPath}` + ); + } + return Object.freeze({ + availableBytes, + checkedPath, + minimumBytes: MIN_PERFORMANCE_ARTIFACT_FREE_BYTES, + }); + } catch (error) { + if (!isMissingPath(error)) { + throw error; + } + const parent = dirname(checkedPath); + if (parent === checkedPath) { + throw new Error( + `performance-artifact-space-unavailable: no existing ancestor for ${targetPath}`, + { cause: error } + ); + } + checkedPath = parent; + } + } +}