mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
fix(clipboard): bound locked staging cleanup retries
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { cleanupLegacyRemoteClipboardStagingMock } = vi.hoisted(() => ({
|
||||
cleanupLegacyRemoteClipboardStagingMock: vi.fn(async () => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } }))
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
requireSshFilesystemProvider: vi.fn()
|
||||
}))
|
||||
vi.mock('./clipboard-file-copy', () => ({ writeFileToClipboard: vi.fn() }))
|
||||
vi.mock('./clipboard-remote-file-staging', () => ({
|
||||
cleanupExpiredRemoteClipboardStaging: vi.fn(async () => undefined),
|
||||
cleanupLegacyRemoteClipboardStaging: cleanupLegacyRemoteClipboardStagingMock,
|
||||
createRemoteClipboardTransferDirectory: vi.fn(),
|
||||
removeRemoteClipboardTransferDirectory: vi.fn(),
|
||||
scheduleRemoteClipboardTransferCleanup: vi.fn()
|
||||
}))
|
||||
|
||||
import { scheduleLegacyRemoteClipboardFileCleanup } from './clipboard-remote-file-copy'
|
||||
|
||||
describe('legacy remote clipboard cleanup scheduling', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('runs the shared-root compatibility scan once after the startup delay', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_760_000_000_000)
|
||||
|
||||
scheduleLegacyRemoteClipboardFileCleanup()
|
||||
scheduleLegacyRemoteClipboardFileCleanup()
|
||||
await vi.advanceTimersByTimeAsync(29_999)
|
||||
|
||||
expect(cleanupLegacyRemoteClipboardStagingMock).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
expect(cleanupLegacyRemoteClipboardStagingMock).toHaveBeenCalledOnce()
|
||||
expect(cleanupLegacyRemoteClipboardStagingMock).toHaveBeenCalledWith('/tmp', 1_760_000_000_000)
|
||||
})
|
||||
})
|
||||
@@ -15,13 +15,12 @@ import {
|
||||
cleanupExpiredRemoteClipboardStaging,
|
||||
cleanupLegacyRemoteClipboardStaging,
|
||||
createRemoteClipboardTransferDirectory,
|
||||
removeRemoteClipboardTransferDirectory
|
||||
removeRemoteClipboardTransferDirectory,
|
||||
scheduleRemoteClipboardTransferCleanup
|
||||
} from './clipboard-remote-file-staging'
|
||||
|
||||
type RemoteClipboardFileDeps = Omit<ClipboardFileDeps, 'resolveFilePath'>
|
||||
|
||||
const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000
|
||||
const REMOTE_CLIPBOARD_CLEANUP_RETRY_MS = 60 * 1000
|
||||
const REMOTE_CLIPBOARD_LEGACY_CLEANUP_DELAY_MS = 30 * 1000
|
||||
const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i
|
||||
const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*'])
|
||||
@@ -73,7 +72,7 @@ export async function writeRemoteFileToClipboard({
|
||||
// Why: OS file clipboards keep a path reference, so the staged copy must
|
||||
// survive after this IPC call long enough for the user to paste it.
|
||||
keepTempFile = true
|
||||
scheduleRemoteClipboardFileCleanup(tempRoot, tempDir)
|
||||
scheduleRemoteClipboardTransferCleanup(tempRoot, tempDir)
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
@@ -114,21 +113,6 @@ function sanitizeLocalClipboardFilename(remoteBasename: string): string {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
function scheduleRemoteClipboardFileCleanup(
|
||||
tempRoot: string,
|
||||
tempDir: string,
|
||||
delayMs = REMOTE_CLIPBOARD_FILE_TTL_MS
|
||||
): void {
|
||||
const timer = setTimeout(() => {
|
||||
void removeRemoteClipboardTransferDirectory(tempRoot, tempDir).then((removed) => {
|
||||
if (!removed) {
|
||||
scheduleRemoteClipboardFileCleanup(tempRoot, tempDir, REMOTE_CLIPBOARD_CLEANUP_RETRY_MS)
|
||||
}
|
||||
})
|
||||
}, delayMs)
|
||||
unrefTimer(timer)
|
||||
}
|
||||
|
||||
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
||||
if (typeof timer === 'object' && 'unref' in timer) {
|
||||
timer.unref()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { access, lstat, mkdir, mkdtemp, readdir, rm, symlink, utimes } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
|
||||
import {
|
||||
cleanupExpiredRemoteClipboardStaging,
|
||||
createRemoteClipboardTransferDirectory,
|
||||
getRemoteClipboardStagingRoot
|
||||
} from './clipboard-remote-file-staging'
|
||||
|
||||
const FIXTURE_PREFIX = 'orca-clipboard-staging-test-'
|
||||
const NOW_MS = 1_760_000_000_000
|
||||
const fixtures: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
fixtures.splice(0).map(async (fixture) => {
|
||||
expect(dirname(fixture)).toBe(tmpdir())
|
||||
expect(basename(fixture)).toMatch(new RegExp(`^${FIXTURE_PREFIX}`))
|
||||
await rm(fixture, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('remote clipboard staging filesystem contract', () => {
|
||||
it('removes only expired owned transfers with real filesystem metadata', async () => {
|
||||
const tempRoot = await makeFixture()
|
||||
const foreignDirectory = join(tempRoot, 'foreign-directory')
|
||||
await mkdir(foreignDirectory)
|
||||
const expiredTransfer = await createRemoteClipboardTransferDirectory(
|
||||
tempRoot,
|
||||
NOW_MS - 2 * 60 * 60 * 1000,
|
||||
'00000000-0000-4000-8000-000000000000'
|
||||
)
|
||||
const freshTransfer = await createRemoteClipboardTransferDirectory(
|
||||
tempRoot,
|
||||
NOW_MS,
|
||||
'00000000-0000-4000-8000-000000000001'
|
||||
)
|
||||
const expiredAt = new Date(NOW_MS - 2 * 60 * 60 * 1000)
|
||||
await utimes(expiredTransfer, expiredAt, expiredAt)
|
||||
|
||||
await cleanupExpiredRemoteClipboardStaging(tempRoot, NOW_MS)
|
||||
|
||||
await expect(access(expiredTransfer)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await expect(access(freshTransfer)).resolves.toBeUndefined()
|
||||
await expect(access(foreignDirectory)).resolves.toBeUndefined()
|
||||
if (typeof process.getuid === 'function') {
|
||||
const rootStats = await lstat(getRemoteClipboardStagingRoot(tempRoot))
|
||||
expect(rootStats.uid).toBe(process.getuid())
|
||||
expect(rootStats.mode & 0o777).toBe(0o700)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a real staging-root symlink without writing through it', async () => {
|
||||
const tempRoot = await makeFixture()
|
||||
const outsideDirectory = join(tempRoot, 'outside')
|
||||
const stagingRoot = getRemoteClipboardStagingRoot(tempRoot)
|
||||
await mkdir(outsideDirectory)
|
||||
await symlink(outsideDirectory, stagingRoot, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
|
||||
await expect(
|
||||
createRemoteClipboardTransferDirectory(
|
||||
tempRoot,
|
||||
NOW_MS,
|
||||
'00000000-0000-4000-8000-000000000000'
|
||||
)
|
||||
).rejects.toThrow('staging root is unsafe')
|
||||
|
||||
expect(await readdir(outsideDirectory)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
async function makeFixture(): Promise<string> {
|
||||
const fixture = await mkdtemp(join(tmpdir(), FIXTURE_PREFIX))
|
||||
fixtures.push(fixture)
|
||||
return fixture
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const { accessMock, lstatMock, mkdirMock, opendirMock, rmMock, writeFileMock } = vi.hoisted(() => ({
|
||||
@@ -24,10 +24,12 @@ import {
|
||||
cleanupLegacyRemoteClipboardStaging,
|
||||
createRemoteClipboardTransferDirectory,
|
||||
getRemoteClipboardStagingRoot,
|
||||
removeRemoteClipboardTransferDirectory
|
||||
removeRemoteClipboardTransferDirectory,
|
||||
scheduleRemoteClipboardTransferCleanup
|
||||
} from './clipboard-remote-file-staging'
|
||||
|
||||
const TTL_MS = 60 * 60 * 1000
|
||||
const RETRY_MS = 60 * 1000
|
||||
const NOW_MS = 1_760_000_000_000
|
||||
const TEMP_ROOT = resolve('fixture-temp')
|
||||
const UID_SUFFIX = typeof process.getuid === 'function' ? `-${process.getuid()}` : ''
|
||||
@@ -83,6 +85,10 @@ describe('remote clipboard staging ownership', () => {
|
||||
writeFileMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('creates a private owned parent and a per-transfer child', async () => {
|
||||
const result = await createRemoteClipboardTransferDirectory(TEMP_ROOT, NOW_MS, UUID_A)
|
||||
|
||||
@@ -215,6 +221,17 @@ describe('remote clipboard staging ownership', () => {
|
||||
expect(rmMock).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('bounds locked-file timer retries', async () => {
|
||||
vi.useFakeTimers()
|
||||
rmMock.mockRejectedValue(Object.assign(new Error('locked'), { code: 'EPERM' }))
|
||||
|
||||
scheduleRemoteClipboardTransferCleanup(TEMP_ROOT, join(STAGING_ROOT, EXPIRED_TRANSFER))
|
||||
await vi.advanceTimersByTimeAsync(TTL_MS + 4 * RETRY_MS)
|
||||
|
||||
expect(rmMock).toHaveBeenCalledTimes(4)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps owned-child cleanup concurrency at eight', async () => {
|
||||
const entries = Array.from({ length: 257 }, (_, index) =>
|
||||
directoryEntry(`1759990000000-00000000-0000-4000-8000-${String(index).padStart(12, '0')}`)
|
||||
|
||||
@@ -7,6 +7,8 @@ const REMOTE_CLIPBOARD_LEGACY_PREFIX = 'orca-clipboard-file-'
|
||||
const REMOTE_CLIPBOARD_MIGRATION_MARKER = '.legacy-cleanup-complete'
|
||||
const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000
|
||||
const REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY = 8
|
||||
const REMOTE_CLIPBOARD_CLEANUP_RETRY_MS = 60 * 1000
|
||||
const REMOTE_CLIPBOARD_CLEANUP_RETRY_LIMIT = 3
|
||||
// Why: compatibility cleanup must never restore O(shared temp root) work.
|
||||
const REMOTE_CLIPBOARD_LEGACY_ENTRY_LIMIT = 4_096
|
||||
const UUID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
|
||||
@@ -119,6 +121,41 @@ export async function removeRemoteClipboardTransferDirectory(
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleRemoteClipboardTransferCleanup(
|
||||
tempRoot: string,
|
||||
transferDirectory: string
|
||||
): void {
|
||||
scheduleCleanupAttempt(
|
||||
tempRoot,
|
||||
transferDirectory,
|
||||
REMOTE_CLIPBOARD_FILE_TTL_MS,
|
||||
REMOTE_CLIPBOARD_CLEANUP_RETRY_LIMIT
|
||||
)
|
||||
}
|
||||
|
||||
function scheduleCleanupAttempt(
|
||||
tempRoot: string,
|
||||
transferDirectory: string,
|
||||
delayMs: number,
|
||||
retriesRemaining: number
|
||||
): void {
|
||||
const timer = setTimeout(() => {
|
||||
void removeRemoteClipboardTransferDirectory(tempRoot, transferDirectory).then((removed) => {
|
||||
if (!removed && retriesRemaining > 0) {
|
||||
scheduleCleanupAttempt(
|
||||
tempRoot,
|
||||
transferDirectory,
|
||||
REMOTE_CLIPBOARD_CLEANUP_RETRY_MS,
|
||||
retriesRemaining - 1
|
||||
)
|
||||
}
|
||||
})
|
||||
}, delayMs)
|
||||
if (typeof timer === 'object' && 'unref' in timer) {
|
||||
timer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRemoteClipboardStagingRoot(tempRoot: string): Promise<string> {
|
||||
const stagingRoot = getRemoteClipboardStagingRoot(tempRoot)
|
||||
await mkdir(stagingRoot, { recursive: true, mode: 0o700 })
|
||||
|
||||
Reference in New Issue
Block a user