mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
fix(updater): coordinate shutdown and observe update handoff (#7982)
* fix(updater): wait for shutdown without false quit failures * feat(updater): observe persisted shutdown and successor stages
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { UPDATE_LIFECYCLE_STAGES, parseUpdateLifecycleObservation, type UpdateLifecycleObservedProps } from '@open-design/contracts/analytics';
|
||||
import { readdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -305,3 +306,60 @@ export async function observePendingInstallerApplyAttempts(
|
||||
}
|
||||
return { observed };
|
||||
}
|
||||
|
||||
/** Replay immutable stage records independently of version-apply classification. */
|
||||
export async function observeUpdateLifecycleStages(
|
||||
options: ObservePendingInstallerApplyAttemptsOptions,
|
||||
): Promise<{ queued: number }> {
|
||||
const root = installerObservationRoot(options.dataRoot);
|
||||
const now = options.now?.() ?? new Date();
|
||||
let entries;
|
||||
try { entries = await readdir(root, { withFileTypes: true }); } catch { return { queued: 0 }; }
|
||||
const config = await (options.readConfig ?? readAppConfig)(options.dataRoot).catch(() => ({} as AppConfigPrefs));
|
||||
const decision = deliveryForConfig(config, options.env ?? process.env, now.toISOString());
|
||||
const channel = normalizeUpdateObservationChannel(options.currentVersion, options.currentChannel);
|
||||
let queued = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !isSafeFlowId(entry.name)) continue;
|
||||
const summary = await readSummary(summaryPath(root, entry.name));
|
||||
if (summary == null || summary.flowId !== entry.name || summary.namespace !== options.namespace || summary.channel !== channel) continue;
|
||||
const age = now.getTime() - Date.parse(summary.attemptedAt);
|
||||
if (!Number.isFinite(age) || age < 0 || age > INSTALLER_OBSERVATION_TTL_MS) continue;
|
||||
for (const stage of UPDATE_LIFECYCLE_STAGES) {
|
||||
const file = path.join(root, entry.name, 'lifecycle', `${stage}.json`);
|
||||
const receipt = `${file}.receipt`;
|
||||
try {
|
||||
const prior = JSON.parse(await readFile(receipt, 'utf8'));
|
||||
if (prior.status === 'queued' || String(prior.status).startsWith('skipped_')) continue;
|
||||
} catch { /* No receipt: retry an unsubmitted stage. */ }
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(file, 'utf8'));
|
||||
const observation = parseUpdateLifecycleObservation(raw);
|
||||
const occurredAt = Date.parse(raw.occurred_at);
|
||||
if (raw.observation_version !== 1 || raw.flow_id !== summary.flowId || observation?.stage !== stage ||
|
||||
!Number.isFinite(occurredAt) || occurredAt < Date.parse(summary.attemptedAt) || occurredAt > now.getTime()) continue;
|
||||
let status: string = decision.delivery.status;
|
||||
const insertId = `update_lifecycle_observed:${summary.flowId}:${stage}`;
|
||||
if (decision.context != null) {
|
||||
const properties: UpdateLifecycleObservedProps = {
|
||||
...observation, flow_id: summary.flowId, from_version: summary.fromVersion,
|
||||
to_version: summary.toVersion, channel: summary.channel, platform: summary.platform,
|
||||
arch: summary.arch, occurred_at: new Date(occurredAt).toISOString(), observation_version: 1,
|
||||
};
|
||||
const capture = await options.analytics.capture({
|
||||
eventName: 'update_lifecycle_observed', context: decision.context,
|
||||
appVersion: options.appVersion, properties, insertId,
|
||||
});
|
||||
// Queue acknowledgement is not remote ingestion acknowledgement.
|
||||
if (capture?.status !== 'queued') continue;
|
||||
status = 'queued';
|
||||
queued += 1;
|
||||
}
|
||||
const temporary = `${receipt}.${process.pid}.tmp`;
|
||||
await writeFile(temporary, JSON.stringify({ status, insertId, updatedAt: now.toISOString() }));
|
||||
await rename(temporary, receipt);
|
||||
} catch { /* Offline, malformed, or unavailable observations never fail startup. */ }
|
||||
}
|
||||
}
|
||||
return { queued };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observeUpdateLifecycleStages } from '../migration/update-apply-observations.js';
|
||||
import express, { type Express } from 'express';
|
||||
import { SIDECAR_DEFAULTS } from '@open-design/sidecar-proto';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -253,9 +254,26 @@ export function registerTelemetryRoutes(app: Express, deps: RegisterTelemetryRou
|
||||
getAppVersion: () => cachedAppVersion,
|
||||
});
|
||||
|
||||
let lifecycleScanRunning = false;
|
||||
let telemetryDisposed = false;
|
||||
const scanUpdateLifecycle = async () => {
|
||||
if (telemetryDisposed || lifecycleScanRunning || cachedAppVersion == null) return;
|
||||
lifecycleScanRunning = true;
|
||||
try {
|
||||
await observeUpdateLifecycleStages({
|
||||
analytics: analyticsService, appVersion: cachedAppVersion.version,
|
||||
currentChannel: cachedAppVersion.channel, currentVersion: cachedAppVersion.version,
|
||||
dataRoot: dataDir, namespace: resolveInstallerObservationNamespace(deps.namespace),
|
||||
});
|
||||
} catch { /* Observability never gates daemon lifecycle. */ }
|
||||
finally { lifecycleScanRunning = false; }
|
||||
};
|
||||
const lifecycleTimer = setInterval(() => { void scanUpdateLifecycle(); }, 10_000);
|
||||
lifecycleTimer.unref();
|
||||
const appVersionPromise = (async () => {
|
||||
try {
|
||||
cachedAppVersion = await readCurrentAppVersionInfo();
|
||||
void scanUpdateLifecycle();
|
||||
void observePendingInstallerApplyAttempts({
|
||||
analytics: analyticsService,
|
||||
appVersion: cachedAppVersion.version,
|
||||
@@ -276,7 +294,11 @@ export function registerTelemetryRoutes(app: Express, deps: RegisterTelemetryRou
|
||||
|
||||
return {
|
||||
analyticsService,
|
||||
disposeFatalHandlers,
|
||||
disposeFatalHandlers: () => {
|
||||
telemetryDisposed = true;
|
||||
clearInterval(lifecycleTimer);
|
||||
disposeFatalHandlers();
|
||||
},
|
||||
getCachedAppVersion: () => cachedAppVersion,
|
||||
resolveAppVersion: () => appVersionPromise,
|
||||
reportFeedback: (req) =>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { observeUpdateLifecycleStages, type ObservePendingInstallerApplyAttemptsOptions } from '../src/migration/update-apply-observations.js';
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'od-lifecycle-replay-')); roots.push(root);
|
||||
const flow = join(root, 'observations/installer/flow');
|
||||
await mkdir(join(flow, 'lifecycle'), { recursive: true });
|
||||
const summary = { schemaVersion: 1, kind: 'installer_apply_observation', flowId: 'flow',
|
||||
channel: 'prerelease', namespace: 'test', arch: 'x64', platform: 'win32', artifactType: 'payload',
|
||||
fromVersion: '0.22.2-prerelease.2', toVersion: '0.22.2-prerelease.3',
|
||||
attemptedAt: '2026-09-10T05:20:00Z', updatedAt: '2026-09-10T05:20:00Z', result: 'success', reason: 'app_version_matches',
|
||||
delivery: { status: 'submitted' } };
|
||||
await writeFile(join(flow, 'summary.json'), JSON.stringify(summary));
|
||||
const capture = vi.fn(async (_args: Parameters<ObservePendingInstallerApplyAttemptsOptions['analytics']['capture']>[0]) => ({ status: 'queued' as const, acknowledgement: 'local_buffer' as const, errorType: null }));
|
||||
const options: ObservePendingInstallerApplyAttemptsOptions = {
|
||||
analytics: { capture }, appVersion: summary.toVersion, currentVersion: summary.toVersion,
|
||||
currentChannel: 'prerelease', namespace: 'test', dataRoot: root, env: { POSTHOG_KEY: 'ph_test' },
|
||||
now: () => new Date('2026-09-10T05:22:00Z'),
|
||||
readConfig: async () => ({ installationId: 'test-device', telemetry: { metrics: true } }),
|
||||
};
|
||||
const stage = async (name: string, overrides: Record<string, unknown> = {}) => writeFile(join(flow, 'lifecycle', `${name}.json`), JSON.stringify({
|
||||
stage: name, outcome: 'completed', flow_id: 'flow', occurred_at: '2026-09-10T05:20:45Z', observation_version: 1, ...overrides,
|
||||
}));
|
||||
return { flow, capture, options, stage };
|
||||
}
|
||||
it('accepts legacy summaries without stages, then observes late desktop readiness separately', async () => {
|
||||
const { options, capture, stage } = await fixture();
|
||||
expect(await observeUpdateLifecycleStages(options)).toEqual({ queued: 0 });
|
||||
await stage('desktop_ready');
|
||||
expect(await observeUpdateLifecycleStages(options)).toEqual({ queued: 1 });
|
||||
expect(capture.mock.calls[0]?.[0]).toMatchObject({ eventName: 'update_lifecycle_observed', insertId: 'update_lifecycle_observed:flow:desktop_ready' });
|
||||
await observeUpdateLifecycleStages(options);
|
||||
expect(capture).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('retries a rejected enqueue with the same insert ID and does not conflate queue acknowledgement with ingestion', async () => {
|
||||
const { options, capture, stage, flow } = await fixture();
|
||||
await stage('shutdown_completed', { duration_ms: 32000, repeated_quit_count: 2 });
|
||||
capture.mockRejectedValueOnce(new Error('offline'));
|
||||
expect(await observeUpdateLifecycleStages(options)).toEqual({ queued: 0 });
|
||||
expect(await observeUpdateLifecycleStages(options)).toEqual({ queued: 1 });
|
||||
expect(capture.mock.calls[0]?.[0]).toEqual(capture.mock.calls[1]?.[0]);
|
||||
expect(JSON.parse(await readFile(join(flow, 'lifecycle/shutdown_completed.json.receipt'), 'utf8')).status).toBe('queued');
|
||||
});
|
||||
it('does not emit opted-out observations later when consent changes', async () => {
|
||||
const { options, capture, stage } = await fixture();
|
||||
await stage('desktop_ready');
|
||||
await observeUpdateLifecycleStages({ ...options, readConfig: async () => ({ installationId: 'test-device', telemetry: { metrics: false } }) });
|
||||
await observeUpdateLifecycleStages(options);
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
it('projects only finite metrics and rejects mismatched flow, stage, identity and invalid counts', async () => {
|
||||
const { options, capture, stage } = await fixture();
|
||||
await stage('cleanup_daemon', { outcome: 'forced', forced_process_count: 3, remaining_process_count: 0, raw_path: '/private/user/file', pid: 123 });
|
||||
await stage('desktop_ready', { flow_id: 'other' });
|
||||
await stage('shutdown_completed', { repeated_quit_count: -1 });
|
||||
await observeUpdateLifecycleStages({ ...options, namespace: 'other' });
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
expect(await observeUpdateLifecycleStages(options)).toEqual({ queued: 1 });
|
||||
const serialized = JSON.stringify(capture.mock.calls);
|
||||
expect(serialized).not.toContain('/private');
|
||||
expect(serialized).not.toContain('"pid"');
|
||||
expect(serialized).toContain('"forced_process_count":3');
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { recordIncomingUpdateLifecycle, type UpdateLifecycleObservation } from "./update-lifecycle-observations.js";
|
||||
export { recordIncomingUpdateLifecycle, type UpdateLifecycleObservation } from "./update-lifecycle-observations.js";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -191,7 +193,7 @@ export function applyLoopbackConnectionLimitSwitch(electronApp: Electron.App): v
|
||||
}
|
||||
|
||||
export type DesktopMainOptions = {
|
||||
beforeShutdown?: () => Promise<void>;
|
||||
beforeShutdown?: (record?: (event: UpdateLifecycleObservation) => Promise<void>) => Promise<void>;
|
||||
onExternalShow?: () => void | Promise<void>;
|
||||
discoverWebUrl: () => Promise<string | null>;
|
||||
/**
|
||||
@@ -776,7 +778,9 @@ export async function runDesktopMain(
|
||||
let disposeMenu: () => void = () => undefined;
|
||||
let updateScheduler: DesktopUpdaterScheduler | null = null;
|
||||
let removeDiagnosticsIpc: () => void = () => undefined;
|
||||
let shuttingDown = false;
|
||||
let shutdownPromise: Promise<void> | null = null;
|
||||
let shutdownComplete = false;
|
||||
let shutdownRequestCount = 0;
|
||||
let pendingUpdateDialogRequest = false;
|
||||
|
||||
async function snapshotUpdateForStatus(): Promise<{
|
||||
@@ -827,22 +831,32 @@ export async function runDesktopMain(
|
||||
};
|
||||
}
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
await options.beforeShutdown?.().catch((error: unknown) => {
|
||||
console.error("desktop beforeShutdown failed", error);
|
||||
// Every quit entry point joins the same cleanup, including repeated updater
|
||||
// requests while sidecars are still draining.
|
||||
function shutdown(): Promise<void> {
|
||||
shutdownRequestCount += 1;
|
||||
shutdownPromise ??= Promise.resolve().then(async () => {
|
||||
const startedAt = Date.now();
|
||||
let shutdownFailed = false;
|
||||
console.info("[open-design desktop] shutdown started");
|
||||
updateScheduler?.stop("shutdown");
|
||||
await updater.recordLifecycle?.({ stage: "shutdown_started", outcome: "started" });
|
||||
await options.beforeShutdown?.((event) => updater.recordLifecycle?.(event) ?? Promise.resolve()).catch((error: unknown) => {
|
||||
shutdownFailed = true;
|
||||
console.error("desktop beforeShutdown failed", error);
|
||||
});
|
||||
console.info("[open-design desktop] shutdown sidecars settled", { durationMs: Date.now() - startedAt });
|
||||
disposeMenu();
|
||||
removeDiagnosticsIpc();
|
||||
await desktop?.close().catch(() => { shutdownFailed = true; });
|
||||
// Mark clean only after teardown; a stalled cleanup is not a clean exit.
|
||||
endDesktopSessionCleanly({ stateFilePath: sessionStatePath });
|
||||
console.info("[open-design desktop] shutdown completed", { durationMs: Date.now() - startedAt });
|
||||
await updater.recordLifecycle?.({ stage: "shutdown_completed", outcome: shutdownFailed ? "failed" : "completed", duration_ms: Date.now() - startedAt, repeated_quit_count: shutdownRequestCount - 1 });
|
||||
shutdownComplete = true;
|
||||
app.quit();
|
||||
});
|
||||
updateScheduler?.stop("shutdown");
|
||||
disposeMenu();
|
||||
removeDiagnosticsIpc();
|
||||
await desktop?.close().catch(() => undefined);
|
||||
// Mark the session clean only AFTER teardown actually completed, right
|
||||
// before app.quit(). Doing it at the start of shutdown would flag a quit as
|
||||
// clean even if a later await hangs and the process is then force-quit or
|
||||
// OS-killed — which is itself an abnormal exit worth reporting.
|
||||
endDesktopSessionCleanly({ stateFilePath: sessionStatePath });
|
||||
app.quit();
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
function shutdownAndExit(): void {
|
||||
@@ -943,6 +957,9 @@ export async function runDesktopMain(
|
||||
onRevealed: () => markDesktopSessionRunning({ stateFilePath: sessionStatePath }),
|
||||
onUpdateMenuLabels: menuController.setUpdateLabels,
|
||||
requestQuit: shutdownAndExit,
|
||||
onMainWindowReady: () => {
|
||||
void recordIncomingUpdateLifecycle({ root: options.update?.installerObservationRoot, namespace: updater.config.namespace ?? "default", channel: updater.config.channel, version: updater.config.currentVersion }, { stage: "desktop_ready", outcome: "completed" });
|
||||
},
|
||||
splashWindow: options.splashWindow,
|
||||
splashStartedAt: options.splashStartedAt,
|
||||
updater,
|
||||
@@ -1013,7 +1030,7 @@ export async function runDesktopMain(
|
||||
if (updater.shouldAutoCheck()) updateScheduler.start();
|
||||
|
||||
app.on("before-quit", (event) => {
|
||||
if (shuttingDown) return;
|
||||
if (shutdownComplete) return;
|
||||
event.preventDefault();
|
||||
void shutdown().finally(() => process.exit(0));
|
||||
});
|
||||
|
||||
@@ -451,6 +451,7 @@ export type DesktopRuntimeOptions = {
|
||||
*/
|
||||
rendererLogPath?: string | null;
|
||||
requestQuit?: () => void;
|
||||
onMainWindowReady?: () => void;
|
||||
/**
|
||||
* Optional pre-created splash window. The packaged entry creates the splash
|
||||
* BEFORE awaiting the daemon/web sidecars so the brand animation is on screen
|
||||
@@ -2849,9 +2850,10 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
|
||||
// The web bundle is loading in the hidden main window from here on; let
|
||||
// the splash status line reflect that final phase while we poll for mount.
|
||||
setSplashStage(splash, "workspace");
|
||||
let mounted = false;
|
||||
const deadline = Date.now() + WEB_MOUNT_REVEAL_TIMEOUT_MS;
|
||||
while (!stopped && !window.isDestroyed() && Date.now() < deadline) {
|
||||
const mounted = await window.webContents
|
||||
mounted = await window.webContents
|
||||
.executeJavaScript(`document.documentElement.getAttribute("data-od-app-mounted") === "1"`, true)
|
||||
.catch(() => false);
|
||||
if (mounted === true) break;
|
||||
@@ -2865,6 +2867,11 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
|
||||
const remaining = MIN_SPLASH_MS - (Date.now() - splashStartedAt);
|
||||
if (remaining > 0) await delay(remaining);
|
||||
revealMainWindow();
|
||||
// A timeout/crash fallback can also reveal a window. Only a mounted,
|
||||
// healthy app is a successful updater desktop observation.
|
||||
if (mounted && !rendererFailed && revealed && !window.isDestroyed()) {
|
||||
try { options.onMainWindowReady?.(); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
const schedule = (delayMs: number) => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { link, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { parseUpdateLifecycleObservation, type UpdateLifecycleObservation } from "@open-design/contracts/analytics";
|
||||
import { isSafeInstallerObservationFlowId, type InstallerObservationHandle } from "./installer-observations.js";
|
||||
|
||||
export type { UpdateLifecycleObservation } from "@open-design/contracts/analytics";
|
||||
const TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Local best-effort telemetry must never hold an updater transition indefinitely. */
|
||||
async function boundedObservation(work: () => Promise<void>): Promise<void> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.resolve().then(work).catch(() => undefined),
|
||||
new Promise<void>((resolve) => { timer = setTimeout(resolve, 100); }),
|
||||
]);
|
||||
} finally {
|
||||
if (timer != null) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** One immutable file per stage. Old/new processes never rewrite each other's summary. */
|
||||
export async function recordUpdateLifecycle(
|
||||
handle: InstallerObservationHandle | null,
|
||||
observation: UpdateLifecycleObservation,
|
||||
): Promise<void> {
|
||||
if (handle == null) return;
|
||||
await boundedObservation(async () => {
|
||||
const event = parseUpdateLifecycleObservation(observation);
|
||||
if (event == null || !isSafeInstallerObservationFlowId(handle.flowId)) return;
|
||||
const root = join(dirname(handle.summaryPath), "lifecycle");
|
||||
await mkdir(root, { recursive: true });
|
||||
const destination = join(root, `${event.stage}.json`);
|
||||
const temporary = join(root, `.${randomUUID()}.tmp`);
|
||||
try {
|
||||
await writeFile(temporary, JSON.stringify({ ...event, flow_id: handle.flowId, occurred_at: new Date().toISOString(), observation_version: 1 }));
|
||||
await link(temporary, destination); // First observation wins, even across processes.
|
||||
} finally {
|
||||
await unlink(temporary).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Older clients already persist these summaries; absent stages remain unknown. */
|
||||
export async function findIncomingUpdateObservation(input: {
|
||||
root: string | null | undefined; namespace: string; channel: string; version: string | null;
|
||||
}): Promise<InstallerObservationHandle | null> {
|
||||
if (input.root == null || input.version == null) return null;
|
||||
try {
|
||||
const candidates: Array<InstallerObservationHandle & { at: number }> = [];
|
||||
for (const entry of await readdir(input.root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !isSafeInstallerObservationFlowId(entry.name)) continue;
|
||||
const summaryPath = join(input.root, entry.name, "summary.json");
|
||||
try {
|
||||
const summary = JSON.parse(await readFile(summaryPath, "utf8"));
|
||||
const at = Date.parse(summary.attemptedAt);
|
||||
if (summary.schemaVersion === 1 && summary.kind === "installer_apply_observation" &&
|
||||
summary.flowId === entry.name && summary.namespace === input.namespace && summary.channel === input.channel &&
|
||||
summary.toVersion === input.version && summary.reason !== "installer_open_failed" &&
|
||||
Number.isFinite(at) && Date.now() - at >= 0 && Date.now() - at < TTL_MS) {
|
||||
candidates.push({ flowId: entry.name, summaryPath, at });
|
||||
}
|
||||
} catch { /* An incomplete legacy observation is not an updater failure. */ }
|
||||
}
|
||||
candidates.sort((a, b) => b.at - a.at || a.flowId.localeCompare(b.flowId));
|
||||
return candidates[0] ?? null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function recordIncomingUpdateLifecycle(
|
||||
input: Parameters<typeof findIncomingUpdateObservation>[0], observation: UpdateLifecycleObservation,
|
||||
): Promise<void> {
|
||||
await boundedObservation(async () => recordUpdateLifecycle(await findIncomingUpdateObservation(input), observation));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { recordUpdateLifecycle, type UpdateLifecycleObservation } from "./update-lifecycle-observations.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createReadStream } from "node:fs";
|
||||
@@ -167,6 +168,7 @@ export type DesktopUpdater = {
|
||||
downloadUpdate(): Promise<DesktopUpdateStatusSnapshot>;
|
||||
handle(action: DesktopUpdateAction): Promise<DesktopUpdateStatusSnapshot>;
|
||||
installUpdate(): Promise<DesktopUpdateStatusSnapshot>;
|
||||
recordLifecycle?(event: UpdateLifecycleObservation): Promise<void>;
|
||||
shouldAutoCheck(): boolean;
|
||||
snapshot(): DesktopUpdateStatusSnapshot;
|
||||
status(): Promise<DesktopUpdateStatusSnapshot>;
|
||||
@@ -432,6 +434,7 @@ export function createDesktopUpdater(
|
||||
let lastCheckedAt: string | undefined;
|
||||
let installResult: DesktopUpdateStatusSnapshot["installResult"];
|
||||
let installFrozen = false;
|
||||
let observationHandle: InstallerObservationHandle | null = null;
|
||||
let lifecycleSummary: DesktopUpdateCacheLifecycleSummary | undefined;
|
||||
let progress: DesktopUpdateProgressSnapshot | undefined;
|
||||
let reinstallRequirement: DesktopUpdateReinstallSnapshot | undefined;
|
||||
@@ -1042,13 +1045,14 @@ export function createDesktopUpdater(
|
||||
}
|
||||
|
||||
async function writeInstallObservation(attemptedAt: string): Promise<InstallerObservationHandle | null> {
|
||||
observationHandle = null;
|
||||
if (config.openDryRun) return null;
|
||||
if (config.installerObservationRoot == null || config.namespace == null) return null;
|
||||
if (activeRelease == null) return null;
|
||||
const artifactType = installerObservationArtifactType(activeRelease.ref.artifact.type);
|
||||
if (artifactType == null) return null;
|
||||
try {
|
||||
return await writePendingInstallerObservation({
|
||||
observationHandle = await writePendingInstallerObservation({
|
||||
arch: activeRelease.ref.arch,
|
||||
artifactType,
|
||||
attemptedAt,
|
||||
@@ -1059,6 +1063,8 @@ export function createDesktopUpdater(
|
||||
root: config.installerObservationRoot,
|
||||
toVersion: activeRelease.ref.version,
|
||||
});
|
||||
await recordUpdateLifecycle(observationHandle, { stage: "install_requested", outcome: "started" });
|
||||
return observationHandle;
|
||||
} catch (observationError) {
|
||||
logger.warn("[open-design updater] failed to write installer observation", observationError);
|
||||
return null;
|
||||
@@ -1349,6 +1355,7 @@ export function createDesktopUpdater(
|
||||
}
|
||||
},
|
||||
installUpdate: () => serialized(installUpdate),
|
||||
recordLifecycle: (event) => recordUpdateLifecycle(installResult != null ? observationHandle : null, event),
|
||||
shouldAutoCheck: () => config.enabled && config.autoCheck,
|
||||
snapshot,
|
||||
async status() {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Execute the entry's actual shutdown declarations, substituting only Electron
|
||||
// and the sidecar cleanup boundary. This catches premature process.exit calls.
|
||||
function shutdownHarness() {
|
||||
const source = readFileSync(new URL("../../src/main/index.ts", import.meta.url), "utf8");
|
||||
const ast = ts.createSourceFile("index.ts", source, ts.ScriptTarget.Latest, true);
|
||||
const declarations: string[] = [];
|
||||
const names = new Set(["shuttingDown", "shutdownPromise", "shutdownComplete", "shutdownRequestCount", "shutdown", "shutdownAndExit"]);
|
||||
function visit(node: ts.Node): void {
|
||||
if (ts.isExpressionStatement(node) && ts.isCallExpression(node.expression) &&
|
||||
node.expression.expression.getText(ast) === "app.on" &&
|
||||
node.expression.arguments[0]?.getText(ast) === '"before-quit"') {
|
||||
declarations.push(node.getText(ast));
|
||||
return;
|
||||
}
|
||||
if (ts.isFunctionDeclaration(node) && node.name != null && names.has(node.name.text)) {
|
||||
declarations.push(node.getText(ast));
|
||||
return;
|
||||
}
|
||||
if (ts.isVariableStatement(node) && node.declarationList.declarations.some((d) => ts.isIdentifier(d.name) && names.has(d.name.text))) {
|
||||
declarations.push(node.getText(ast));
|
||||
return;
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(ast);
|
||||
let finishCleanup!: () => void;
|
||||
const cleanup = new Promise<void>((resolve) => { finishCleanup = resolve; });
|
||||
const beforeShutdown = vi.fn(() => cleanup);
|
||||
const close = vi.fn(async () => undefined);
|
||||
let beforeQuit!: (event: { preventDefault(): void }) => void;
|
||||
const quit = vi.fn();
|
||||
const on = (_event: string, listener: typeof beforeQuit) => { beforeQuit = listener; };
|
||||
const exit = vi.fn();
|
||||
const endSession = vi.fn();
|
||||
const recordLifecycle = vi.fn(async (_event: unknown) => undefined);
|
||||
const sandbox = {
|
||||
updater: { recordLifecycle },
|
||||
options: { beforeShutdown }, desktop: { close }, app: { quit, on }, process: { exit },
|
||||
updateScheduler: { stop: vi.fn() }, disposeMenu: vi.fn(), removeDiagnosticsIpc: vi.fn(),
|
||||
endDesktopSessionCleanly: endSession, sessionStatePath: "test-session", console: { info: vi.fn(), error: vi.fn() },
|
||||
};
|
||||
const code = ts.transpileModule(declarations.join("\n"), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
const api = runInNewContext(`${code}\n({ shutdown, shutdownAndExit })`, sandbox) as {
|
||||
shutdown(): Promise<void>; shutdownAndExit(): void;
|
||||
};
|
||||
return { ...api, beforeShutdown, close, quit, exit, endSession, finishCleanup, beforeQuit, recordLifecycle };
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
for (let i = 0; i < 24; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("desktop shutdown", () => {
|
||||
it("does not exit early when quit is requested again during sidecar cleanup", async () => {
|
||||
const h = shutdownHarness();
|
||||
h.shutdownAndExit();
|
||||
h.shutdownAndExit();
|
||||
await flushPromises();
|
||||
expect(h.beforeShutdown).toHaveBeenCalledTimes(1);
|
||||
expect(h.exit).not.toHaveBeenCalled();
|
||||
expect(h.close).not.toHaveBeenCalled();
|
||||
expect(h.endSession).not.toHaveBeenCalled();
|
||||
h.finishCleanup();
|
||||
await flushPromises();
|
||||
expect(h.close).toHaveBeenCalledTimes(1);
|
||||
expect(h.quit).toHaveBeenCalledTimes(1);
|
||||
expect(h.exit).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("prevents native quit from bypassing pending cleanup, then allows the final quit", async () => {
|
||||
const h = shutdownHarness();
|
||||
const pending = h.shutdown();
|
||||
await flushPromises();
|
||||
const preventDefault = vi.fn();
|
||||
h.beforeQuit({ preventDefault });
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
await flushPromises();
|
||||
expect(h.exit).not.toHaveBeenCalled();
|
||||
h.finishCleanup();
|
||||
await pending;
|
||||
preventDefault.mockClear();
|
||||
h.beforeQuit({ preventDefault });
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps every shutdown caller pending until the same cleanup finishes", async () => {
|
||||
const h = shutdownHarness();
|
||||
const first = h.shutdown();
|
||||
const finished = vi.fn();
|
||||
const second = h.shutdown().then(finished);
|
||||
await flushPromises();
|
||||
expect(finished).not.toHaveBeenCalled();
|
||||
h.finishCleanup();
|
||||
await Promise.all([first, second]);
|
||||
expect(finished).toHaveBeenCalledTimes(1);
|
||||
expect(h.beforeShutdown).toHaveBeenCalledTimes(1);
|
||||
expect(h.endSession).toHaveBeenCalledTimes(1);
|
||||
expect(h.recordLifecycle.mock.calls.map(([event]) => event)).toEqual([
|
||||
{ stage: "shutdown_started", outcome: "started" },
|
||||
{ stage: "shutdown_completed", outcome: "completed", repeated_quit_count: 1, duration_ms: expect.any(Number) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("records caught cleanup failures without preventing the existing exit behavior", async () => {
|
||||
const h = shutdownHarness();
|
||||
h.beforeShutdown.mockRejectedValueOnce(new Error("cleanup failed"));
|
||||
await h.shutdown();
|
||||
expect(h.recordLifecycle).toHaveBeenLastCalledWith(expect.objectContaining({ stage: "shutdown_completed", outcome: "failed" }));
|
||||
expect(h.quit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import { expect, it, vi } from 'vitest';
|
||||
|
||||
it.each([
|
||||
{ mounted: true, failed: false, expected: 1 },
|
||||
{ mounted: false, failed: false, expected: 0 },
|
||||
{ mounted: true, failed: true, expected: 0 },
|
||||
])('reports desktop readiness only for a mounted healthy revealed app: %j', async ({ mounted, failed, expected }) => {
|
||||
const source = readFileSync(new URL('../../src/main/runtime.ts', import.meta.url), 'utf8');
|
||||
const start = source.indexOf(' const revealWhenReady = async');
|
||||
const end = source.indexOf('\n const schedule =', start);
|
||||
expect(start).toBeGreaterThan(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
let now = 0;
|
||||
const report = vi.fn();
|
||||
const code = ts.transpileModule(source.slice(start, end), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
const run = runInNewContext(`let revealed=false;let revealing=false;const revealMainWindow=()=>{revealed=true;};${code}\nrevealWhenReady`, {
|
||||
stopped: false, rendererFailed: failed, window: { isDestroyed: () => false, webContents: { executeJavaScript: async () => mounted } },
|
||||
Date: { now: () => now }, delay: async (ms: number) => { now += ms; }, setSplashStage() {}, splash: null,
|
||||
WEB_MOUNT_REVEAL_TIMEOUT_MS: 100, WEB_MOUNT_POLL_MS: 10, MIN_SPLASH_MS: 0, splashStartedAt: 0,
|
||||
options: { onMainWindowReady: report },
|
||||
}) as () => Promise<void>;
|
||||
await run();
|
||||
await run();
|
||||
expect(report).toHaveBeenCalledTimes(expected);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { writePendingInstallerObservation } from "../../src/main/installer-observations.js";
|
||||
import { findIncomingUpdateObservation, recordIncomingUpdateLifecycle, recordUpdateLifecycle } from "../../src/main/update-lifecycle-observations.js";
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), "od-update-stages-")); roots.push(root);
|
||||
const handle = await writePendingInstallerObservation({ root, flowId: "flow", platform: "darwin", arch: "arm64", artifactType: "payload", channel: "prerelease", namespace: "test", fromVersion: "0.22.2-prerelease.2", toVersion: "0.22.2-prerelease.3", attemptedAt: new Date(Date.now() - 1000).toISOString() });
|
||||
return { root, handle };
|
||||
}
|
||||
it("keeps concurrent old and new stages without modifying the legacy summary", async () => {
|
||||
const { handle } = await fixture();
|
||||
const before = await readFile(handle.summaryPath, "utf8");
|
||||
await Promise.all([
|
||||
recordUpdateLifecycle(handle, { stage: "shutdown_completed", outcome: "completed", repeated_quit_count: 2, duration_ms: 32000 }),
|
||||
recordUpdateLifecycle(handle, { stage: "desktop_ready", outcome: "completed" }),
|
||||
]);
|
||||
expect(await readFile(handle.summaryPath, "utf8")).toBe(before);
|
||||
const path = join(handle.summaryPath, "..", "lifecycle", "desktop_ready.json");
|
||||
const first = await readFile(path, "utf8");
|
||||
await recordUpdateLifecycle(handle, { stage: "desktop_ready", outcome: "failed" });
|
||||
expect(await readFile(path, "utf8")).toBe(first);
|
||||
});
|
||||
it("correlates legacy observations only to the same target version and identity", async () => {
|
||||
const { root, handle } = await fixture();
|
||||
const identity = { root, namespace: "test", channel: "prerelease", version: "0.22.2-prerelease.3" };
|
||||
expect(await findIncomingUpdateObservation(identity)).toMatchObject(handle);
|
||||
expect(await findIncomingUpdateObservation({ ...identity, version: "0.22.2-prerelease.2" })).toBeNull();
|
||||
expect(await findIncomingUpdateObservation({ ...identity, channel: "stable" })).toBeNull();
|
||||
expect(await findIncomingUpdateObservation({ ...identity, namespace: "other" })).toBeNull();
|
||||
await recordIncomingUpdateLifecycle(identity, { stage: "desktop_ready", outcome: "completed" });
|
||||
const event = JSON.parse(await readFile(join(root, "flow/lifecycle/desktop_ready.json"), "utf8"));
|
||||
expect(event.flow_id).toBe("flow");
|
||||
expect(event.observation_version).toBe(1);
|
||||
});
|
||||
it("never makes update transitions fail when the journal is unavailable", async () => {
|
||||
const { handle } = await fixture();
|
||||
await expect(recordUpdateLifecycle({ ...handle, summaryPath: join(handle.summaryPath, "unavailable", "summary.json") }, { stage: "shutdown_completed", outcome: "completed" })).resolves.toBeUndefined();
|
||||
});
|
||||
@@ -2566,6 +2566,10 @@ describe("desktop updater", () => {
|
||||
const installed = await updater.installUpdate();
|
||||
const flowIds = await readdir(observationRoot);
|
||||
const summary = JSON.parse(await readFile(join(observationRoot, flowIds[0] ?? "", "summary.json"), "utf8")) as Record<string, unknown>;
|
||||
const lifecycleRoot = join(observationRoot, flowIds[0] ?? "", "lifecycle");
|
||||
expect(JSON.parse(await readFile(join(lifecycleRoot, "install_requested.json"), "utf8"))).toMatchObject({ stage: "install_requested", outcome: "started", flow_id: flowIds[0] });
|
||||
await updater.recordLifecycle?.({ stage: "shutdown_started", outcome: "started" });
|
||||
expect(JSON.parse(await readFile(join(lifecycleRoot, "shutdown_started.json"), "utf8"))).toMatchObject({ stage: "shutdown_started", flow_id: flowIds[0] });
|
||||
const updateRoot = await realpath(join(root, "updates"));
|
||||
|
||||
expect(installed.installResult?.path).toBe(checked.downloadPath);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type SidecarStamp,
|
||||
} from "@open-design/sidecar";
|
||||
import {
|
||||
recordIncomingUpdateLifecycle,
|
||||
applyLoopbackConnectionLimitSwitch,
|
||||
applyOsLocaleSwitch,
|
||||
createSplashWindow,
|
||||
@@ -181,7 +182,13 @@ async function main(): Promise<void> {
|
||||
// An updater successor must outlive its predecessor before discovering or
|
||||
// bootstrapping a desktop in the same namespace. Otherwise it can focus
|
||||
// the quitting predecessor and exit as an ordinary duplicate launch.
|
||||
if (!headlessRequest.headless && !await waitForLauncherAfterQuit(afterQuit, initialPaths)) {
|
||||
const incomingObservation = { root: initialPaths.installerObservationRoot, namespace,
|
||||
channel: launchStamp.channel, version: namespaceConfig.appVersion };
|
||||
if (!headlessRequest.headless && afterQuit != null) {
|
||||
await recordIncomingUpdateLifecycle(incomingObservation, { stage: "predecessor_wait_started", outcome: "started" });
|
||||
}
|
||||
if (!headlessRequest.headless && !await waitForLauncherAfterQuit(afterQuit, initialPaths, console, {},
|
||||
afterQuit == null ? undefined : (event) => recordIncomingUpdateLifecycle(incomingObservation, event))) {
|
||||
app.exit(1);
|
||||
return;
|
||||
}
|
||||
@@ -394,11 +401,11 @@ async function main(): Promise<void> {
|
||||
const started = await runDesktopMain(runtime, {
|
||||
splashWindow: splash.window,
|
||||
splashStartedAt: splash.startedAt,
|
||||
async beforeShutdown() {
|
||||
async beforeShutdown(record) {
|
||||
try {
|
||||
await retireObsoleteInstalledOuter();
|
||||
} finally {
|
||||
await sidecars.close();
|
||||
await sidecars.close(record);
|
||||
}
|
||||
},
|
||||
async discoverWebUrl() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UpdateLifecycleObservation } from "@open-design/desktop/main";
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -187,14 +188,20 @@ export async function waitForLauncherAfterQuit(
|
||||
paths: PackagedNamespacePaths,
|
||||
logger: LauncherAfterQuitLogger = console,
|
||||
controls: Partial<LauncherProcessControls> = {},
|
||||
observe?: (event: UpdateLifecycleObservation) => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
if (request == null) return true;
|
||||
const startedAt = Date.now();
|
||||
const report = async (outcome: "completed" | "forced" | "failed") => {
|
||||
try { await observe?.({ stage: "predecessor_wait_completed", outcome, duration_ms: Date.now() - startedAt }); } catch {}
|
||||
};
|
||||
const waitForExit = controls.waitForExit ?? waitForProcessExit;
|
||||
const stop = controls.stopProcesses ?? stopProcesses;
|
||||
await writeLauncherAfterQuitLog(paths, `armed targetPid=${request.targetPid} timeoutMs=${request.timeoutMs}`);
|
||||
const exited = await waitForExit(request.targetPid, request.timeoutMs);
|
||||
if (exited) {
|
||||
await writeLauncherAfterQuitLog(paths, `observed-exit targetPid=${request.targetPid}`);
|
||||
await report("completed");
|
||||
return true;
|
||||
}
|
||||
// The old process outlived its quit grace and still holds the fixed socket.
|
||||
@@ -202,7 +209,9 @@ export async function waitForLauncherAfterQuit(
|
||||
const message = `timed-out targetPid=${request.targetPid}; forcing stop`;
|
||||
await writeLauncherAfterQuitLog(paths, message);
|
||||
logger.warn(`[open-design launcher] ${message}`);
|
||||
return await forceStopLingeringDesktop(request.targetPid, "after-quit-timeout", paths, logger, stop);
|
||||
const stopped = await forceStopLingeringDesktop(request.targetPid, "after-quit-timeout", paths, logger, stop);
|
||||
await report(stopped ? "forced" : "failed");
|
||||
return stopped;
|
||||
}
|
||||
|
||||
export async function inspectExistingDesktopForLauncher(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UpdateLifecycleObservation } from "@open-design/desktop/main";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { access, appendFile, mkdir, open, rename, type FileHandle } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
@@ -96,7 +97,7 @@ function shouldForwardPackagedChildEnv(key: string, includeProviderSecrets = fal
|
||||
}
|
||||
|
||||
export type PackagedSidecarHandle = {
|
||||
close(): Promise<void>;
|
||||
close(observe?: (event: UpdateLifecycleObservation) => Promise<void>): Promise<void>;
|
||||
/**
|
||||
* URL of the web sidecar that is live *right now*. `web` below is the
|
||||
* first-boot snapshot and goes stale as soon as the sidecar is
|
||||
@@ -808,7 +809,8 @@ export function createPackagedSidecarSpawnOptions(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function closeManagedChild(child: ManagedSidecarChild): Promise<void> {
|
||||
export async function closeManagedChild(child: ManagedSidecarChild, observe?: (event: UpdateLifecycleObservation) => Promise<void>): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const appendLifecycleLog = async (message: string): Promise<void> => appendSidecarLifecycleLog(child.logPath, message);
|
||||
try {
|
||||
await appendLifecycleLog(`[open-design packaged] shutdown requested app=${child.app} pid=${child.child.pid ?? "unknown"}`);
|
||||
@@ -816,6 +818,11 @@ export async function closeManagedChild(child: ManagedSidecarChild): Promise<voi
|
||||
// The sidecar generation boundary still owns escalation; packaged only
|
||||
// supplies a bounded grace appropriate for that lifecycle operation.
|
||||
const stop = await child.generation.stop({ termGraceMs: child.app === APP_KEYS.DAEMON ? 30_000 : 5_000 });
|
||||
try {
|
||||
await observe?.({ stage: child.app === APP_KEYS.DAEMON ? "cleanup_daemon" : "cleanup_web",
|
||||
outcome: stop.remainingPids.length > 0 ? "failed" : stop.forcedPids.length > 0 ? "forced" : "completed",
|
||||
duration_ms: Date.now() - startedAt, forced_process_count: stop.forcedPids.length, remaining_process_count: stop.remainingPids.length });
|
||||
} catch {}
|
||||
if (stop.forcedPids.length > 0) {
|
||||
await appendLifecycleLog(`[open-design packaged] graceful shutdown timed out app=${child.app} pid=${child.child.pid ?? "unknown"}; forced=${stop.forcedPids.join(",")}`);
|
||||
}
|
||||
@@ -899,6 +906,7 @@ export async function startPackagedSidecars(
|
||||
await mkdir(paths.electronSessionDataRoot, { recursive: true });
|
||||
|
||||
const children: ManagedSidecarChild[] = [];
|
||||
let shutdownObserver: ((event: UpdateLifecycleObservation) => Promise<void>) | undefined;
|
||||
let webSupervisor: { close(): Promise<void> } | null = null;
|
||||
|
||||
const daemonSidecarEntry =
|
||||
@@ -993,7 +1001,7 @@ export async function startPackagedSidecars(
|
||||
const daemonPort = extractPort(daemonStatus.url);
|
||||
|
||||
const supervisor = createWebSidecarSupervisor<ManagedSidecarChild, WebStatusSnapshot>({
|
||||
closeChild: closeManagedChild,
|
||||
closeChild: (child) => closeManagedChild(child, shutdownObserver),
|
||||
hasExited: (web) => web.child.exitCode !== null || web.child.signalCode !== null,
|
||||
onExit: (web, listener) => web.child.once("exit", listener),
|
||||
registerUrl: async (url) => await registerPackagedWebUrl(daemon.stamp, url),
|
||||
@@ -1042,14 +1050,15 @@ export async function startPackagedSidecars(
|
||||
daemon: daemonStatus,
|
||||
web: webStatus,
|
||||
currentWebUrl: supervisor.currentUrl,
|
||||
async close() {
|
||||
async close(observe) {
|
||||
shutdownObserver = observe;
|
||||
const closeErrors: unknown[] = [];
|
||||
await supervisor.close().catch((error: unknown) => {
|
||||
closeErrors.push(error);
|
||||
console.error("failed to close packaged web sidecar", error);
|
||||
});
|
||||
for (const child of [...children].reverse()) {
|
||||
await closeManagedChild(child).catch((error: unknown) => {
|
||||
await closeManagedChild(child, observe).catch((error: unknown) => {
|
||||
closeErrors.push(error);
|
||||
console.error(`failed to close packaged ${child.app} sidecar`, error);
|
||||
});
|
||||
|
||||
@@ -89,7 +89,7 @@ async function scenario(platform: "darwin" | "win32", channel: "stable" | "prere
|
||||
bootstrapSidecarProcess: async () => { trace.push(`bootstrap:${alive}`); return false; },
|
||||
},
|
||||
"electron": { app: { commandLine: { appendSwitch() {} }, exit: (code: number) => trace.push(`exit:${code}`) } },
|
||||
"@open-design/desktop/main": { applyOsLocaleSwitch() {}, applyLoopbackConnectionLimitSwitch() {} },
|
||||
"@open-design/desktop/main": { async recordIncomingUpdateLifecycle() {}, applyOsLocaleSwitch() {}, applyLoopbackConnectionLimitSwitch() {} },
|
||||
"./config.js": { readPackagedConfig: async () => ({ namespace, appVersion: version }) },
|
||||
"./headless-runtime.js": { parsePackagedHeadlessRequest: () => ({ headless: false }), runPackagedMcpActionAgainstExistingDaemon: async () => false },
|
||||
"./paths.js": { resolvePackagedNamespacePaths: () => paths },
|
||||
|
||||
@@ -35,11 +35,13 @@ function sidecarStop(pid = 1234): SidecarStopResult {
|
||||
describe("waitForLauncherAfterQuit", () => {
|
||||
it("logs a completed updater wait", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "od-launcher-after-quit-"));
|
||||
const observed = vi.fn(async () => { throw new Error("observation unavailable"); });
|
||||
try {
|
||||
const result = await waitForLauncherAfterQuit({ targetPid: 999999, timeoutMs: 1000 }, fakePaths(root), console, {
|
||||
waitForExit: (async () => true) as typeof waitForProcessExit,
|
||||
});
|
||||
}, observed);
|
||||
expect(result).toBe(true);
|
||||
expect(observed).toHaveBeenCalledWith(expect.objectContaining({ stage: "predecessor_wait_completed", outcome: "completed" }));
|
||||
expect(await readFile(join(root, "logs", "launcher", "after-quit.log"), "utf8")).toContain("observed-exit targetPid=999999");
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
@@ -48,11 +50,13 @@ describe("waitForLauncherAfterQuit", () => {
|
||||
|
||||
it("force-stops a pid after the updater grace expires", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "od-launcher-timeout-"));
|
||||
const observed = vi.fn(async () => undefined);
|
||||
const stop = vi.fn(async () => processStop(4242)) as unknown as typeof stopProcesses;
|
||||
try {
|
||||
await expect(waitForLauncherAfterQuit({ targetPid: 4242, timeoutMs: 1 }, fakePaths(root), console, {
|
||||
stopProcesses: stop, waitForExit: (async () => false) as typeof waitForProcessExit,
|
||||
})).resolves.toBe(true);
|
||||
}, observed)).resolves.toBe(true);
|
||||
expect(observed).toHaveBeenCalledWith(expect.objectContaining({ stage: "predecessor_wait_completed", outcome: "forced" }));
|
||||
expect(stop).toHaveBeenCalledWith([4242]);
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('packaged sidecar shutdown', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'od-sidecar-close-'));
|
||||
const logPath = join(root, 'latest.log');
|
||||
const closeLog = vi.fn(async () => undefined);
|
||||
const observe = vi.fn(async () => undefined);
|
||||
const stop = vi.fn(async () => ({
|
||||
alreadyStopped: false,
|
||||
forcedPids: [42],
|
||||
@@ -77,10 +78,11 @@ describe('packaged sidecar shutdown', () => {
|
||||
logHandle: { close: closeLog },
|
||||
logPath,
|
||||
stamp: testStamp(),
|
||||
} as unknown as Parameters<typeof closeManagedChild>[0])).rejects.toThrow(
|
||||
} as unknown as Parameters<typeof closeManagedChild>[0], observe)).rejects.toThrow(
|
||||
'failed to stop packaged daemon sidecar processes: 42',
|
||||
);
|
||||
expect(closeLog).toHaveBeenCalledOnce();
|
||||
expect(observe).toHaveBeenCalledWith({ stage: 'cleanup_daemon', outcome: 'failed', duration_ms: expect.any(Number), forced_process_count: 1, remaining_process_count: 1 });
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('shutdown requested');
|
||||
expect(readFileSync(logPath, 'utf8')).not.toContain('exited app=daemon');
|
||||
} finally {
|
||||
|
||||
@@ -27,8 +27,6 @@ import {
|
||||
} from '../analytics/events';
|
||||
import styles from './UpdaterPopup.module.css';
|
||||
|
||||
const INSTALL_HANDOFF_WATCHDOG_MS = 10_000;
|
||||
|
||||
/** Rocket badge from the update visual language, on the ready indicator. */
|
||||
function RocketBadgeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
@@ -121,7 +119,6 @@ export function UpdaterPopup({
|
||||
const t = useT();
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const actionInFlightRef = useRef(false);
|
||||
const handoffWatchdogRef = useRef<number | null>(null);
|
||||
const [model, setModel] = useState<UpdaterModel>(() => deriveUpdaterModel(null));
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [installState, setInstallState] = useState<InstallState>('idle');
|
||||
@@ -142,28 +139,6 @@ export function UpdaterPopup({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearHandoffWatchdog = useCallback(() => {
|
||||
if (handoffWatchdogRef.current == null) return;
|
||||
window.clearTimeout(handoffWatchdogRef.current);
|
||||
handoffWatchdogRef.current = null;
|
||||
}, []);
|
||||
|
||||
const recoverFromInstallerHandoff = useCallback(() => {
|
||||
handoffWatchdogRef.current = null;
|
||||
actionInFlightRef.current = false;
|
||||
setInstallState('recoverable');
|
||||
setPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
const startHandoffWatchdog = useCallback(() => {
|
||||
clearHandoffWatchdog();
|
||||
// The quit IPC can resolve before Electron has actually torn down the
|
||||
// renderer. Keep the handoff UI up, but do not leave it stuck forever.
|
||||
handoffWatchdogRef.current = window.setTimeout(recoverFromInstallerHandoff, INSTALL_HANDOFF_WATCHDOG_MS);
|
||||
}, [clearHandoffWatchdog, recoverFromInstallerHandoff]);
|
||||
|
||||
useEffect(() => clearHandoffWatchdog, [clearHandoffWatchdog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (installState !== 'idle') return;
|
||||
// Until a successful daemon GET has landed, undefined may still mean
|
||||
@@ -251,7 +226,7 @@ export function UpdaterPopup({
|
||||
|
||||
const ready = model.environment === 'desktop' && model.shouldShowControl;
|
||||
const installBusy = installState === 'opening' || installState === 'handoff' || installState === 'quitting';
|
||||
const quitRecoverable = installState === 'recoverable' || installState === 'quitting';
|
||||
const quitRecoverable = installState === 'recoverable';
|
||||
const canStartInstall = ready || installState === 'recoverable';
|
||||
const showControl = ready || installState !== 'idle';
|
||||
const installFailureText = model.canOpenInstaller ? t('updater.openFailedFallback') : t('updater.failed');
|
||||
@@ -333,7 +308,6 @@ export function UpdaterPopup({
|
||||
const installAndQuit = async () => {
|
||||
if (actionInFlightRef.current || !canStartInstall) return;
|
||||
actionInFlightRef.current = true;
|
||||
clearHandoffWatchdog();
|
||||
setInstallError(null);
|
||||
setInstallState('opening');
|
||||
setPanelOpen(true);
|
||||
@@ -382,8 +356,9 @@ export function UpdaterPopup({
|
||||
}
|
||||
setModel(result.model);
|
||||
setInstallError(null);
|
||||
// An accepted quit may still be draining background processes. Only a
|
||||
// failed quit response permits recovery; elapsed time is not failure.
|
||||
setInstallState('handoff');
|
||||
startHandoffWatchdog();
|
||||
trackUpdateInstallResult(analytics.track, {
|
||||
page_name: 'home',
|
||||
area: 'update_prompt',
|
||||
@@ -394,13 +369,11 @@ export function UpdaterPopup({
|
||||
if (!quitResult.ok) {
|
||||
const quitSafety = restartSafetyFromActionResult(quitResult);
|
||||
if (quitSafety != null) setInstallError(restartSafetyText(t, quitSafety));
|
||||
clearHandoffWatchdog();
|
||||
actionInFlightRef.current = false;
|
||||
setInstallState('recoverable');
|
||||
setPanelOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
clearHandoffWatchdog();
|
||||
actionInFlightRef.current = false;
|
||||
setInstallError(installFailureText);
|
||||
setInstallState('idle');
|
||||
@@ -417,16 +390,14 @@ export function UpdaterPopup({
|
||||
const retryQuit = async () => {
|
||||
if (actionInFlightRef.current || installState !== 'recoverable') return;
|
||||
actionInFlightRef.current = true;
|
||||
clearHandoffWatchdog();
|
||||
setInstallError(null);
|
||||
setInstallState('quitting');
|
||||
startHandoffWatchdog();
|
||||
try {
|
||||
const quitResult = await quitAfterUpdaterInstallerOpen({ payload: { source: 'updater-prompt' } });
|
||||
if (quitResult.ok) return;
|
||||
} catch {
|
||||
// Keep the explicit quit recovery action available.
|
||||
}
|
||||
clearHandoffWatchdog();
|
||||
actionInFlightRef.current = false;
|
||||
setInstallState('recoverable');
|
||||
setPanelOpen(true);
|
||||
|
||||
@@ -486,67 +486,56 @@ describe('UpdaterPopup', () => {
|
||||
expect(screen.getByRole('button', { name: 'Later' }).getAttribute('disabled')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('recovers the handoff prompt if the app has not closed after the watchdog', async () => {
|
||||
const install = vi.fn(async () => downloadedStatus({
|
||||
installResult: {
|
||||
dryRun: true,
|
||||
openedAt: '2026-05-19T00:00:00.000Z',
|
||||
path: '/tmp/open-design-updater/Open Design Beta.dmg',
|
||||
},
|
||||
it.each(['darwin', 'win32'] as const)('keeps accepted %s payload shutdown pending beyond the cleanup grace', async (platform) => {
|
||||
const status = payloadDownloadedStatus({ platform });
|
||||
const install = vi.fn(async () => ({
|
||||
...status,
|
||||
installResult: { openedAt: '2026-09-10T05:20:14Z', path: status.downloadPath! },
|
||||
}));
|
||||
const quit = vi.fn(async () => ({ ok: true as const }));
|
||||
restoreHost = installMockOpenDesignHost({
|
||||
host: {
|
||||
updater: {
|
||||
install,
|
||||
quit,
|
||||
status: vi.fn(async () => downloadedStatus()),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
restoreHost = installMockOpenDesignHost({ host: { updater: {
|
||||
install, quit, status: vi.fn(async () => status),
|
||||
} } });
|
||||
render(<UpdaterPopup />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('entry-nav-updater'));
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
fireEvent.click(screen.getByTestId('updater-install-button'));
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Opening installer...' }).getAttribute('disabled')).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Could not quit' })).toBeTruthy();
|
||||
expect(screen.getByTestId('updater-install-button').textContent).toBe('Quit OpenDesign');
|
||||
expect(screen.getByTestId('updater-install-button').getAttribute('disabled')).toBeNull();
|
||||
fireEvent.click(screen.getByTestId('updater-install-button'));
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByTestId('updater-install-button')); });
|
||||
for (const elapsed of [10_000, 22_000, 28_000]) {
|
||||
await act(async () => { vi.advanceTimersByTime(elapsed); });
|
||||
expect(screen.queryByRole('dialog', { name: 'Could not quit' })).toBeNull();
|
||||
expect(screen.getByTestId('updater-install-button').getAttribute('disabled')).not.toBeNull();
|
||||
fireEvent.click(screen.getByTestId('updater-install-button'));
|
||||
}
|
||||
expect(install).toHaveBeenCalledTimes(1);
|
||||
expect(quit).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByTestId('updater-install-button').getAttribute('disabled')).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('updater-install-button').getAttribute('disabled')).toBeNull();
|
||||
expect(quit).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows retry after an explicit quit refusal without reopening the installer', async () => {
|
||||
const status = payloadDownloadedStatus();
|
||||
const install = vi.fn(async () => ({
|
||||
...status,
|
||||
installResult: { openedAt: '2026-09-10T05:20:14Z', path: status.downloadPath! },
|
||||
}));
|
||||
const quit = vi.fn().mockResolvedValueOnce({ ok: false, reason: 'desktop quit is not available' })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
restoreHost = installMockOpenDesignHost({ host: { updater: {
|
||||
install, quit, status: vi.fn(async () => status),
|
||||
} } });
|
||||
render(<UpdaterPopup />);
|
||||
fireEvent.click(await screen.findByTestId('entry-nav-updater'));
|
||||
fireEvent.click(screen.getByTestId('updater-install-button'));
|
||||
await waitFor(() => expect(screen.getByRole('dialog', { name: 'Could not quit' })).toBeTruthy());
|
||||
fireEvent.click(screen.getByTestId('updater-install-button'));
|
||||
await waitFor(() => expect(quit).toHaveBeenCalledTimes(2));
|
||||
expect(install).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('dialog', { name: 'Could not quit' })).toBeNull();
|
||||
expect(screen.getByTestId('updater-install-button').getAttribute('disabled')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows install failures and leaves the ready prompt usable', async () => {
|
||||
const install = vi.fn(async () => downloadedStatus({
|
||||
error: {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Update lifecycle observations
|
||||
|
||||
`update_install_result` remains an installer/payload request result, not a restart acknowledgement. `update_apply_observed` remains a later daemon's version-match observation, not proof that a desktop window became usable. Neither event's historical meaning changes.
|
||||
|
||||
`update_lifecycle_observed` adds a finite set of stages joined by the existing installer observation `flow_id`. It is emitted through the daemon's existing consent-gated PostHog client, not the safety-event consent bypass.
|
||||
|
||||
| Stage | Meaning | Detail |
|
||||
|---|---|---|
|
||||
| install_requested | A persisted install attempt was created | Not installation success |
|
||||
| shutdown_started | The desktop entered its single shared teardown | Not proof that it exited |
|
||||
| cleanup_daemon / cleanup_web | The owned sidecar cleanup returned | Duration, forced and remaining process counts |
|
||||
| shutdown_completed | Desktop teardown reached its final boundary | Duration and repeated quit count; failed if a caught teardown error occurred |
|
||||
| predecessor_wait_started | An updater successor started waiting for the old desktop | Only present on after-quit launches |
|
||||
| predecessor_wait_completed | The predecessor gate resolved | Normal completion, forced termination, or failure; duration |
|
||||
| desktop_ready | The web mount signal was observed and a healthy main window was revealed | A splash timeout or crash fallback does not count |
|
||||
|
||||
Common properties: `flow_id`, `from_version`, `to_version`, `channel`, `platform`, `arch`, `occurred_at`, `observation_version=1`. Stage-specific fields are nonnegative bounded integer milliseconds/counts. No process IDs, file paths, raw errors, credentials or task content are added to these events.
|
||||
|
||||
## Persistence and delivery
|
||||
|
||||
Each stage is written once to an immutable file beside the existing installer observation. Different stages never rewrite the shared summary. Creation uses a completed temporary file and an exclusive link; duplicate callbacks keep the first observation. Telemetry I/O is best effort with a 100 ms caller budget, and cannot turn an updater operation into a failure.
|
||||
|
||||
The daemon scans at startup and every ten seconds. This catches stages written after daemon startup (particularly desktop readiness), and replays old shutdown stages on a subsequent launch. Collection is stopped with the existing telemetry disposal. Only observations within seven days and the same channel/namespace are considered.
|
||||
|
||||
A persisted receipt records `queued` only after the analytics client returns a local queue acknowledgement. This is **not** a PostHog ingestion acknowledgement. Failures retain the stage for retry; the deterministic `$insert_id` is `update_lifecycle_observed:<flow_id>:<stage>`. Opted-out/disabled observations receive skipped receipts and are not replayed later after consent changes.
|
||||
|
||||
## Queries and interpretation
|
||||
|
||||
Group by `flow_id` and slice by from/to version, channel, platform and architecture. Use `occurred_at` for lifecycle ordering; a replay's ingestion time is not the original event time.
|
||||
|
||||
- Predecessor-wait latency: duration percentiles of predecessor_wait_completed; split normal/forced/failed.
|
||||
- Shutdown latency and retries: duration percentiles and repeated_quit_count of shutdown_completed, split by outcome.
|
||||
- Forced cleanup fraction: forced cleanup events divided by observed cleanup completions, separately for daemon and web.
|
||||
- Desktop readiness coverage: distinct flows with desktop_ready divided by observed install-request flows, with a defined observation horizon and matching instrumentation versions. Report missing stages as **unknown**, not failure.
|
||||
- Version-apply success and desktop readiness are separate outcomes. The existing update_apply_observed event supplies the former; do not substitute one for the other.
|
||||
|
||||
Old versions can supply their existing flow summaries to the new target but cannot retroactively report their shutdown stages. A client that never starts again or never sends telemetry remains unobserved. This is an observed-cohort metric, not an unbiased success rate for all installations. Missing records, local queue loss and telemetry opt-out must not be counted as failed updates.
|
||||
|
||||
Zero remaining processes means the cleanup primitive found no survivors within its owned scope. It is not proof of no unrelated/orphaned processes or of task/data integrity. No claim is made that collection shortens shutdown or rescues an already failed client.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Exercise both macOS and Windows with a source build containing this instrumentation, then upgrade to the next instrumented candidate. Check flow continuity, normal and forced predecessor waits, slow cleanup, repeated quit requests, and mounted window readiness. Repeat with metrics disabled and with enqueue failure/recovery. Do not use normal launch or a crash-screen reveal as evidence of successful update restart.
|
||||
@@ -32,6 +32,7 @@ export type AnalyticsEventName =
|
||||
| 'update_install_result'
|
||||
| 'update_check_result'
|
||||
| 'update_apply_observed'
|
||||
| 'update_lifecycle_observed'
|
||||
// Packaged startup failure — emitted by the packaged MAIN process (not the
|
||||
// daemon) when daemon/web sidecars die before reporting status, i.e. the
|
||||
// pre-daemon crash class that produces zero telemetry today (issue #4638).
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UpdateLifecycleObservedProps } from '../update-lifecycle.js';
|
||||
/**
|
||||
* @module analytics/events/event-payload
|
||||
* Discriminated union of all analytics event payloads.
|
||||
@@ -40,6 +41,7 @@ export type AnalyticsEventPayload =
|
||||
| { event: 'update_install_result'; props: UpdateInstallResultProps }
|
||||
| { event: 'update_check_result'; props: UpdateCheckResultProps }
|
||||
| { event: 'update_apply_observed'; props: UpdateApplyObservedProps }
|
||||
| { event: 'update_lifecycle_observed'; props: UpdateLifecycleObservedProps }
|
||||
| { event: 'file_upload_result'; props: FileUploadResultProps }
|
||||
| { event: 'context_link_result'; props: ContextLinkResultProps }
|
||||
| { event: 'speaker_notes_save_result'; props: SpeakerNotesSaveResultProps }
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './artifact-id.js';
|
||||
export * from './observability.js';
|
||||
export * from './client-observability.js';
|
||||
export * from './run-schema-v4.js';
|
||||
export * from './update-lifecycle.js';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Finite, content-free stages of one persisted updater flow. */
|
||||
export const UPDATE_LIFECYCLE_STAGES = [
|
||||
'install_requested', 'shutdown_started', 'shutdown_completed',
|
||||
'cleanup_daemon', 'cleanup_web', 'predecessor_wait_started',
|
||||
'predecessor_wait_completed', 'desktop_ready',
|
||||
] as const;
|
||||
export type UpdateLifecycleStage = typeof UPDATE_LIFECYCLE_STAGES[number];
|
||||
export type UpdateLifecycleObservation = {
|
||||
stage: UpdateLifecycleStage;
|
||||
outcome: 'started' | 'completed' | 'forced' | 'failed';
|
||||
duration_ms?: number;
|
||||
repeated_quit_count?: number;
|
||||
forced_process_count?: number;
|
||||
remaining_process_count?: number;
|
||||
};
|
||||
export type UpdateLifecycleObservedProps = UpdateLifecycleObservation & {
|
||||
flow_id: string;
|
||||
from_version: string;
|
||||
to_version: string;
|
||||
channel: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
occurred_at: string;
|
||||
observation_version: 1;
|
||||
};
|
||||
|
||||
/** Project only bounded metrics; never forward paths, PIDs, messages or extra keys. */
|
||||
export function parseUpdateLifecycleObservation(value: unknown): UpdateLifecycleObservation | null {
|
||||
if (value == null || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (!(UPDATE_LIFECYCLE_STAGES as readonly unknown[]).includes(record.stage) ||
|
||||
!['started', 'completed', 'forced', 'failed'].includes(String(record.outcome))) return null;
|
||||
const result: UpdateLifecycleObservation = {
|
||||
stage: record.stage as UpdateLifecycleStage,
|
||||
outcome: record.outcome as UpdateLifecycleObservation['outcome'],
|
||||
};
|
||||
for (const key of ['duration_ms', 'repeated_quit_count', 'forced_process_count', 'remaining_process_count'] as const) {
|
||||
const value = record[key];
|
||||
if (value !== undefined) {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0 || value > 604_800_000) return null;
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user