diff --git a/src/main/codex/codex-session-backfill-copy.ts b/src/main/codex/codex-session-backfill-copy.ts index bb2f7621058..8c033abf97d 100644 --- a/src/main/codex/codex-session-backfill-copy.ts +++ b/src/main/codex/codex-session-backfill-copy.ts @@ -1,7 +1,9 @@ import { randomUUID } from 'node:crypto' -import { copyFile, link, lstat, rename, rm, writeFile } from 'node:fs/promises' +import { copyFile, link, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' +const ATOMIC_NO_REPLACE_UNSUPPORTED_CODE = 'ORCA_ATOMIC_NO_REPLACE_UNSUPPORTED' + export async function copySessionFileWithoutOverwrite( sourcePath: string, targetPath: string @@ -20,16 +22,12 @@ export async function copySessionFileWithoutOverwrite( if (isExistsError(installLinkError)) { throw installLinkError } - // Why: some target filesystems support no hardlinks at all. Install the - // fully-staged copy with an atomic rename — never a raw copy into the - // rollout filename — so an interrupted install cannot strand a truncated - // session that a later run skips as already-present. Re-check existence - // first because rename, unlike the hardlink above, would clobber a target - // that appeared mid-run. - if (await pathEntryExists(targetPath)) { - throw makeTargetExistsError(targetPath) + if (!isHardlinkUnsupportedError(installLinkError)) { + throw installLinkError } - await rename(temporaryPath, targetPath) + // Why: Node has no portable atomic rename-if-absent. Fail closed on a + // hardlink-less target instead of risking replacement of a concurrent file. + throw makeAtomicNoReplaceUnsupportedError(targetPath, installLinkError) } } finally { try { @@ -42,23 +40,33 @@ export async function copySessionFileWithoutOverwrite( } } -/** Existence via lstat so a broken symlink at the target still counts as taken. */ -async function pathEntryExists(entryPath: string): Promise { - try { - await lstat(entryPath) - return true - } catch { - return false - } -} - function isExistsError(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' } -/** EEXIST so a rename-install collision routes to the skip path, not a failure. */ -function makeTargetExistsError(targetPath: string): NodeJS.ErrnoException { - const error = new Error(`EEXIST: backfill target already exists: ${targetPath}`) - ;(error as NodeJS.ErrnoException).code = 'EEXIST' +function isHardlinkUnsupportedError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return ( + code === 'EPERM' || + code === 'EACCES' || + code === 'ENOTSUP' || + code === 'EOPNOTSUPP' || + code === 'ENOSYS' + ) +} + +function makeAtomicNoReplaceUnsupportedError( + targetPath: string, + cause: unknown +): NodeJS.ErrnoException { + const error = new Error( + `Cannot atomically install backfill without overwrite on this filesystem: ${targetPath}`, + { cause } + ) as NodeJS.ErrnoException + error.code = ATOMIC_NO_REPLACE_UNSUPPORTED_CODE return error } + +export function isAtomicNoReplaceUnsupportedError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === ATOMIC_NO_REPLACE_UNSUPPORTED_CODE +} diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index b923993ce2c..089a52f08ef 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -24,7 +24,7 @@ const { fsMockState } = vi.hoisted(() => ({ fsMockState: { failLink: false, failInstallLink: false, - failInstallRename: false, + failInstallLinkTransiently: false, failCopy: false, failAuditMkdirOnce: false, failDirectoryPath: null as string | null, @@ -81,17 +81,12 @@ vi.mock('node:fs/promises', async () => { error.code = 'EPERM' throw error } - return actual.link(...args) - }, - rename: (...args: Parameters) => { - // Simulate an interrupted atomic install (crash/ENOSPC mid-rename) on a - // hardlink-less target, exercising the no-partial-stranded guarantee. - if (fsMockState.failInstallRename && String(args[0]).includes('.orca-backfill-')) { - const error = new Error('EIO: rename interrupted') as NodeJS.ErrnoException + if (fsMockState.failInstallLinkTransiently && String(args[0]).includes('.orca-backfill-')) { + const error = new Error('EIO: transient install failure') as NodeJS.ErrnoException error.code = 'EIO' throw error } - return actual.rename(...args) + return actual.link(...args) }, copyFile: async (...args: Parameters) => { if (fsMockState.failCopy) { @@ -166,7 +161,7 @@ function readAuditActions(): string[] { beforeEach(() => { fsMockState.failLink = false fsMockState.failInstallLink = false - fsMockState.failInstallRename = false + fsMockState.failInstallLinkTransiently = false fsMockState.failCopy = false fsMockState.failAuditMkdirOnce = false fsMockState.failDirectoryPath = null @@ -335,48 +330,40 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['copy', 'run-summary']) }) - it('installs via atomic rename when the target volume has no hardlink support', async () => { - // Why: exFAT/FAT and some network targets support no hardlinks, so even the - // staged-copy install link fails and the atomic-rename fallback must run. + it('fails closed when the target filesystem cannot install without overwrite', async () => { fsMockState.failLink = true fsMockState.failInstallLink = true - const managedPath = writeManagedSession( - join('2026', '05', '26', 'rollout-a.jsonl'), - '{"id":"a"}\n' - ) - - const summary = await backfillManagedCodexSessionsIntoSystemHome( - resolveCodexSessionBackfillPaths() - ) - - expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 1, failedFiles: 0 }) - const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') - expect(readFileSync(targetPath, 'utf-8')).toBe(readFileSync(managedPath, 'utf-8')) - // Staged temp file is renamed into place, not stranded in the sessions tree. - expect( - readdirSync(dirname(targetPath)).filter((name) => name.includes('.orca-backfill-')) - ).toEqual([]) - expect(readAuditActions()).toEqual(['copy', 'run-summary']) - }) - - it('never strands a partial rollout when the atomic install is interrupted', async () => { - // Why: a hardlink-less target plus an interrupted install must leave the - // real rollout filename absent (not truncated), so the next run retries - // instead of skipping a partial session as already-present. - fsMockState.failLink = true - fsMockState.failInstallLink = true - fsMockState.failInstallRename = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( resolveCodexSessionBackfillPaths() ) - expect(summary).toMatchObject({ failedFiles: 1, copiedFiles: 0, linkedFiles: 0 }) + expect(summary).toMatchObject({ + linkedFiles: 0, + copiedFiles: 0, + skippedUnsupportedFilesystemFiles: 1, + failedFiles: 0 + }) const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') expect(existsSync(targetPath)).toBe(false) - // No partial rollout and no leftover staging file in the user's tree. expect(readdirSync(dirname(targetPath))).toEqual([]) + expect(readAuditActions()).toEqual(['copy-unsupported', 'run-summary']) + }) + + it('keeps transient install failures retryable', async () => { + fsMockState.failLink = true + fsMockState.failInstallLinkTransiently = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths() + ) + + expect(summary).toMatchObject({ + skippedUnsupportedFilesystemFiles: 0, + failedFiles: 1 + }) expect(readAuditActions()).toEqual(['failed', 'run-summary']) }) @@ -423,6 +410,18 @@ describe('startCodexSessionBackfillInBackground', () => { ).toBe(false) }) + it('does not retry a stable hardlink-less filesystem limitation', async () => { + fsMockState.failLink = true + fsMockState.failInstallLink = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const first = await startCodexSessionBackfillInBackground() + expect(first).toMatchObject({ skippedUnsupportedFilesystemFiles: 1, failedFiles: 0 }) + expect(existsSync(getMarkerPath())).toBe(true) + + expect(await startCodexSessionBackfillInBackground()).toBeNull() + }) + it('leaves the marker unset when any file fails so the next startup retries', async () => { fsMockState.failLink = true fsMockState.failCopy = true diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts index d7489126dd1..7b24808c23a 100644 --- a/src/main/codex/codex-session-backfill.ts +++ b/src/main/codex/codex-session-backfill.ts @@ -11,7 +11,10 @@ import { createCodexSessionBackfillAuditWriter, type CodexSessionBackfillAuditWriter } from './codex-session-backfill-audit' -import { copySessionFileWithoutOverwrite } from './codex-session-backfill-copy' +import { + copySessionFileWithoutOverwrite, + isAtomicNoReplaceUnsupportedError +} from './codex-session-backfill-copy' import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' @@ -26,6 +29,7 @@ export type CodexSessionBackfillSummary = { skippedExistingFiles: number skippedUnexpectedFiles: number skippedSymlinkFiles: number + skippedUnsupportedFilesystemFiles: number failedDirectories: number failedFiles: number } @@ -122,6 +126,7 @@ export async function backfillManagedCodexSessionsIntoSystemHome( skippedExistingFiles: 0, skippedUnexpectedFiles: 0, skippedSymlinkFiles: 0, + skippedUnsupportedFilesystemFiles: 0, failedDirectories: 0, failedFiles: 0 } @@ -263,6 +268,15 @@ async function backfillOneManagedSessionFile( summary.skippedExistingFiles += 1 return } + if (isAtomicNoReplaceUnsupportedError(copyError)) { + summary.skippedUnsupportedFilesystemFiles += 1 + await appendAuditRecord({ + action: 'copy-unsupported', + source: managedSessionFilePath, + target: systemSessionFilePath + }) + return + } summary.failedFiles += 1 await appendAuditRecord({ action: 'failed',