mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
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:
co-authored by
LDH1103
Neil
parent
81e69f3eb1
commit
6565a3fd23
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user