Fix Windows Codex launch-home file links

Fix Windows launch-home file linking by using hard links for shared Codex files, handling EEXIST/idempotency, and covering the Windows SQLite sidecar behavior.
This commit is contained in:
Jinwoo Hong
2026-06-01 02:59:16 -04:00
committed by GitHub
parent 9e147da522
commit d900bf2eea
4 changed files with 223 additions and 11 deletions
@@ -196,8 +196,17 @@ function expectResourceLinkedOrCopied(targetPath: string, sourcePath: string): v
}
function expectResourceLinked(targetPath: string, sourcePath: string): void {
expect(lstatSync(targetPath).isSymbolicLink()).toBe(true)
expect(normalizeLinkTarget(readlinkSync(targetPath))).toBe(normalizeLinkTarget(sourcePath))
const targetStat = lstatSync(targetPath)
if (targetStat.isSymbolicLink()) {
expect(normalizeLinkTarget(readlinkSync(targetPath))).toBe(normalizeLinkTarget(sourcePath))
return
}
const sourceStat = statSync(sourcePath)
expect(sourceStat.isFile()).toBe(true)
expect(targetStat.isFile()).toBe(true)
expect(targetStat.dev).toBe(sourceStat.dev)
expect(targetStat.ino).toBe(sourceStat.ino)
expect(targetStat.nlink).toBeGreaterThan(1)
}
function createStore(settings: GlobalSettings) {
@@ -791,8 +800,12 @@ describe('CodexRuntimeHomeService', () => {
join(launchHome!, 'state_5.sqlite-shm'),
join(getRuntimeCodexHomePath(), 'state_5.sqlite-shm')
)
expect(existsSync(join(getRuntimeCodexHomePath(), 'state_5.sqlite-wal'))).toBe(false)
expect(existsSync(join(getRuntimeCodexHomePath(), 'state_5.sqlite-shm'))).toBe(false)
expect(existsSync(join(getRuntimeCodexHomePath(), 'state_5.sqlite-wal'))).toBe(
process.platform === 'win32'
)
expect(existsSync(join(getRuntimeCodexHomePath(), 'state_5.sqlite-shm'))).toBe(
process.platform === 'win32'
)
expect(readFileSync(join(launchHome!, 'auth.json'), 'utf-8')).toBe(accountAuth)
})
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
existsSync,
lstatSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import type * as NodeFs from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const { fsMockState } = vi.hoisted(() => ({
fsMockState: { failSymlink: false, hardLinkRace: false }
}))
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof NodeFs>('node:fs')
return {
...actual,
linkSync: (...args: Parameters<typeof actual.linkSync>) => {
if (fsMockState.hardLinkRace) {
fsMockState.hardLinkRace = false
actual.linkSync(...args)
const error = new Error('target already linked') as NodeJS.ErrnoException
error.code = 'EEXIST'
throw error
}
return actual.linkSync(...args)
},
symlinkSync: (...args: Parameters<typeof actual.symlinkSync>) => {
if (fsMockState.failSymlink) {
throw new Error('symlink disabled for test')
}
return actual.symlinkSync(...args)
}
}
})
import { materializeScopedCodexLaunchHome } from './codex-launch-home-paths'
let tempDir: string
let sharedHomePath: string
let launchRootPath: string
function expectSameFile(targetPath: string, sourcePath: string): void {
const targetStat = statSync(targetPath)
const sourceStat = statSync(sourcePath)
expect(targetStat.dev).toBe(sourceStat.dev)
expect(targetStat.ino).toBe(sourceStat.ino)
expect(targetStat.nlink).toBeGreaterThan(1)
}
function withWin32Platform<T>(callback: () => T): T {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
return callback()
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
}
beforeEach(() => {
fsMockState.failSymlink = false
fsMockState.hardLinkRace = false
tempDir = mkdtempSync(join(tmpdir(), 'orca-codex-launch-home-'))
sharedHomePath = join(tempDir, 'shared-home')
launchRootPath = join(tempDir, 'launch-root')
mkdirSync(sharedHomePath, { recursive: true })
mkdirSync(launchRootPath, { recursive: true })
})
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true })
vi.clearAllMocks()
})
describe('materializeScopedCodexLaunchHome', () => {
it('hard-links shared files when symlinks are unavailable', () => {
fsMockState.failSymlink = true
const hooksPath = join(sharedHomePath, 'hooks.json')
const sqlitePath = join(sharedHomePath, 'logs_2.sqlite')
const sqliteWalPath = join(sharedHomePath, 'logs_2.sqlite-wal')
const sqliteShmPath = join(sharedHomePath, 'logs_2.sqlite-shm')
writeFileSync(hooksPath, '{"hooks":{}}\n')
writeFileSync(sqlitePath, 'sqlite\n')
writeFileSync(sqliteWalPath, 'wal\n')
writeFileSync(sqliteShmPath, 'shm\n')
const launchHomePath = withWin32Platform(() =>
materializeScopedCodexLaunchHome(sharedHomePath, launchRootPath, null)
)
for (const entryName of [
'hooks.json',
'logs_2.sqlite',
'logs_2.sqlite-wal',
'logs_2.sqlite-shm'
]) {
const targetPath = join(launchHomePath, entryName)
const sourcePath = join(sharedHomePath, entryName)
expect(existsSync(targetPath)).toBe(true)
expect(lstatSync(targetPath).isSymbolicLink()).toBe(false)
expect(readFileSync(targetPath, 'utf-8')).toBe(readFileSync(sourcePath, 'utf-8'))
expectSameFile(targetPath, sourcePath)
}
})
it('accepts a shared file already linked by a concurrent materializer', () => {
fsMockState.hardLinkRace = true
const sourcePath = join(sharedHomePath, 'memories_1.sqlite-wal')
writeFileSync(sourcePath, 'wal\n')
const launchHomePath = withWin32Platform(() =>
materializeScopedCodexLaunchHome(sharedHomePath, launchRootPath, null)
)
const targetPath = join(launchHomePath, 'memories_1.sqlite-wal')
expect(existsSync(targetPath)).toBe(true)
expect(lstatSync(targetPath).isSymbolicLink()).toBe(false)
expect(readFileSync(targetPath, 'utf-8')).toBe(readFileSync(sourcePath, 'utf-8'))
expectSameFile(targetPath, sourcePath)
})
})
+73 -7
View File
@@ -5,6 +5,7 @@ import {
cpSync,
existsSync,
lstatSync,
linkSync,
mkdirSync,
readFileSync,
readlinkSync,
@@ -220,6 +221,7 @@ function linkSharedEntryIntoLaunchHome(
removeLaunchEntryIfOwned(targetPath, launchHomePath, entryName, sourcePath)
return
}
materializeMissingSharedEntryIfNeeded(sourcePath, entryName)
if (targetAlreadyPointsToSource(targetPath, sourcePath)) {
markLaunchEntry(launchHomePath, entryName, sourcePath, 'link')
return
@@ -260,16 +262,70 @@ function linkSharedEntryIntoLaunchHome(
}
}
function materializeMissingSharedEntryIfNeeded(sourcePath: string, entryName: string): void {
if (
process.platform !== 'win32' ||
!isCodexSqliteSidecarEntryName(entryName) ||
existsSync(sourcePath)
) {
return
}
try {
writeFileSync(sourcePath, '', { flag: 'wx', mode: 0o600 })
} catch (error) {
if (existsSync(sourcePath)) {
return
}
throw error
}
}
function createSharedEntryLink(sourcePath: string, targetPath: string): void {
if (createWslSymlinkIfPossible(sourcePath, targetPath)) {
return
}
const sourceStat = existsSync(sourcePath) ? lstatSync(sourcePath) : null
symlinkSync(
sourcePath,
targetPath,
sourceStat?.isDirectory() && process.platform === 'win32' ? 'junction' : undefined
)
if (
sourceStat?.isFile() &&
process.platform === 'win32' &&
createHardLinkIfPossible(sourcePath, targetPath)
) {
return
}
if (targetAlreadyPointsToSource(targetPath, sourcePath)) {
return
}
try {
symlinkSync(
sourcePath,
targetPath,
sourceStat?.isDirectory() && process.platform === 'win32' ? 'junction' : undefined
)
} catch (error) {
if (targetAlreadyPointsToSource(targetPath, sourcePath)) {
return
}
if (
sourceStat?.isFile() &&
process.platform === 'win32' &&
createHardLinkIfPossible(sourcePath, targetPath)
) {
return
}
if (targetAlreadyPointsToSource(targetPath, sourcePath)) {
return
}
throw error
}
}
function createHardLinkIfPossible(sourcePath: string, targetPath: string): boolean {
try {
linkSync(sourcePath, targetPath)
return true
} catch {
return false
}
}
function createWslSymlinkIfPossible(sourcePath: string, targetPath: string): boolean {
@@ -648,9 +704,19 @@ function targetAlreadyPointsToSource(targetPath: string, sourcePath: string): bo
return readWslSymlinkTarget(targetPath) === paths.sourceLinuxPath
}
try {
const targetStat = lstatSync(targetPath)
if (targetStat.isSymbolicLink()) {
return linkTargetsMatch(readlinkSync(targetPath), sourcePath)
}
if (!targetStat.isFile() || !existsSync(sourcePath)) {
return false
}
const sourceStat = statSync(sourcePath)
return (
lstatSync(targetPath).isSymbolicLink() &&
linkTargetsMatch(readlinkSync(targetPath), sourcePath)
sourceStat.isFile() &&
targetStat.dev === sourceStat.dev &&
targetStat.ino === sourceStat.ino &&
targetStat.nlink > 1
)
} catch {
return false
@@ -73,6 +73,8 @@ async function usePollingOnce(
vi.doMock('@/store/selectors', () => ({
useActiveWorktree: () =>
options.pushTarget ? { ...worktree, pushTarget: options.pushTarget } : worktree,
useWorktreeById: () =>
options.pushTarget ? { ...worktree, pushTarget: options.pushTarget } : worktree,
useAllWorktrees: () => [worktree],
useRepoById: () => mockedRepo,
useRepoMap: () => new Map([[mockedRepo.id, mockedRepo]])
@@ -233,6 +235,7 @@ describe('useGitStatusPolling', () => {
}))
vi.doMock('@/store/selectors', () => ({
useActiveWorktree: () => worktree,
useWorktreeById: () => worktree,
useAllWorktrees: () => [worktree],
useRepoById: () => repo,
useRepoMap: () => new Map([[repo.id, repo]])