mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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]])
|
||||
|
||||
Reference in New Issue
Block a user