mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
Merge remote-tracking branch 'origin/master' into claude/vod-multi-source-49a8ba
This commit is contained in:
commit
26b820cc29
60 files changed
+2772
-160
No files matched your search
@@ -81,7 +81,13 @@ type ElectronFixtures = {
|
||||
};
|
||||
|
||||
export type LaunchElectronAppOptions = {
|
||||
/** Electron/Chromium switches — they must precede the entry point. */
|
||||
args?: readonly string[];
|
||||
/**
|
||||
* Arguments for the app itself, appended *after* the entry point so they
|
||||
* land in `process.argv` the way a file-association launch does.
|
||||
*/
|
||||
appArgs?: readonly string[];
|
||||
environmentInheritance?: 'all' | 'runtime-only';
|
||||
env?: Record<string, string | undefined>;
|
||||
omitEnvKeys?: readonly string[];
|
||||
@@ -184,10 +190,14 @@ export { expect };
|
||||
*
|
||||
* Headless Linux CI has no usable sandbox or GPU, and an Electron started
|
||||
* without these flags there dies on a signal instead of running — so any
|
||||
* helper that spawns the app itself has to use the same list.
|
||||
* helper that spawns the app itself has to use the same list. `appArgs` land
|
||||
* after the entry point, which is where the OS puts an opened file's path.
|
||||
*/
|
||||
function buildElectronLaunchArgs(extraArgs: readonly string[] = []): string[] {
|
||||
const args = [...extraArgs, electronMainPath];
|
||||
function buildElectronLaunchArgs(
|
||||
extraArgs: readonly string[] = [],
|
||||
appArgs: readonly string[] = []
|
||||
): string[] {
|
||||
const args = [...extraArgs, electronMainPath, ...appArgs];
|
||||
|
||||
if (process.platform === 'linux' && process.env['CI']) {
|
||||
args.unshift('--no-sandbox', '--disable-gpu');
|
||||
@@ -207,7 +217,7 @@ export async function launchElectronApp(
|
||||
}
|
||||
assertPackagedRendererBuildIsElectronSafe();
|
||||
|
||||
const args = buildElectronLaunchArgs(options.args);
|
||||
const args = buildElectronLaunchArgs(options.args, options.appArgs);
|
||||
|
||||
const electronApp = await electron.launch({
|
||||
args,
|
||||
@@ -476,11 +486,16 @@ function attachElectronProcessDiagnostics(
|
||||
*/
|
||||
export async function launchCompetingElectronInstance(
|
||||
dataDir: string,
|
||||
timeoutMs = 30000
|
||||
options: {
|
||||
/** Arguments for the app itself, e.g. a playlist path to open. */
|
||||
appArgs?: readonly string[];
|
||||
timeoutMs?: number;
|
||||
} = {}
|
||||
): Promise<CompetingElectronInstanceResult> {
|
||||
const { appArgs = [], timeoutMs = 30000 } = options;
|
||||
// In a Node context the `electron` package resolves to its binary path.
|
||||
const electronBinaryPath = require('electron') as unknown as string;
|
||||
const child = spawn(electronBinaryPath, buildElectronLaunchArgs(), {
|
||||
const child = spawn(electronBinaryPath, buildElectronLaunchArgs([], appArgs), {
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_IS_DEV: '0',
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { copyFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchCompetingElectronInstance,
|
||||
launchElectronApp,
|
||||
m3uFixturePath,
|
||||
openSources,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
@@ -24,4 +28,80 @@ test.describe('Electron Native Playlist Import', () => {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@m3u @electron opens a playlist passed on the command line', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
// Mirrors a file-association double-click on Windows/Linux: the path
|
||||
// is the app's own argv, resolved and queued in the main process
|
||||
// before the renderer exists.
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
appArgs: [m3uFixturePath],
|
||||
});
|
||||
|
||||
try {
|
||||
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('channel-item')
|
||||
).toHaveCount(4);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@m3u @electron opens every playlist of a multi-file selection', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
// Selecting several playlists in a file manager is a single launch
|
||||
// with one argument per file, because the generated desktop entry
|
||||
// ends in `%U`. Stopping at the first would drop the rest.
|
||||
const secondPlaylist = join(dataDir, 'second-selection.m3u');
|
||||
copyFileSync(m3uFixturePath, secondPlaylist);
|
||||
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
appArgs: [m3uFixturePath, secondPlaylist],
|
||||
});
|
||||
|
||||
try {
|
||||
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await openSources(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.locator('app-playlist-item')
|
||||
).toHaveCount(2, { timeout: 30000 });
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@m3u @electron opens a playlist handed over by a second launch', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
// The second process never gets a window: the single-instance guard
|
||||
// makes it quit and forwards its argv to the running app.
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const competing = await launchCompetingElectronInstance(dataDir, {
|
||||
appArgs: [m3uFixturePath],
|
||||
});
|
||||
|
||||
expect(
|
||||
competing.timedOut,
|
||||
`Competing instance did not exit. stderr: ${competing.stderr}`
|
||||
).toBe(false);
|
||||
|
||||
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('channel-item')
|
||||
).toHaveCount(4);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,11 @@ import {
|
||||
createWorkerTerminationGenerationApi,
|
||||
type WorkerTerminationGenerationApi,
|
||||
} from './worker-termination-generation';
|
||||
import {
|
||||
assertWorkerSampleCaptureValid,
|
||||
createWorkerSampleDeadlineApi,
|
||||
type WorkerSampleDeadlineApi,
|
||||
} from './worker-sample-deadline';
|
||||
import {
|
||||
createRendererProcessRssCaptureApi,
|
||||
type RendererProcessRssCaptureApi,
|
||||
@@ -230,6 +235,9 @@ export async function installMainCapture(
|
||||
performanceTimelineMergeApiFactorySource:
|
||||
createPerformanceTimelineMergeApi.toString(),
|
||||
stateKey: MAIN_CAPTURE_STATE_KEY,
|
||||
workerSampleDeadlineApiFactorySource:
|
||||
createWorkerSampleDeadlineApi.toString(),
|
||||
workerSampleDeadlineMs: 5_000,
|
||||
workerTerminationGenerationApiFactorySource:
|
||||
createWorkerTerminationGenerationApi.toString(),
|
||||
xtreamIpcMarkerCaptureFactorySource:
|
||||
@@ -325,7 +333,9 @@ export async function installMainCapture(
|
||||
resolvedProfileHandle: CpuProfileHandle | null;
|
||||
requestPerformance: WorkerRequestPerformance[];
|
||||
responseEpochMs: number | null;
|
||||
sampleKey: object;
|
||||
samplePromise: Promise<void> | null;
|
||||
sampleTimedOut: boolean;
|
||||
samplingStarted: boolean;
|
||||
snapshotPath: string | null;
|
||||
terminatedEpochMs: number | null;
|
||||
@@ -399,6 +409,9 @@ export async function installMainCapture(
|
||||
restoreFactory<WorkerTerminationGenerationApi>(
|
||||
input.workerTerminationGenerationApiFactorySource
|
||||
);
|
||||
const workerSampleDeadlineApi = restoreFactory<WorkerSampleDeadlineApi>(
|
||||
input.workerSampleDeadlineApiFactorySource
|
||||
);
|
||||
const diagnosticsPerformancePhaseEventParserFactory = restoreFunction<
|
||||
typeof createDiagnosticsPerformancePhaseEventParser
|
||||
>(input.diagnosticsPerformancePhaseEventParserFactorySource);
|
||||
@@ -467,6 +480,7 @@ export async function installMainCapture(
|
||||
active: false,
|
||||
captureGeneration: 0,
|
||||
captureInvalidReasons: [] as string[],
|
||||
capturePoisonedReason: null as string | null,
|
||||
captureOptions: null as MainCaptureStartOptions | null,
|
||||
captureStartedEpochMs: null as number | null,
|
||||
cpuStart: null as NodeJS.CpuUsage | null,
|
||||
@@ -735,7 +749,9 @@ export async function installMainCapture(
|
||||
resolvedProfileHandle: null,
|
||||
requestPerformance: [],
|
||||
responseEpochMs: null,
|
||||
sampleKey: {},
|
||||
samplePromise: null,
|
||||
sampleTimedOut: false,
|
||||
samplingStarted: false,
|
||||
snapshotPath: null,
|
||||
terminatedEpochMs: null,
|
||||
@@ -864,59 +880,91 @@ export async function installMainCapture(
|
||||
return record;
|
||||
};
|
||||
const sampleWorker = (record: WorkerRecord): Promise<void> => {
|
||||
if (record.finalized) {
|
||||
if (record.finalized || record.sampleTimedOut) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (record.samplePromise) {
|
||||
return record.samplePromise;
|
||||
}
|
||||
record.samplePromise = (async () => {
|
||||
try {
|
||||
const stats = await record.worker.getHeapStatistics?.();
|
||||
if (stats) {
|
||||
const heapUsedSample = stats.used_heap_size;
|
||||
if (
|
||||
typeof heapUsedSample === 'number' &&
|
||||
Number.isFinite(heapUsedSample) &&
|
||||
heapUsedSample >= 0
|
||||
) {
|
||||
record.heapUsedSampleCount += 1;
|
||||
record.heapPeak = Math.max(
|
||||
record.heapPeak,
|
||||
heapUsedSample
|
||||
);
|
||||
const captureGeneration = record.captureGeneration;
|
||||
const sampleKey = {};
|
||||
record.sampleKey = sampleKey;
|
||||
const boundedSample = workerSampleDeadlineApi
|
||||
.run({
|
||||
apply: ({ cpu, elu, stats }) => {
|
||||
if (stats) {
|
||||
const heapUsedSample = stats.used_heap_size;
|
||||
if (
|
||||
typeof heapUsedSample === 'number' &&
|
||||
Number.isFinite(heapUsedSample) &&
|
||||
heapUsedSample >= 0
|
||||
) {
|
||||
record.heapUsedSampleCount += 1;
|
||||
record.heapPeak = Math.max(
|
||||
record.heapPeak,
|
||||
heapUsedSample
|
||||
);
|
||||
}
|
||||
const externalMemorySample = stats.external_memory;
|
||||
if (
|
||||
typeof externalMemorySample === 'number' &&
|
||||
Number.isFinite(externalMemorySample) &&
|
||||
externalMemorySample >= 0
|
||||
) {
|
||||
record.externalMemorySampleCount += 1;
|
||||
record.externalPeak = Math.max(
|
||||
record.externalPeak,
|
||||
externalMemorySample
|
||||
);
|
||||
}
|
||||
}
|
||||
const externalMemorySample = stats.external_memory;
|
||||
if (
|
||||
typeof externalMemorySample === 'number' &&
|
||||
Number.isFinite(externalMemorySample) &&
|
||||
externalMemorySample >= 0
|
||||
) {
|
||||
record.externalMemorySampleCount += 1;
|
||||
record.externalPeak = Math.max(
|
||||
record.externalPeak,
|
||||
externalMemorySample
|
||||
);
|
||||
if (cpu) {
|
||||
record.cpuFirst ??= cpu;
|
||||
record.cpuLast = cpu;
|
||||
}
|
||||
if (elu) {
|
||||
record.elu = elu.utilization;
|
||||
}
|
||||
},
|
||||
capturedGeneration: captureGeneration,
|
||||
currentIdentity: () => ({
|
||||
captureGeneration: state.captureGeneration,
|
||||
recordGeneration: record.captureGeneration,
|
||||
sampleKey: record.sampleKey,
|
||||
}),
|
||||
onTimeout: () => {
|
||||
record.sampleTimedOut = true;
|
||||
state.capturePoisonedReason = 'worker-sample-timeout';
|
||||
invalidateCapture('worker-sample-timeout');
|
||||
recordTimeline({
|
||||
operationId: record.operationId ?? undefined,
|
||||
playlistId: record.playlistId ?? undefined,
|
||||
type: `${record.kind}-sample-timeout`,
|
||||
});
|
||||
},
|
||||
operation: async () => {
|
||||
const stats = await record.worker.getHeapStatistics?.();
|
||||
const cpu = await record.worker.cpuUsage?.();
|
||||
const elu =
|
||||
record.worker.performance?.eventLoopUtilization(
|
||||
record.eluStart ?? undefined
|
||||
) ?? null;
|
||||
return { cpu, elu, stats };
|
||||
},
|
||||
sampleKey,
|
||||
timeoutMs: input.workerSampleDeadlineMs,
|
||||
})
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
if (
|
||||
record.sampleKey === sampleKey &&
|
||||
record.samplePromise === boundedSample
|
||||
) {
|
||||
record.samplePromise = null;
|
||||
}
|
||||
const cpu = await record.worker.cpuUsage?.();
|
||||
if (cpu) {
|
||||
record.cpuFirst ??= cpu;
|
||||
record.cpuLast = cpu;
|
||||
}
|
||||
const elu = record.worker.performance?.eventLoopUtilization(
|
||||
record.eluStart ?? undefined
|
||||
);
|
||||
if (elu) {
|
||||
record.elu = elu.utilization;
|
||||
}
|
||||
} catch {
|
||||
// A one-shot worker may terminate between sampling calls.
|
||||
}
|
||||
})().finally(() => {
|
||||
record.samplePromise = null;
|
||||
});
|
||||
return record.samplePromise;
|
||||
});
|
||||
record.samplePromise = boundedSample;
|
||||
return boundedSample;
|
||||
};
|
||||
const resetWorkerForCapture = (record: WorkerRecord): void => {
|
||||
record.cancelPostedEpochMs = null;
|
||||
@@ -945,7 +993,9 @@ export async function installMainCapture(
|
||||
record.resolvedProfileHandle = null;
|
||||
record.requestPerformance = [];
|
||||
record.responseEpochMs = null;
|
||||
record.sampleKey = {};
|
||||
record.samplePromise = null;
|
||||
record.sampleTimedOut = false;
|
||||
record.samplingStarted = false;
|
||||
record.snapshotPath = null;
|
||||
record.terminatedEpochMs = null;
|
||||
@@ -1563,6 +1613,11 @@ export async function installMainCapture(
|
||||
const startCapture = async (
|
||||
options: MainCaptureStartOptions
|
||||
): Promise<void> => {
|
||||
if (state.capturePoisonedReason !== null) {
|
||||
throw new Error(
|
||||
`xtream-main-capture-poisoned:${state.capturePoisonedReason}`
|
||||
);
|
||||
}
|
||||
const captureStartedEpochMs = nowEpochMs();
|
||||
state.rendererWindowSession?.detach();
|
||||
state.rendererWindowSession = null;
|
||||
@@ -1641,6 +1696,11 @@ export async function installMainCapture(
|
||||
databaseRequestIdentityCapture.successMarkerCount(),
|
||||
}),
|
||||
start: async (options: MainCaptureStartOptions): Promise<void> => {
|
||||
if (state.capturePoisonedReason !== null) {
|
||||
throw new Error(
|
||||
`xtream-main-capture-poisoned:${state.capturePoisonedReason}`
|
||||
);
|
||||
}
|
||||
databaseWorkerPostGcCutoffApi.beginCapture();
|
||||
await startCapture(options);
|
||||
},
|
||||
@@ -1754,7 +1814,7 @@ export async function installMainCapture(
|
||||
);
|
||||
}
|
||||
await Promise.all(
|
||||
currentDatabaseRecords.map((record) =>
|
||||
currentWorkerRecords.map((record) =>
|
||||
joinFinalWorkerSample(record)
|
||||
)
|
||||
);
|
||||
@@ -1962,7 +2022,8 @@ export async function installMainCapture(
|
||||
? currentDatabaseRecords[0]
|
||||
: null;
|
||||
const nextCaptureUnavailableReason =
|
||||
cutoff.lateRequestCount > 0
|
||||
state.capturePoisonedReason ??
|
||||
(cutoff.lateRequestCount > 0
|
||||
? 'database-worker-activity-after-cutoff'
|
||||
: dbRequests.size > 0
|
||||
? 'database-worker-not-idle'
|
||||
@@ -1980,7 +2041,7 @@ export async function installMainCapture(
|
||||
null
|
||||
? (databaseRecord?.postGcHeapUnavailableReason ??
|
||||
'post-gc-capture-invalid')
|
||||
: null;
|
||||
: null);
|
||||
if (nextCaptureUnavailableReason === null) {
|
||||
databaseWorkerPostGcCutoffApi.rolloverCapture();
|
||||
await startCapture(nextOptions);
|
||||
@@ -2488,6 +2549,7 @@ export async function rolloverMainCapture(
|
||||
},
|
||||
{ options, stateKey: MAIN_CAPTURE_STATE_KEY }
|
||||
);
|
||||
assertWorkerSampleCaptureValid(transport.xtream.invalidReasons);
|
||||
if (transport.rollover === null) {
|
||||
throw new Error('main-capture-rollover-status-missing');
|
||||
}
|
||||
@@ -2512,6 +2574,7 @@ export async function stopMainCapture(
|
||||
},
|
||||
MAIN_CAPTURE_STATE_KEY
|
||||
);
|
||||
assertWorkerSampleCaptureValid(transport.xtream.invalidReasons);
|
||||
return selectMainCaptureGeneration(transport);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
interface WorkerSampleDeadlineModule {
|
||||
assertWorkerSampleCaptureValid?: (
|
||||
invalidReasons: readonly string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
const modulePromise = import(
|
||||
new URL('./worker-sample-deadline.ts', import.meta.url).href
|
||||
).then((module) => module as WorkerSampleDeadlineModule);
|
||||
|
||||
test('rejects a timed-out worker sample before returning plain capture metrics', async () => {
|
||||
const module = await modulePromise;
|
||||
const assertCaptureValid = module.assertWorkerSampleCaptureValid;
|
||||
assert.equal(typeof assertCaptureValid, 'function');
|
||||
|
||||
assert.doesNotThrow(() => assertCaptureValid?.([]));
|
||||
assert.throws(
|
||||
() => assertCaptureValid?.(['worker-sample-timeout']),
|
||||
/main-capture-worker-sample-timeout/
|
||||
);
|
||||
});
|
||||
|
||||
test('plain capture stop and rollover both enforce worker sample validity', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const rolloverStart = source.indexOf(
|
||||
'export async function rolloverMainCapture'
|
||||
);
|
||||
const stopStart = source.indexOf('export async function stopMainCapture');
|
||||
const xtreamRolloverStart = source.indexOf(
|
||||
'export async function rolloverXtreamMainCapture'
|
||||
);
|
||||
const rollover = source.slice(rolloverStart, stopStart);
|
||||
const stop = source.slice(stopStart, xtreamRolloverStart);
|
||||
|
||||
assert.match(
|
||||
rollover,
|
||||
/assertWorkerSampleCaptureValid\(transport\.xtream\.invalidReasons\)/
|
||||
);
|
||||
assert.match(
|
||||
stop,
|
||||
/assertWorkerSampleCaptureValid\(transport\.xtream\.invalidReasons\)/
|
||||
);
|
||||
});
|
||||
|
||||
test('capture stop settles final samples for every current worker', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const stopStart = source.indexOf('stop: async (');
|
||||
const stopEnd = source.indexOf(
|
||||
'xtreamStatus: (): XtreamMainCaptureStatus',
|
||||
stopStart
|
||||
);
|
||||
const stop = source.slice(stopStart, stopEnd);
|
||||
|
||||
assert.match(
|
||||
stop,
|
||||
/await Promise\.all\(\s*currentWorkerRecords\.map\(\(record\) =>\s*joinFinalWorkerSample\(record\)/
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
stop,
|
||||
/currentDatabaseRecords\.map\(\(record\) =>\s*joinFinalWorkerSample\(record\)/
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
interface WorkerSampleDeadlineIdentity {
|
||||
readonly captureGeneration: number;
|
||||
readonly recordGeneration: number | null;
|
||||
readonly sampleKey: object;
|
||||
}
|
||||
|
||||
type WorkerSampleDeadlineOutcome =
|
||||
| { readonly status: 'completed' }
|
||||
| { readonly error: unknown; readonly status: 'failed' }
|
||||
| { readonly status: 'stale' }
|
||||
| { readonly status: 'timed-out' };
|
||||
|
||||
interface WorkerSampleDeadlineApi {
|
||||
run<T>(input: {
|
||||
readonly apply: (value: T) => void;
|
||||
readonly capturedGeneration: number | null;
|
||||
readonly currentIdentity: () => WorkerSampleDeadlineIdentity;
|
||||
readonly onTimeout: () => void;
|
||||
readonly operation: () => Promise<T>;
|
||||
readonly sampleKey: object;
|
||||
readonly timers?: WorkerSampleDeadlineTimers;
|
||||
readonly timeoutMs: number;
|
||||
}): Promise<WorkerSampleDeadlineOutcome>;
|
||||
}
|
||||
|
||||
interface WorkerSampleDeadlineTimers {
|
||||
clearTimeout(handle: unknown): void;
|
||||
setTimeout(callback: () => void, timeoutMs: number): unknown;
|
||||
}
|
||||
|
||||
interface WorkerSampleDeadlineModule {
|
||||
createWorkerSampleDeadlineApi?: () => WorkerSampleDeadlineApi;
|
||||
}
|
||||
|
||||
const modulePromise = import(
|
||||
new URL('./worker-sample-deadline.ts', import.meta.url).href
|
||||
)
|
||||
.then((module) => module as WorkerSampleDeadlineModule)
|
||||
.catch(() => null);
|
||||
|
||||
function deferred<T>(): {
|
||||
readonly promise: Promise<T>;
|
||||
readonly resolve: (value: T) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function createScheduler(): WorkerSampleDeadlineTimers & {
|
||||
readonly activeCount: () => number;
|
||||
readonly fireNext: () => void;
|
||||
} {
|
||||
const tasks = new Map<unknown, () => void>();
|
||||
|
||||
return {
|
||||
activeCount: () => tasks.size,
|
||||
clearTimeout(handle: unknown): void {
|
||||
tasks.delete(handle);
|
||||
},
|
||||
fireNext(): void {
|
||||
const next = tasks.entries().next().value as
|
||||
[unknown, () => void] | undefined;
|
||||
assert.ok(next, 'expected one pending deadline');
|
||||
tasks.delete(next[0]);
|
||||
next[1]();
|
||||
},
|
||||
setTimeout(callback): object {
|
||||
const handle = {};
|
||||
tasks.set(handle, callback);
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function restoreSerializableApi(): Promise<WorkerSampleDeadlineApi> {
|
||||
const module = await modulePromise;
|
||||
assert.ok(module, 'worker sample deadline module must exist');
|
||||
const factory = module.createWorkerSampleDeadlineApi;
|
||||
assert.equal(typeof factory, 'function');
|
||||
|
||||
const source = factory.toString();
|
||||
assert.doesNotMatch(source, /__name/);
|
||||
const restoredFactory = Function(
|
||||
`"use strict"; return (${source});`
|
||||
)() as () => WorkerSampleDeadlineApi;
|
||||
return restoredFactory();
|
||||
}
|
||||
|
||||
test('applies a worker sample only while its generation and identity are current', async () => {
|
||||
const scheduler = createScheduler();
|
||||
const api = await restoreSerializableApi();
|
||||
const operation = deferred<number>();
|
||||
const sampleKey = {};
|
||||
const applied: number[] = [];
|
||||
|
||||
const outcomePromise = api.run({
|
||||
apply: (value) => applied.push(value),
|
||||
capturedGeneration: 7,
|
||||
currentIdentity: () => ({
|
||||
captureGeneration: 7,
|
||||
recordGeneration: 7,
|
||||
sampleKey,
|
||||
}),
|
||||
onTimeout: () => assert.fail('the operation completed in time'),
|
||||
operation: () => operation.promise,
|
||||
sampleKey,
|
||||
timers: scheduler,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
operation.resolve(42);
|
||||
|
||||
assert.deepEqual(await outcomePromise, { status: 'completed' });
|
||||
assert.deepEqual(applied, [42]);
|
||||
assert.equal(scheduler.activeCount(), 0);
|
||||
});
|
||||
|
||||
test('ignores an already queued deadline callback after successful completion', async () => {
|
||||
const api = await restoreSerializableApi();
|
||||
const operation = deferred<number>();
|
||||
const sampleKey = {};
|
||||
const applied: number[] = [];
|
||||
let queuedTimeout: (() => void) | null = null;
|
||||
let timeoutCount = 0;
|
||||
const timers: WorkerSampleDeadlineTimers = {
|
||||
clearTimeout(): void {
|
||||
// The callback may already be queued when cancellation runs.
|
||||
},
|
||||
setTimeout(callback): object {
|
||||
queuedTimeout = callback;
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
const outcomePromise = api.run({
|
||||
apply: (value) => applied.push(value),
|
||||
capturedGeneration: 8,
|
||||
currentIdentity: () => ({
|
||||
captureGeneration: 8,
|
||||
recordGeneration: 8,
|
||||
sampleKey,
|
||||
}),
|
||||
onTimeout: () => {
|
||||
timeoutCount += 1;
|
||||
},
|
||||
operation: () => operation.promise,
|
||||
sampleKey,
|
||||
timers,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
operation.resolve(43);
|
||||
|
||||
assert.deepEqual(await outcomePromise, { status: 'completed' });
|
||||
assert.deepEqual(applied, [43]);
|
||||
assert.ok(queuedTimeout);
|
||||
queuedTimeout();
|
||||
assert.equal(timeoutCount, 0);
|
||||
assert.deepEqual(applied, [43]);
|
||||
});
|
||||
|
||||
test('times out a stalled sample and ignores its late result', async () => {
|
||||
const scheduler = createScheduler();
|
||||
const api = await restoreSerializableApi();
|
||||
const operation = deferred<number>();
|
||||
const sampleKey = {};
|
||||
const applied: number[] = [];
|
||||
let timeoutCount = 0;
|
||||
|
||||
const outcomePromise = api.run({
|
||||
apply: (value) => applied.push(value),
|
||||
capturedGeneration: 11,
|
||||
currentIdentity: () => ({
|
||||
captureGeneration: 11,
|
||||
recordGeneration: 11,
|
||||
sampleKey,
|
||||
}),
|
||||
onTimeout: () => {
|
||||
timeoutCount += 1;
|
||||
},
|
||||
operation: () => operation.promise,
|
||||
sampleKey,
|
||||
timers: scheduler,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
scheduler.fireNext();
|
||||
|
||||
assert.deepEqual(await outcomePromise, { status: 'timed-out' });
|
||||
assert.equal(timeoutCount, 1);
|
||||
assert.deepEqual(applied, []);
|
||||
|
||||
operation.resolve(99);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(
|
||||
applied,
|
||||
[],
|
||||
'a non-cancellable Worker promise must not mutate capture state after its deadline'
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed when capture rollover makes a pending sample stale', async () => {
|
||||
const scheduler = createScheduler();
|
||||
const api = await restoreSerializableApi();
|
||||
const operation = deferred<number>();
|
||||
const sampleKey = {};
|
||||
const applied: number[] = [];
|
||||
let currentGeneration = 4;
|
||||
|
||||
const outcomePromise = api.run({
|
||||
apply: (value) => applied.push(value),
|
||||
capturedGeneration: 4,
|
||||
currentIdentity: () => ({
|
||||
captureGeneration: currentGeneration,
|
||||
recordGeneration: currentGeneration,
|
||||
sampleKey,
|
||||
}),
|
||||
onTimeout: () => assert.fail('a stale sample is not a timeout'),
|
||||
operation: () => operation.promise,
|
||||
sampleKey,
|
||||
timers: scheduler,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
currentGeneration = 5;
|
||||
operation.resolve(7);
|
||||
|
||||
assert.deepEqual(await outcomePromise, { status: 'stale' });
|
||||
assert.deepEqual(applied, []);
|
||||
assert.equal(scheduler.activeCount(), 0);
|
||||
});
|
||||
|
||||
test('the main capture bounds worker sampling and invalidates timed-out evidence', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const sampleWorkerStart = source.indexOf('const sampleWorker =');
|
||||
const sampleWorkerEnd = source.indexOf(
|
||||
'const resetWorkerForCapture',
|
||||
sampleWorkerStart
|
||||
);
|
||||
const sampleWorker = source.slice(sampleWorkerStart, sampleWorkerEnd);
|
||||
const startCaptureStart = source.indexOf('const startCapture =');
|
||||
const startCaptureEnd = source.indexOf('const api =', startCaptureStart);
|
||||
const startCapture = source.slice(startCaptureStart, startCaptureEnd);
|
||||
const apiStartStart = source.indexOf(
|
||||
'start: async (options: MainCaptureStartOptions)'
|
||||
);
|
||||
const apiStartEnd = source.indexOf(
|
||||
'beginMeasurement: async',
|
||||
apiStartStart
|
||||
);
|
||||
const apiStart = source.slice(apiStartStart, apiStartEnd);
|
||||
const stopStart = source.indexOf('stop: async (');
|
||||
const stopEnd = source.indexOf(
|
||||
'xtreamStatus: (): XtreamMainCaptureStatus',
|
||||
stopStart
|
||||
);
|
||||
const stopCapture = source.slice(stopStart, stopEnd);
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/workerSampleDeadlineApiFactorySource:\s+createWorkerSampleDeadlineApi\.toString\(\)/
|
||||
);
|
||||
assert.match(source, /workerSampleDeadlineMs:\s+5_000/);
|
||||
assert.match(source, /capturePoisonedReason:\s+null as string \| null/);
|
||||
assert.match(source, /sampleKey: object/);
|
||||
assert.match(source, /sampleTimedOut: boolean/);
|
||||
assert.match(sampleWorker, /workerSampleDeadlineApi\s*\.run\(/);
|
||||
assert.match(sampleWorker, /invalidateCapture\('worker-sample-timeout'\)/);
|
||||
assert.match(
|
||||
sampleWorker,
|
||||
/state\.capturePoisonedReason\s*=\s*'worker-sample-timeout'/
|
||||
);
|
||||
assert.match(sampleWorker, /record\.sampleKey === sampleKey/);
|
||||
assert.match(sampleWorker, /record\.samplePromise === boundedSample/);
|
||||
assert.match(
|
||||
startCapture,
|
||||
/if \(state\.capturePoisonedReason !== null\)[\s\S]*xtream-main-capture-poisoned/
|
||||
);
|
||||
assert.match(
|
||||
apiStart,
|
||||
/if \(state\.capturePoisonedReason !== null\)[\s\S]*xtream-main-capture-poisoned[\s\S]*databaseWorkerPostGcCutoffApi\.beginCapture\(\)/
|
||||
);
|
||||
assert.match(
|
||||
stopCapture,
|
||||
/const nextCaptureUnavailableReason =\s+state\.capturePoisonedReason \?\?/
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
export interface WorkerSampleDeadlineIdentity {
|
||||
readonly captureGeneration: number;
|
||||
readonly recordGeneration: number | null;
|
||||
readonly sampleKey: object;
|
||||
}
|
||||
|
||||
export type WorkerSampleDeadlineOutcome =
|
||||
| { readonly status: 'completed' }
|
||||
| { readonly error: unknown; readonly status: 'failed' }
|
||||
| { readonly status: 'stale' }
|
||||
| { readonly status: 'timed-out' };
|
||||
|
||||
export interface WorkerSampleDeadlineInput<T> {
|
||||
readonly apply: (value: T) => void;
|
||||
readonly capturedGeneration: number | null;
|
||||
readonly currentIdentity: () => WorkerSampleDeadlineIdentity;
|
||||
readonly onTimeout: () => void;
|
||||
readonly operation: () => Promise<T>;
|
||||
readonly sampleKey: object;
|
||||
readonly timers?: WorkerSampleDeadlineTimers;
|
||||
readonly timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface WorkerSampleDeadlineTimers {
|
||||
clearTimeout(handle: unknown): void;
|
||||
setTimeout(callback: () => void, timeoutMs: number): unknown;
|
||||
}
|
||||
|
||||
export interface WorkerSampleDeadlineApi {
|
||||
run<T>(
|
||||
input: WorkerSampleDeadlineInput<T>
|
||||
): Promise<WorkerSampleDeadlineOutcome>;
|
||||
}
|
||||
|
||||
export function assertWorkerSampleCaptureValid(
|
||||
invalidReasons: readonly string[]
|
||||
): void {
|
||||
if (invalidReasons.includes('worker-sample-timeout')) {
|
||||
throw new Error('main-capture-worker-sample-timeout');
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorkerSampleDeadlineApi(): WorkerSampleDeadlineApi {
|
||||
const defaultTimers: WorkerSampleDeadlineTimers = {
|
||||
clearTimeout(handle: unknown): void {
|
||||
globalThis.clearTimeout(
|
||||
handle as ReturnType<typeof globalThis.setTimeout>
|
||||
);
|
||||
},
|
||||
setTimeout(callback: () => void, timeoutMs: number): unknown {
|
||||
return globalThis.setTimeout(callback, timeoutMs);
|
||||
},
|
||||
};
|
||||
|
||||
const helpers = {
|
||||
isCurrent<T>(input: WorkerSampleDeadlineInput<T>): boolean {
|
||||
try {
|
||||
const current = input.currentIdentity();
|
||||
return (
|
||||
Number.isSafeInteger(input.capturedGeneration) &&
|
||||
Number(input.capturedGeneration) > 0 &&
|
||||
current.captureGeneration === input.capturedGeneration &&
|
||||
current.recordGeneration === input.capturedGeneration &&
|
||||
current.sampleKey === input.sampleKey
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
run<T>(
|
||||
input: WorkerSampleDeadlineInput<T>
|
||||
): Promise<WorkerSampleDeadlineOutcome> {
|
||||
if (!Number.isFinite(input.timeoutMs) || input.timeoutMs <= 0) {
|
||||
return Promise.resolve({
|
||||
error: new RangeError(
|
||||
'worker sample deadline must be a positive finite number'
|
||||
),
|
||||
status: 'failed',
|
||||
});
|
||||
}
|
||||
|
||||
const timers = input.timers ?? defaultTimers;
|
||||
return new Promise<WorkerSampleDeadlineOutcome>(
|
||||
(resolvePromise) => {
|
||||
let settled = false;
|
||||
let timeoutHandle: unknown;
|
||||
let timeoutScheduled = false;
|
||||
const callbacks = {
|
||||
complete(outcome: WorkerSampleDeadlineOutcome): void {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeoutScheduled) {
|
||||
try {
|
||||
timers.clearTimeout(timeoutHandle);
|
||||
} catch {
|
||||
// Best-effort cleanup must not hide evidence.
|
||||
}
|
||||
}
|
||||
resolvePromise(outcome);
|
||||
},
|
||||
|
||||
onError(error: unknown): void {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
callbacks.complete(
|
||||
helpers.isCurrent(input)
|
||||
? { error, status: 'failed' }
|
||||
: { status: 'stale' }
|
||||
);
|
||||
},
|
||||
|
||||
onTimeout(): void {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
if (!helpers.isCurrent(input)) {
|
||||
callbacks.complete({ status: 'stale' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
input.onTimeout();
|
||||
callbacks.complete({ status: 'timed-out' });
|
||||
} catch (error: unknown) {
|
||||
callbacks.complete({
|
||||
error,
|
||||
status: 'failed',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onValue(value: T): void {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
if (!helpers.isCurrent(input)) {
|
||||
callbacks.complete({ status: 'stale' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
input.apply(value);
|
||||
callbacks.complete({ status: 'completed' });
|
||||
} catch (error: unknown) {
|
||||
callbacks.complete({
|
||||
error,
|
||||
status: 'failed',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
timeoutHandle = timers.setTimeout(
|
||||
callbacks.onTimeout,
|
||||
input.timeoutMs
|
||||
);
|
||||
timeoutScheduled = true;
|
||||
} catch (error: unknown) {
|
||||
callbacks.complete({ error, status: 'failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.resolve()
|
||||
.then(input.operation)
|
||||
.then(callbacks.onValue, callbacks.onError);
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return Object.freeze({ run: helpers.run });
|
||||
}
|
||||
@@ -50,6 +50,14 @@ export interface SeedXtreamExistingPortalOptions {
|
||||
export async function prearmXtreamDatabaseWorker(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
// Main owns schema creation but loads the renderer first. Fence that
|
||||
// startup promise before the independent worker opens the fresh database.
|
||||
const downloads = await app.mainWindow.evaluate(() =>
|
||||
window.electron.downloadsGetList()
|
||||
);
|
||||
if (!Array.isArray(downloads)) {
|
||||
throw new Error('xtream-main-database-readiness-invalid');
|
||||
}
|
||||
const playlists = await app.mainWindow.evaluate(() =>
|
||||
window.electron.dbGetAppPlaylists()
|
||||
);
|
||||
|
||||
@@ -173,6 +173,60 @@ describe('Xtream raw iteration assembler', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts sub-millisecond skew between renderer and main cancellation clocks', () => {
|
||||
const input = createXtreamAssemblerFixture(
|
||||
XTREAM_SCENARIO_ID.CANCEL_IMPORT
|
||||
);
|
||||
const workerTerminal = input.mainCapture.cancelTimeline.find(
|
||||
({ type }) => type === 'db-cancel-terminal-received'
|
||||
);
|
||||
assert.ok(workerTerminal);
|
||||
const rendererTerminalEpochMs = workerTerminal.epochMs - 0.1;
|
||||
assert.ok(
|
||||
rendererTerminalEpochMs >
|
||||
Number(input.rendererCapture.probe.cancellationClickEpochMs)
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
assembleXtreamRawIteration({
|
||||
...input,
|
||||
rendererCapture: {
|
||||
...input.rendererCapture,
|
||||
probe: {
|
||||
...input.rendererCapture.probe,
|
||||
dbCancellationTerminalEpochMs: rendererTerminalEpochMs,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a cancellation terminal that substantially predates the main receipt', () => {
|
||||
const input = createXtreamAssemblerFixture(
|
||||
XTREAM_SCENARIO_ID.CANCEL_IMPORT
|
||||
);
|
||||
const workerTerminal = input.mainCapture.cancelTimeline.find(
|
||||
({ type }) => type === 'db-cancel-terminal-received'
|
||||
);
|
||||
assert.ok(workerTerminal);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
assembleXtreamRawIteration({
|
||||
...input,
|
||||
rendererCapture: {
|
||||
...input.rendererCapture,
|
||||
probe: {
|
||||
...input.rendererCapture.probe,
|
||||
dbCancellationTerminalEpochMs:
|
||||
workerTerminal.epochMs - 2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
/xtream-iteration-assembly-invalid/
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts main observing a network cancel response after the next renderer IPC starts', () => {
|
||||
const input = createXtreamAssemblerFixture(
|
||||
XTREAM_SCENARIO_ID.CANCEL_IMPORT
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
type XtreamUiActionSample,
|
||||
} from './xtream-ui-action-probe';
|
||||
|
||||
const CANCELLATION_CLOCK_SKEW_TOLERANCE_MS = 1;
|
||||
|
||||
export interface XtreamScenarioAssembly {
|
||||
readonly backgroundMetrics: {
|
||||
readonly actionLatencyMs: number;
|
||||
@@ -264,6 +266,8 @@ function cancellationEvidence(
|
||||
const preload = ipcPair(input.phaseCapture.ipcSpans, 'dbCancelOperation');
|
||||
const authoritative = request.responseEpochMs;
|
||||
const painted = requireNumber(renderer.uiPaintedEpochMs);
|
||||
// Renderer and main sample independent clocks, so allow their correlated
|
||||
// terminal timestamps to invert only within the known sub-millisecond skew.
|
||||
if (
|
||||
!dispatch ||
|
||||
!receipt ||
|
||||
@@ -271,7 +275,8 @@ function cancellationEvidence(
|
||||
dispatch.type !== 'db-cancel-dispatched' ||
|
||||
receipt.type !== 'db-cancel-received' ||
|
||||
workerTerminal.type !== 'db-cancel-terminal-received' ||
|
||||
renderer.dbCancellationTerminalEpochMs < workerTerminal.epochMs ||
|
||||
renderer.dbCancellationTerminalEpochMs <
|
||||
workerTerminal.epochMs - CANCELLATION_CLOCK_SKEW_TOLERANCE_MS ||
|
||||
renderer.dbCancellationTerminalEpochMs > painted ||
|
||||
authoritative > painted
|
||||
) {
|
||||
|
||||
@@ -145,7 +145,14 @@ describe('Xtream scenario driver source wiring', () => {
|
||||
support,
|
||||
/export async function prearmXtreamDatabaseWorker/
|
||||
);
|
||||
assert.match(support, /window\.electron\.dbGetAppPlaylists\(\)/);
|
||||
const mainDatabaseReady = support.indexOf(
|
||||
'window.electron.downloadsGetList()'
|
||||
);
|
||||
const workerPrearm = support.indexOf(
|
||||
'window.electron.dbGetAppPlaylists()'
|
||||
);
|
||||
assert.ok(mainDatabaseReady >= 0);
|
||||
assert.ok(workerPrearm > mainDatabaseReady);
|
||||
assert.ok(install >= 0);
|
||||
assert.ok(prearm > install);
|
||||
assert.ok(captureOptions > prearm);
|
||||
|
||||
Reference in new issue
Block a user