fix(runtime): reclaim temp files orphaned by interrupted mobile store writes

Reuse the existing stale temporary-file cleanup policy when each mobile store opens.

Co-authored-by: LDH1103 <ldh517525@gmail.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Dongho Lee
2026-10-03 00:50:26 -07:00
committed by GitHub
co-authored by LDH1103 Neil
parent 81e69f3eb1
commit 6565a3fd23
3 changed files with 88 additions and 0 deletions
+6
View File
@@ -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()
}
@@ -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'))
@@ -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<void>((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
}
})