fix(clipboard): bound locked staging cleanup retries

This commit is contained in:
OrcaWin
2026-08-09 22:40:44 -07:00
parent baee13f90c
commit 03c9d26cc1
5 changed files with 181 additions and 21 deletions
@@ -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)
})
})
+3 -19
View File
@@ -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 })