diff --git a/electron/main/features/activityWindow/ActivityWindowService.ts b/electron/main/features/activityWindow/ActivityWindowService.ts index 085ab2e..2db6949 100644 --- a/electron/main/features/activityWindow/ActivityWindowService.ts +++ b/electron/main/features/activityWindow/ActivityWindowService.ts @@ -5,6 +5,10 @@ import { powerMonitor, screen } from "electron"; import { v4 as uuid } from "uuid"; import { SELF_APP_BUNDLE_ID } from "../../../shared/appIdentity"; import type { CaptureResult } from "../../../shared/types"; +import { + createTimedLease, + type TimedLease, +} from "../../infra/async/timedLease"; import { createLogger } from "../../infra/log"; import { createPerfTracker } from "../../infra/log/perf"; import { @@ -29,6 +33,7 @@ const MIN_STABLE_MS = 10_000; const MIN_DOMINANT_TOTAL_MS = 10_000; const IDLE_AWAY_SECONDS = 5 * 60; const IDLE_BUNDLE_ID = "__idle__"; +const CAPTURE_IN_FLIGHT_TIMEOUT_MS = 60_000; type Segment = ActivitySegment; @@ -82,7 +87,7 @@ type ServiceState = { } | null; pollTimer: NodeJS.Timeout | null; pollInFlight: Promise | null; - captureInFlight: Promise | null; + captureInFlight: TimedLease | null; }; const state: ServiceState = { @@ -118,6 +123,58 @@ async function withWindowLock(fn: () => Promise): Promise { } } +function releaseCaptureInFlight(lease: TimedLease): void { + if (state.captureInFlight === lease) { + state.captureInFlight = null; + } + lease.release(); +} + +function forceReleaseStaleCaptureInFlight(now = Date.now()): boolean { + const lease = state.captureInFlight; + if (!lease || !lease.hasTimedOut(now)) return false; + + logger.warn("Activity window capture held too long, force releasing", { + leaseId: lease.id, + label: lease.label, + heldForMs: lease.heldForMs(now), + timeoutMs: CAPTURE_IN_FLIGHT_TIMEOUT_MS, + }); + releaseCaptureInFlight(lease); + return true; +} + +function createCaptureInFlightLease(targetKey: string): TimedLease { + const lease = createTimedLease({ + label: `activity-window:${targetKey}`, + timeoutMs: CAPTURE_IN_FLIGHT_TIMEOUT_MS, + onTimeout(expiredLease, heldForMs) { + if (state.captureInFlight !== expiredLease) return; + logger.warn("Activity window capture held too long, force releasing", { + leaseId: expiredLease.id, + label: expiredLease.label, + heldForMs, + timeoutMs: CAPTURE_IN_FLIGHT_TIMEOUT_MS, + }); + state.captureInFlight = null; + }, + }); + + state.captureInFlight = lease; + return lease; +} + +function hasActiveCaptureInFlightLease(lease: TimedLease): boolean { + return state.captureInFlight === lease && !lease.isReleased(); +} + +async function waitForCaptureInFlight(): Promise { + forceReleaseStaleCaptureInFlight(); + const lease = state.captureInFlight; + if (!lease) return; + await lease.done; +} + function buildKey( displayId: string, bundleId: string, @@ -291,15 +348,24 @@ async function captureCandidate(target: { if (state.status !== "running") return; if (powerMonitor.getSystemIdleTime() > IDLE_AWAY_SECONDS) return; if (state.candidates.has(target.key)) return; + forceReleaseStaleCaptureInFlight(); + if (state.captureInFlight) return; if (!state.windowThumbnailsDir || !state.windowOriginalsDir) return; - let release!: () => void; - state.captureInFlight = new Promise((resolve) => { - release = resolve; - }); + const thumbnailsDir = state.windowThumbnailsDir; + const originalsDir = state.windowOriginalsDir; + const lease = createCaptureInFlightLease(target.key); try { const context = await collectActivityContext(); + if (!hasActiveCaptureInFlightLease(lease)) { + logger.warn("Ignoring stale activity window capture result", { + leaseId: lease.id, + targetKey: target.key, + stage: "context", + }); + return; + } if (state.status !== "running") return; if (!context) return; if (context.app.bundleId !== target.bundleId) return; @@ -319,11 +385,19 @@ async function captureCandidate(target: { const captures = await captureAllDisplays({ highResDisplayId: primaryDisplayId, dirs: { - thumbnailsDir: state.windowThumbnailsDir, - originalsDir: state.windowOriginalsDir, + thumbnailsDir, + originalsDir, }, }); + if (!hasActiveCaptureInFlightLease(lease)) { + logger.warn("Ignoring stale activity window capture result", { + leaseId: lease.id, + targetKey: target.key, + stage: "capture", + }); + return; + } if (state.status !== "running") return; if (captures.length === 0) return; @@ -338,8 +412,7 @@ async function captureCandidate(target: { } catch (error) { logger.debug("Candidate capture failed", { error }); } finally { - release(); - state.captureInFlight = null; + releaseCaptureInFlight(lease); if (perf.enabled) perf.track("activity.captureCandidate", performance.now() - startedAt); } @@ -432,6 +505,7 @@ async function pollOnce(): Promise { if (state.candidates.has(key)) return; if (capturedAt - state.current.startAt < MIN_STABLE_MS) return; + forceReleaseStaleCaptureInFlight(); if (state.captureInFlight) return; void captureCandidate({ key, bundleId, displayId, urlHost }); @@ -469,7 +543,7 @@ export function stopActivityWindowTracking(): void { state.status = "stopped"; const windowDir = state.windowDir; if (state.captureInFlight) { - void state.captureInFlight.finally(() => safeRm(windowDir)); + void state.captureInFlight.done.finally(() => safeRm(windowDir)); } else { void safeRm(windowDir); } @@ -524,7 +598,7 @@ export function getLastKnownCandidate(): { export async function discardActivityWindow(windowEnd: number): Promise { await withWindowLock(async () => { const safeWindowEnd = Math.max(state.windowStart, windowEnd); - if (state.captureInFlight) await state.captureInFlight; + await waitForCaptureInFlight(); const continuation = state.lastSnapshot; await safeRm(state.windowDir); await initWindow(safeWindowEnd, continuation); @@ -547,7 +621,7 @@ export async function finalizeActivityWindow( }; } - if (state.captureInFlight) await state.captureInFlight; + await waitForCaptureInFlight(); const finalized: Segment[] = [...state.segments]; if (state.current) diff --git a/electron/main/features/activityWindow/__tests__/ActivityWindowService.test.ts b/electron/main/features/activityWindow/__tests__/ActivityWindowService.test.ts new file mode 100644 index 0000000..2ca4180 --- /dev/null +++ b/electron/main/features/activityWindow/__tests__/ActivityWindowService.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const powerMonitor = { getSystemIdleTime: vi.fn(() => 0) }; +const screen = { getPrimaryDisplay: vi.fn(() => ({ id: 1 })) }; + +vi.mock("electron", () => ({ + powerMonitor, + screen, +})); + +const mkdir = vi.fn(async () => undefined); +const readdir = vi.fn(async () => []); +const rename = vi.fn(async () => undefined); +const rm = vi.fn(async () => undefined); +vi.mock("node:fs/promises", () => ({ + mkdir, + readdir, + rename, + rm, +})); + +vi.mock("uuid", () => ({ + v4: vi.fn(() => "window-id"), +})); + +vi.mock("../../../infra/paths", () => ({ + getOriginalsDir: () => "/tmp/originals", + getTempCapturesDir: () => "/tmp/temp-captures", + getThumbnailsDir: () => "/tmp/thumbnails", +})); + +vi.mock("../../../infra/settings", () => ({ + getSettings: () => ({ automationRules: { apps: {}, hosts: {} } }), +})); + +vi.mock("../../automationRules", () => ({ + evaluateAutomationPolicy: () => ({ + capture: "allow", + llm: "allow", + overrides: {}, + }), +})); + +const captureAllDisplays = vi.fn(); +vi.mock("../../capture", () => ({ + captureAllDisplays, +})); + +const collectActivityContext = vi.fn(); +const collectForegroundSnapshot = vi.fn(); +vi.mock("../../context", () => ({ + collectActivityContext, + collectForegroundSnapshot, +})); + +vi.mock("../../context/providers", () => ({ + chromiumProvider: { + supports: vi.fn(() => false), + }, + safariProvider: { + supports: vi.fn(() => false), + }, +})); + +describe("ActivityWindowService", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + collectForegroundSnapshot.mockImplementation(async () => ({ + capturedAt: Date.now(), + app: { + bundleId: "com.test.app", + name: "Test App", + }, + window: { + displayId: "1", + title: "Test Window", + isFullscreen: false, + }, + })); + + collectActivityContext.mockImplementation(async () => ({ + key: "1::app:com.test.app", + provider: "test", + confidence: 1, + app: { + bundleId: "com.test.app", + name: "Test App", + }, + window: { + displayId: "1", + title: "Test Window", + isFullscreen: false, + }, + url: null, + content: null, + })); + }); + + afterEach(async () => { + try { + const { stopActivityWindowTracking } = await import( + "../ActivityWindowService" + ); + stopActivityWindowTracking(); + } catch {} + vi.useRealTimers(); + }); + + it("releases stale in-flight candidate captures so finalization can recover", async () => { + captureAllDisplays.mockImplementationOnce( + () => new Promise(() => undefined), + ); + captureAllDisplays.mockResolvedValue([]); + + const { finalizeActivityWindow, startActivityWindowTracking } = + await import("../ActivityWindowService"); + + startActivityWindowTracking(); + await Promise.resolve(); + await Promise.resolve(); + + await vi.advanceTimersByTimeAsync(12_000); + expect(captureAllDisplays).toHaveBeenCalledTimes(1); + + const finalizePromise = finalizeActivityWindow(Date.now()); + + await vi.advanceTimersByTimeAsync(60_000); + + await expect(finalizePromise).resolves.toMatchObject({ + kind: "skip", + reason: "no-candidate", + }); + expect(captureAllDisplays).toHaveBeenCalledTimes(2); + }); +}); diff --git a/electron/main/features/scheduler/SchedulerService.ts b/electron/main/features/scheduler/SchedulerService.ts index 06cad8d..ff96f07 100644 --- a/electron/main/features/scheduler/SchedulerService.ts +++ b/electron/main/features/scheduler/SchedulerService.ts @@ -4,6 +4,10 @@ import type { CaptureIntent, CaptureTriggerResult, } from "../../../shared/types"; +import { + createTimedLease, + type TimedLease, +} from "../../infra/async/timedLease"; import { updateEvent } from "../../infra/db/repositories/EventRepository"; import { createLogger } from "../../infra/log"; import { getCaptureInterval, getSettings } from "../../infra/settings"; @@ -35,11 +39,12 @@ type ManualCaptureOptions = { const DEFAULT_INTERVAL_MINUTES = 5; const IDLE_SKIP_SECONDS = 5 * 60; +const CAPTURE_LEASE_TIMEOUT_MS = 60_000; let state: SchedulerState = "stopped"; let captureInterval: NodeJS.Timeout | null = null; let currentIntervalMinutes = DEFAULT_INTERVAL_MINUTES; -let captureLock: Promise | null = null; +let captureLease: TimedLease | null = null; type CapturedWindow = Extract; @@ -47,6 +52,93 @@ function getIntervalMs(): number { return currentIntervalMinutes * 60 * 1000; } +function releaseCaptureLease(lease: TimedLease): void { + if (captureLease === lease) { + captureLease = null; + } + lease.release(); +} + +function forceReleaseStaleCaptureLease(now = Date.now()): boolean { + const lease = captureLease; + if (!lease || !lease.hasTimedOut(now)) return false; + + logger.warn("Capture lease held too long, force releasing", { + leaseId: lease.id, + label: lease.label, + heldForMs: lease.heldForMs(now), + timeoutMs: CAPTURE_LEASE_TIMEOUT_MS, + }); + releaseCaptureLease(lease); + return true; +} + +function createCaptureLease(label: "manual" | "scheduled"): TimedLease { + const lease = createTimedLease({ + label, + timeoutMs: CAPTURE_LEASE_TIMEOUT_MS, + onTimeout(expiredLease, heldForMs) { + if (captureLease !== expiredLease) return; + logger.warn("Capture lease held too long, force releasing", { + leaseId: expiredLease.id, + label: expiredLease.label, + heldForMs, + timeoutMs: CAPTURE_LEASE_TIMEOUT_MS, + }); + captureLease = null; + }, + }); + + captureLease = lease; + return lease; +} + +async function acquireCaptureLease( + label: "manual" | "scheduled", + waitForActive: boolean, +): Promise { + for (;;) { + forceReleaseStaleCaptureLease(); + + const activeLease = captureLease; + if (!activeLease) { + return createCaptureLease(label); + } + + if (!waitForActive) { + logger.debug("Capture already in progress, skipping"); + return null; + } + + logger.debug("Capture already in progress, waiting", { + activeLeaseId: activeLease.id, + label: activeLease.label, + heldForMs: activeLease.heldForMs(), + timeoutMs: CAPTURE_LEASE_TIMEOUT_MS, + }); + await activeLease.done; + } +} + +function hasActiveCaptureLease(lease: TimedLease): boolean { + return captureLease === lease && !lease.isReleased(); +} + +function bailOnExpiredCaptureLease( + lease: TimedLease, + reason: "manual" | "scheduled", +): CaptureTriggerResult | null { + if (hasActiveCaptureLease(lease)) return null; + + logger.warn("Capture lease expired before cycle completed, dropping result", { + leaseId: lease.id, + label: lease.label, + reason, + timeoutMs: CAPTURE_LEASE_TIMEOUT_MS, + }); + return { merged: false, eventId: null }; +} + async function processCapturedWindow( windowed: CapturedWindow, ): Promise { @@ -73,16 +165,11 @@ async function processCapturedWindow( } async function runWindowedCaptureCycle(): Promise { - if (captureLock) { - logger.debug("Capture already in progress, skipping"); + const lease = await acquireCaptureLease("scheduled", false); + if (!lease) { return { merged: false, eventId: null }; } - let releaseLock!: () => void; - captureLock = new Promise((resolve) => { - releaseLock = resolve; - }); - try { const hasPermission = checkScreenCapturePermission(); const idleTime = powerMonitor.getSystemIdleTime(); @@ -107,7 +194,11 @@ async function runWindowedCaptureCycle(): Promise { }); const windowed = await finalizeActivityWindow(idleStartAt); + const expiredResult = bailOnExpiredCaptureLease(lease, "scheduled"); + if (expiredResult) return expiredResult; await discardActivityWindow(windowEnd); + const expiredAfterDiscard = bailOnExpiredCaptureLease(lease, "scheduled"); + if (expiredAfterDiscard) return expiredAfterDiscard; if (windowed.kind !== "capture") { logger.info("Scheduled capture skipped (idle)", { reason: windowed.reason, @@ -118,6 +209,8 @@ async function runWindowedCaptureCycle(): Promise { } const windowed = await finalizeActivityWindow(windowEnd); + const expiredResult = bailOnExpiredCaptureLease(lease, "scheduled"); + if (expiredResult) return expiredResult; if (windowed.kind !== "capture") { logger.info("Scheduled capture skipped", { reason: windowed.reason }); return { merged: false, eventId: null }; @@ -127,8 +220,7 @@ async function runWindowedCaptureCycle(): Promise { }); return await processCapturedWindow(windowed); } finally { - releaseLock(); - captureLock = null; + releaseCaptureLease(lease); } } @@ -136,20 +228,11 @@ async function runCaptureCycle( reason: "scheduled" | "manual", options?: ManualCaptureOptions, ): Promise { - if (captureLock) { - if (reason === "scheduled") { - logger.debug("Capture already in progress, skipping"); - return { merged: false, eventId: null }; - } - logger.debug("Capture already in progress, waiting"); - await captureLock; + const lease = await acquireCaptureLease(reason, reason === "manual"); + if (!lease) { + return { merged: false, eventId: null }; } - let releaseLock!: () => void; - captureLock = new Promise((resolve) => { - releaseLock = resolve; - }); - try { const hasPermission = checkScreenCapturePermission(); const idleTime = powerMonitor.getSystemIdleTime(); @@ -174,6 +257,8 @@ async function runCaptureCycle( reason === "manual" ? (getLastKnownCandidate()?.context ?? null) : await collectActivityContext(); + const expiredAfterContext = bailOnExpiredCaptureLease(lease, reason); + if (expiredAfterContext) return expiredAfterContext; const isSelfCapture = context?.app.bundleId === SELF_APP_BUNDLE_ID; if (reason === "scheduled" && isSelfCapture) { @@ -209,6 +294,8 @@ async function runCaptureCycle( const captures = await captureAllDisplays({ highResDisplayId: primaryDisplayId, }); + const expiredAfterCapture = bailOnExpiredCaptureLease(lease, reason); + if (expiredAfterCapture) return expiredAfterCapture; logger.info(`Captured ${captures.length} displays`); const intervalMs = getIntervalMs(); @@ -249,8 +336,7 @@ async function runCaptureCycle( logger.error(`Capture cycle (${reason}) failed:`, error); throw error; } finally { - releaseLock(); - captureLock = null; + releaseCaptureLease(lease); } } diff --git a/electron/main/features/scheduler/__tests__/SchedulerService.test.ts b/electron/main/features/scheduler/__tests__/SchedulerService.test.ts index e9033fe..98370b8 100644 --- a/electron/main/features/scheduler/__tests__/SchedulerService.test.ts +++ b/electron/main/features/scheduler/__tests__/SchedulerService.test.ts @@ -77,13 +77,14 @@ vi.mock("../../context", () => ({ })); describe("SchedulerService (manual capture)", () => { - afterEach(() => { - vi.useRealTimers(); - }); - beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + vi.useRealTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); }); it("broadcasts permission required and does not capture when permission is missing", async () => { @@ -216,4 +217,73 @@ describe("SchedulerService (manual capture)", () => { expect(logger.error).toHaveBeenCalledWith("Scheduler tick failed", error); }); + + it("force releases a stale manual capture lease so the next manual capture can proceed", async () => { + vi.useFakeTimers(); + + checkScreenCapturePermission.mockReturnValue(true); + captureAllDisplays.mockImplementationOnce( + () => new Promise(() => undefined), + ); + captureAllDisplays.mockResolvedValue([ + { + id: "s2", + timestamp: 2, + displayId: "D1", + thumbnailPath: "/tmp/t2.webp", + originalPath: "/tmp/o2.webp", + stableHash: "1".repeat(16), + detailHash: "1".repeat(64), + width: 100, + height: 100, + }, + ]); + processCaptureGroup.mockResolvedValue({ merged: false, eventId: "e2" }); + + const { triggerManualCaptureWithPrimaryDisplay } = await import( + "../SchedulerService" + ); + + void triggerManualCaptureWithPrimaryDisplay({ + primaryDisplayId: "D1", + intent: "default", + }); + + const secondCapturePromise = triggerManualCaptureWithPrimaryDisplay({ + primaryDisplayId: "D1", + intent: "default", + }); + + await vi.advanceTimersByTimeAsync(60_000); + + await expect(secondCapturePromise).resolves.toEqual({ + merged: false, + eventId: "e2", + }); + expect(captureAllDisplays).toHaveBeenCalledTimes(2); + }); + + it("recovers scheduled captures after a stale windowed capture lease expires", async () => { + vi.useFakeTimers(); + + finalizeActivityWindow + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockResolvedValue({ + kind: "skip", + windowStart: 0, + windowEnd: 0, + reason: "no-data", + }); + discardActivityWindow.mockResolvedValue(undefined); + + const { startScheduler, stopScheduler } = await import( + "../SchedulerService" + ); + + startScheduler(1); + await vi.advanceTimersByTimeAsync(181_000); + stopScheduler(); + + expect(finalizeActivityWindow.mock.calls.length).toBeGreaterThanOrEqual(2); + }); }); diff --git a/electron/main/infra/async/timedLease.ts b/electron/main/infra/async/timedLease.ts new file mode 100644 index 0000000..9bb20e9 --- /dev/null +++ b/electron/main/infra/async/timedLease.ts @@ -0,0 +1,71 @@ +export interface TimedLease { + readonly id: number; + readonly label: string; + readonly startedAt: number; + readonly timeoutMs: number; + readonly done: Promise; + hasTimedOut(now?: number): boolean; + heldForMs(now?: number): number; + isReleased(): boolean; + release(): boolean; +} + +type CreateTimedLeaseOptions = { + label: string; + timeoutMs: number; + onTimeout?: (lease: TimedLease, heldForMs: number) => void; +}; + +let nextLeaseId = 1; + +export function createTimedLease(options: CreateTimedLeaseOptions): TimedLease { + const { label, timeoutMs, onTimeout } = options; + const startedAt = Date.now(); + + let resolveDone!: () => void; + let released = false; + let timeout: NodeJS.Timeout | null = null; + let lease!: TimedLease; + + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + + const release = (): boolean => { + if (released) return false; + released = true; + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + resolveDone(); + return true; + }; + + lease = { + id: nextLeaseId++, + label, + startedAt, + timeoutMs, + done, + hasTimedOut(now = Date.now()) { + return now - startedAt >= timeoutMs; + }, + heldForMs(now = Date.now()) { + return Math.max(0, now - startedAt); + }, + isReleased() { + return released; + }, + release, + }; + + timeout = setTimeout(() => { + if (released) return; + onTimeout?.(lease, lease.heldForMs()); + release(); + }, timeoutMs); + timeout.unref?.(); + + return lease; +}