refactor: centralize Electron execution limits and action projection

This commit is contained in:
morluto
2026-10-24 02:33:06 +08:00
parent 1707d171d7
commit 5eada7e410
5 changed files with 67 additions and 76 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ import type {
ProcessCleanupResult,
ProcessLineageObservation,
} from "../process/ProcessOwnership.js";
import { runWithExecutionLimits } from "./PlaywrightElectronActiveActions.js";
import { runWithExecutionLimits } from "./PlaywrightElectronExecution.js";
import { cleanupElectronProcesses } from "./ElectronProcessCleanup.js";
type ElectronApplication = Awaited<ReturnType<typeof electron.launch>>;
@@ -1,3 +1,4 @@
import { runWithExecutionLimits } from "./PlaywrightElectronExecution.js";
// Fixture-backed action coverage; real Electron verification lives in
// `npm run verify:electron`.
import { afterEach, expect, it, vi } from "vitest";
@@ -13,7 +14,6 @@ import { createElectronActiveObservationFixtureResult } from "../domain/javascri
import {
readFinalApplicationState,
runElectronActions,
runWithExecutionLimits,
} from "./PlaywrightElectronActiveActions.js";
const fixtureBrowserWindow = async () => ({
+12 -73
View File
@@ -1,5 +1,6 @@
import type { ElectronApplication, JSHandle, Page } from "playwright-core";
import { z } from "zod";
import { runWithExecutionLimits } from "./PlaywrightElectronExecution.js";
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
import type {
@@ -101,6 +102,7 @@ export const runElectronActions = async (
let selectedHandle: SelectedElectronWindow | undefined;
let handleOwner: ArtifactResourceOwner | undefined;
let target: string | null = null;
let actionError: string | null = null;
try {
if (options.signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
@@ -143,33 +145,19 @@ export const runElectronActions = async (
selectedHandle,
options,
});
actions.push({
step_id: action.step_id,
kind: action.kind,
window_index: windowIndex,
target,
status: "completed",
elapsed_ms: Math.max(
0,
Math.round(performance.now() - actionStartedAt),
),
error: null,
});
} catch (cause: unknown) {
stoppedBy = options.signal?.aborted ? "cancelled" : "failed";
actions.push({
step_id: action.step_id,
kind: action.kind,
window_index: windowIndex,
target,
status: stoppedBy,
elapsed_ms: Math.max(
0,
Math.round(performance.now() - actionStartedAt),
),
error: safeActionErrorMessage(cause),
});
actionError = safeActionErrorMessage(cause);
}
actions.push({
step_id: action.step_id,
kind: action.kind,
window_index: windowIndex,
target,
status: stoppedBy ?? "completed",
elapsed_ms: Math.max(0, Math.round(performance.now() - actionStartedAt)),
error: actionError,
});
if (handleOwner !== undefined) {
cleanupError = await releaseActionWindow(resources, handleOwner);
if (cleanupError !== undefined) stoppedBy = "failed";
@@ -500,55 +488,6 @@ export const readFinalApplicationState = async (
});
};
/** Apply an optional lifecycle deadline and caller cancellation to an operation. */
export const runWithExecutionLimits = async <Value>(
operation: Promise<Value>,
signal: AbortSignal | undefined,
deadline?: number,
): Promise<Value> => {
if (signal?.aborted === true) {
// The operation has already started; observe its abandoned settlement.
void operation.catch(() => undefined);
throw new BrowserObservationError(OPERATION, "cancelled", {
cause: signal.reason,
});
}
if (signal === undefined && deadline === undefined) return operation;
let timer: ReturnType<typeof setTimeout> | undefined;
let abortListener: (() => void) | undefined;
const interruptions: Promise<never>[] = [];
if (deadline !== undefined)
interruptions.push(
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new BrowserObservationError(OPERATION, "timeout")),
Math.max(1, deadline - performance.now()),
);
}),
);
if (signal !== undefined)
interruptions.push(
new Promise<never>((_, reject) => {
abortListener = () =>
reject(
new BrowserObservationError(OPERATION, "cancelled", {
cause: signal.reason,
}),
);
signal.addEventListener("abort", abortListener, { once: true });
}),
);
try {
return interruptions.length === 0
? await operation
: await Promise.race([operation, ...interruptions]);
} finally {
if (timer !== undefined) clearTimeout(timer);
if (abortListener !== undefined)
signal?.removeEventListener("abort", abortListener);
}
};
const safeActionErrorMessage = (cause: unknown): string => {
return cause instanceof Error ? cause.message : "Electron action failed";
};
@@ -1,3 +1,4 @@
import { runWithExecutionLimits } from "./PlaywrightElectronExecution.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
@@ -38,7 +39,6 @@ import {
runElectronActions,
readApplicationState,
readFinalApplicationState,
runWithExecutionLimits,
} from "./PlaywrightElectronActiveActions.js";
import { projectElectronActiveCapture } from "./PlaywrightElectronActiveProjection.js";
import { readElectronFuseImage } from "../electron/ElectronFuseReader.js";
@@ -0,0 +1,52 @@
import { BrowserObservationError } from "../domain/browserObservationError.js";
const OPERATION = "capture_electron_scenario" as const;
/** Apply an optional lifecycle deadline and caller cancellation to an operation. */
export const runWithExecutionLimits = async <Value>(
operation: Promise<Value>,
signal: AbortSignal | undefined,
deadline?: number,
): Promise<Value> => {
if (signal?.aborted === true) {
// The operation has already started; observe its abandoned settlement.
void operation.catch(() => undefined);
throw new BrowserObservationError(OPERATION, "cancelled", {
cause: signal.reason,
});
}
if (signal === undefined && deadline === undefined) return operation;
let timer: ReturnType<typeof setTimeout> | undefined;
let abortListener: (() => void) | undefined;
const interruptions: Promise<never>[] = [];
if (deadline !== undefined)
interruptions.push(
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new BrowserObservationError(OPERATION, "timeout")),
Math.max(1, deadline - performance.now()),
);
}),
);
if (signal !== undefined)
interruptions.push(
new Promise<never>((_, reject) => {
abortListener = () =>
reject(
new BrowserObservationError(OPERATION, "cancelled", {
cause: signal.reason,
}),
);
signal.addEventListener("abort", abortListener, { once: true });
}),
);
try {
return interruptions.length === 0
? await operation
: await Promise.race([operation, ...interruptions]);
} finally {
if (timer !== undefined) clearTimeout(timer);
if (abortListener !== undefined)
signal?.removeEventListener("abort", abortListener);
}
};