From d900bf2eeaa7d9e26792b34a5015191fa1c0a78c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 1 Jun 2026 02:59:16 -0400 Subject: [PATCH] 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. --- .../runtime-home-service.test.ts | 21 ++- .../codex/codex-launch-home-paths.test.ts | 130 ++++++++++++++++++ src/main/codex/codex-launch-home-paths.ts | 80 ++++++++++- .../right-sidebar/useGitStatusPolling.test.ts | 3 + 4 files changed, 223 insertions(+), 11 deletions(-) create mode 100644 src/main/codex/codex-launch-home-paths.test.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 50a332568a6..839dc475594 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -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) }) diff --git a/src/main/codex/codex-launch-home-paths.test.ts b/src/main/codex/codex-launch-home-paths.test.ts new file mode 100644 index 00000000000..5452fb3a99f --- /dev/null +++ b/src/main/codex/codex-launch-home-paths.test.ts @@ -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('node:fs') + return { + ...actual, + linkSync: (...args: Parameters) => { + 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) => { + 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(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) + }) +}) diff --git a/src/main/codex/codex-launch-home-paths.ts b/src/main/codex/codex-launch-home-paths.ts index 8260325c3e4..7afb8f5839f 100644 --- a/src/main/codex/codex-launch-home-paths.ts +++ b/src/main/codex/codex-launch-home-paths.ts @@ -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 diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index bbf800f9c60..0c72360bf8a 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -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]])