diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index e3d848405f0..612b5fcada1 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -11,6 +11,7 @@ import { writeSecureJsonFile } from '../../shared/secure-file' import type { DeviceScope } from '../../shared/runtime-types' +import { removeStaleDurableWriteTempFiles } from '../durable-file-write' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' @@ -61,6 +62,7 @@ function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding // Why: a lastSeen refresh is pure bookkeeping, so coalesce reconnect bursts into one write instead of // paying a secure-file rewrite (two synchronous PowerShell ACL spawns on Windows) per connection. const LAST_SEEN_FLUSH_DELAY_MS = 250 +const STALE_WRITE_TEMP_AGE_MS = 24 * 60 * 60 * 1000 export class DeviceRegistry { private readonly registryPath: string @@ -71,6 +73,10 @@ export class DeviceRegistry { constructor(userDataPath: string) { this.registryPath = join(userDataPath, DEVICE_REGISTRY_FILENAME) + // Why: a write killed between writeFile and rename (e.g. a hung icacls, #20497) orphans its temp forever. + void removeStaleDurableWriteTempFiles(this.registryPath, { + minimumAgeMs: STALE_WRITE_TEMP_AGE_MS + }) this.load() } diff --git a/src/main/runtime/mobile-notification-dismissal-store.ts b/src/main/runtime/mobile-notification-dismissal-store.ts index 984a8c9a1e0..0be613b792b 100644 --- a/src/main/runtime/mobile-notification-dismissal-store.ts +++ b/src/main/runtime/mobile-notification-dismissal-store.ts @@ -5,6 +5,7 @@ import { hardenExistingSecureFile, isUnreadableError } from '../../shared/secure-file' +import { removeStaleDurableWriteTempFiles } from '../durable-file-write' import type { MobileNotificationEvent } from './runtime-mobile-notification-controller' export type DeliveredNotificationIdentity = { @@ -15,6 +16,7 @@ export type DeliveredNotificationIdentity = { type RecordEntry = DeliveredNotificationIdentity & { dismissedThrough: number; expiresAt: number } const LIMIT = 4096 const RETENTION_MS = 7 * 86400_000 +const STALE_WRITE_TEMP_AGE_MS = 86400_000 export class MobileNotificationDismissalStore { private readonly path: string @@ -22,6 +24,8 @@ export class MobileNotificationDismissalStore { private unreadable = false constructor(userDataPath: string) { this.path = join(userDataPath, 'mobile-notification-dismissals.json') + // Why: a write killed between writeFile and rename (e.g. a hung icacls, #20497) orphans its temp forever. + void removeStaleDurableWriteTempFiles(this.path, { minimumAgeMs: STALE_WRITE_TEMP_AGE_MS }) try { hardenExistingSecureFile(this.path) const value: unknown = JSON.parse(readFileSync(this.path, 'utf8')) diff --git a/src/main/runtime/mobile-store-orphaned-temp-cleanup.test.ts b/src/main/runtime/mobile-store-orphaned-temp-cleanup.test.ts new file mode 100644 index 00000000000..0eb1690a1a3 --- /dev/null +++ b/src/main/runtime/mobile-store-orphaned-temp-cleanup.test.ts @@ -0,0 +1,78 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import * as durableFileWrite from '../durable-file-write' +import { DeviceRegistry } from './device-registry' +import { MobileNotificationDismissalStore } from './mobile-notification-dismissal-store' +import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' + +const dirs: string[] = [] +afterEach(() => { + vi.restoreAllMocks() + dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })) +}) + +const stores = [ + [ + 'dismissal store', + 'mobile-notification-dismissals.json', + (dir: string) => new MobileNotificationDismissalStore(dir) + ], + ['device registry', DEVICE_REGISTRY_FILENAME, (dir: string) => new DeviceRegistry(dir)] +] as const + +it.each(stores)( + '%s reclaims orphaned temps while preserving active writes and unrelated files', + async (_, fileName, open) => { + const dir = mkdtempSync(join(tmpdir(), 'orca-orphaned-temp-')) + dirs.push(dir) + // Other-process PIDs: the sweep always spares this process's own temps. + const orphaned = join(dir, `${fileName}.${process.pid + 1}.1784108697605.b306bb91.tmp`) + const recent = join(dir, `${fileName}.${process.pid + 2}.1784108697605.cafef00d.tmp`) + const own = join(dir, `${fileName}.${process.pid}.1784108697605.aaaaaaaa.tmp`) + const otherStore = join( + dir, + `${fileName === DEVICE_REGISTRY_FILENAME ? 'mobile-notification-dismissals.json' : DEVICE_REGISTRY_FILENAME}.${process.pid + 1}.0.bbbbbbbb.tmp` + ) + const similarName = join(dir, `${fileName}-backup.${process.pid + 1}.0.cccccccc.tmp`) + const wrongSuffix = join(dir, `${fileName}.${process.pid + 1}.0.dddddddd.tmp.backup`) + const finalPath = join(dir, fileName) + writeFileSync(orphaned, '[]') + writeFileSync(recent, '[]') + writeFileSync(finalPath, '[]') + const twoDaysAgo = (Date.now() - 2 * 86400_000) / 1000 + for (const path of [orphaned, own, otherStore, similarName, wrongSuffix]) { + writeFileSync(path, '[]') + utimesSync(path, twoDaysAgo, twoDaysAgo) + } + + open(dir) + + await vi.waitFor(() => expect(existsSync(orphaned)).toBe(false)) + for (const path of [recent, own, otherStore, similarName, wrongSuffix, finalPath]) { + expect(readFileSync(path, 'utf8')).toBe('[]') + } + } +) + +it.each(stores)('%s loads while startup cleanup is still pending', async (_, fileName, open) => { + const dir = mkdtempSync(join(tmpdir(), 'orca-orphaned-temp-pending-')) + dirs.push(dir) + writeFileSync(join(dir, fileName), '[]') + let finishCleanup = () => {} + const pending = new Promise((resolve) => { + finishCleanup = resolve + }) + const cleanup = vi + .spyOn(durableFileWrite, 'removeStaleDurableWriteTempFiles') + .mockReturnValue(pending) + try { + const store = open(dir) + expect(cleanup).toHaveBeenCalledOnce() + expect(store instanceof DeviceRegistry ? store.listDevices() : store.reconcile([])).toEqual([]) + } finally { + finishCleanup() + await pending + } +})