Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions src/main/window/dropped-path-preparation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DragTempCopyItemResult } from './dragged-temp-file-copy'
import type * as DragTempFileCopy from './dragged-temp-file-copy'
import type { NativeFileDropPayload } from '../../shared/native-file-drop'
import { createDragTempCopyLane, MAX_PENDING_DRAG_TEMP_COPIES } from './dropped-path-preparation'
import { createNativeFileDropQueue } from './native-file-drop-relay'

const materialize = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({ app: {}, ipcMain: {} }))
vi.mock('./dragged-temp-file-copy', async (original) => ({
...(await original<typeof DragTempFileCopy>()),
materializeDragTempPaths: materialize
}))
const TEMP = join('/', 'var', 'T', 'TemporaryItems', 'NSIRD_one', 'Shot.png')
const ORDINARY = join('/', 'files', 'notes.txt')
const COPY = join('/', 'var', 'T', 'orca-drops', 'orca-drop-abc123', 'Shot.png')
const env = { platform: 'darwin' as const, sourceTempRoot: '/var/T', copyRoot: '/var/T/drops' }

function queue(lane = createDragTempCopyLane(), copyTimeoutMs = 1000) {
const controller = new AbortController()
const forwarded: NativeFileDropPayload[] = []
const dispose = vi.fn()
const enqueue = createNativeFileDropQueue(
{
platform: 'darwin',
getCopyEnvironment: async () => env,
watchRenderer: () => ({ signal: controller.signal, dispose }),
forward: (payload) => forwarded.push(payload),
copyTimeoutMs
},
lane
)
return { enqueue, controller, forwarded, dispose }
}
function deferred() {
let resolve!: (value: DragTempCopyItemResult[]) => void
const promise = new Promise<DragTempCopyItemResult[]>((settle) => {
resolve = settle
})
return { promise, resolve }
}
async function settle() {
await new Promise((resolve) => setTimeout(resolve, 0))
}
beforeEach(() => {
materialize.mockReset()
})

describe('dropped path preparation through the shared relay queue', () => {
it.each(['agent', 'main-reader'] as const)(
'passes ordinary paths without copying for %s',
async (consumer) => {
const { enqueue } = queue()
await expect(enqueue.prepare({ paths: [ORDINARY], consumer })).resolves.toEqual({
paths: [ORDINARY],
failures: []
})
expect(materialize).not.toHaveBeenCalled()
}
)

it.each(['agent', 'main-reader'] as const)(
'withholds uncopied originals only from agents: %s',
async (consumer) => {
materialize.mockResolvedValue([
{ sourcePath: ORDINARY, status: 'imported', destPath: ORDINARY },
{ sourcePath: TEMP, status: 'uncopied', reason: 'storage-full' }
])
const { enqueue } = queue()
const result = await enqueue.prepare({ paths: [ORDINARY, TEMP], consumer })
expect(result.paths).toEqual(consumer === 'agent' ? [ORDINARY] : [ORDINARY, TEMP])
expect(result.failures).toEqual(
consumer === 'agent'
? [
{
byteLength: 0,
pathCount: 1,
reason: 'temp-copy-failed',
target: 'rejected',
commonReason: 'storage-full'
}
]
: []
)
}
)

it('preserves ordinary successes and copied successes when another file fails', async () => {
materialize.mockResolvedValue([
{ sourcePath: ORDINARY, status: 'imported', destPath: ORDINARY },
{ sourcePath: TEMP, status: 'imported', destPath: COPY },
{ sourcePath: TEMP, status: 'failed', reason: 'missing' }
])
const result = await queue().enqueue.prepare({
paths: [ORDINARY, TEMP, TEMP],
consumer: 'agent'
})
expect(result.paths).toEqual([ORDINARY, COPY])
expect(result.failures[0]).toMatchObject({ pathCount: 1, commonReason: 'missing' })
})

it('serializes requests and the legacy relay together', async () => {
const first = deferred()
materialize
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce([{ sourcePath: TEMP, status: 'imported', destPath: COPY }])
const { enqueue, forwarded } = queue()
enqueue({ paths: [TEMP], target: 'terminal' })
const requested = enqueue.prepare({ paths: [TEMP], consumer: 'agent' })
await settle()
expect(materialize).toHaveBeenCalledTimes(1)
first.resolve([{ sourcePath: TEMP, status: 'imported', destPath: COPY }])
await expect(requested).resolves.toEqual({ paths: [COPY], failures: [] })
await settle()
expect(forwarded).toEqual([{ paths: [COPY], target: 'terminal' }])
expect(materialize).toHaveBeenCalledTimes(2)
})

it('shares serialization and the pending cap across replacement-window queues', async () => {
materialize.mockReturnValue(new Promise(() => undefined))
const lane = createDragTempCopyLane()
const first = queue(lane)
const second = queue(lane)
const results = Array.from({ length: MAX_PENDING_DRAG_TEMP_COPIES }, () =>
first.enqueue.prepare({ paths: [TEMP], consumer: 'agent' }).catch(() => undefined)
)
await expect(
second.enqueue.prepare({ paths: [ORDINARY, TEMP], consumer: 'agent' })
).resolves.toMatchObject({ paths: [ORDINARY], failures: [{ commonReason: 'busy' }] })
first.controller.abort(new Error('test ended'))
await Promise.all(results)
await settle()
expect(materialize).toHaveBeenCalledTimes(1)
})

it('lets another owner prepare ordinary paths while a copy waits', async () => {
const copy = deferred()
materialize.mockReturnValueOnce(copy.promise)
const { enqueue } = queue()
const pending = enqueue.prepare({ paths: [TEMP], consumer: 'agent' })
await expect(enqueue.prepare({ paths: [ORDINARY], consumer: 'main-reader' })).resolves.toEqual({
paths: [ORDINARY],
failures: []
})
copy.resolve([{ sourcePath: TEMP, status: 'imported', destPath: COPY }])
await pending
})

it('aborts running and queued requests on reload and releases their listeners', async () => {
materialize.mockReturnValue(new Promise(() => undefined))
const { enqueue, controller, dispose } = queue()
const running = enqueue.prepare({ paths: [TEMP], consumer: 'agent' })
const queued = enqueue.prepare({ paths: [TEMP], consumer: 'agent' })
const assertions = [
expect(running).rejects.toThrow('reloaded'),
expect(queued).rejects.toThrow('reloaded')
]
await settle()
controller.abort(new Error('reloaded'))
await Promise.all(assertions)
await settle()
expect(materialize).toHaveBeenCalledTimes(1)
expect(dispose).toHaveBeenCalledTimes(2)
})

it('reports timeout, keeps ordinary paths, and serves the next copy', async () => {
materialize
.mockReturnValueOnce(new Promise(() => undefined))
.mockResolvedValueOnce([{ sourcePath: TEMP, status: 'imported', destPath: COPY }])
const { enqueue } = queue(undefined, 5)
const first = enqueue.prepare({ paths: [ORDINARY, TEMP], consumer: 'agent' })
const next = enqueue.prepare({ paths: [TEMP], consumer: 'agent' })
await expect(first).resolves.toMatchObject({
paths: [ORDINARY],
failures: [{ commonReason: 'timed-out' }]
})
await expect(next).resolves.toEqual({ paths: [COPY], failures: [] })
})
})
161 changes: 161 additions & 0 deletions src/main/window/dropped-path-preparation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import type { NativeFileDropCopyFailureReason } from '../../shared/native-file-drop'
import type {
PrepareDroppedPathsRequest,
PreparedDroppedPaths
} from '../../shared/native-file-drop-preparation'
import {
materializeDragTempPaths,
mayNeedDragTempCopy,
type DragTempCopyEnvironment
} from './dragged-temp-file-copy'

const DRAG_TEMP_COPY_TIMEOUT_MS = 2 * 60 * 1000
export const MAX_PENDING_DRAG_TEMP_COPIES = 8

export type RendererDropLifetime = { signal: AbortSignal; dispose: () => void }
export type DroppedPathPreparationDeps = {
platform: NodeJS.Platform
getCopyEnvironment: () => Promise<DragTempCopyEnvironment>
watchRenderer: () => RendererDropLifetime
copyTimeoutMs?: number
}

export function createDragTempCopyLane(): { tail: Promise<void>; pending: number } {
return { tail: Promise.resolve(), pending: 0 }
}

// Why: replacement windows still share the retained-copy budget with work already in flight.
export const windowDragTempCopyLane = createDragTempCopyLane()

export function createDroppedPathPreparationQueue(
deps: DroppedPathPreparationDeps,
lane = createDragTempCopyLane()
): (request: PrepareDroppedPathsRequest) => PreparedDroppedPaths | Promise<PreparedDroppedPaths> {
return (request) => {
if (!request.paths.some((path) => mayNeedDragTempCopy(path, deps.platform))) {
return { paths: [...request.paths], failures: [] }
}
if (lane.pending >= MAX_PENDING_DRAG_TEMP_COPIES) {
return failDroppedPathPreparation(request.paths, deps.platform, 'busy')
}
const lifetime = deps.watchRenderer()
lane.pending += 1
const prepared = lane.tail.then(() => {
lifetime.signal.throwIfAborted()
return copyDroppedPaths(request, lifetime.signal, deps)
})
lane.tail = prepared
.then(
() => undefined,
() => undefined
)
.finally(() => {
lane.pending -= 1
lifetime.dispose()
})
// Why: a reloaded document must stop waiting even while its request is queued.
return rejectOnDropAbort(prepared, lifetime.signal)
}
}

export async function prepareDroppedPaths(
request: PrepareDroppedPathsRequest,
env: DragTempCopyEnvironment,
signal?: AbortSignal
): Promise<PreparedDroppedPaths> {
const results = await materializeDragTempPaths(request.paths, env, signal)
const acceptsOriginal = request.consumer === 'main-reader'
const paths = results.flatMap((result) =>
result.status === 'imported'
? [result.destPath]
: result.status === 'uncopied' && acceptsOriginal
? [result.sourcePath]
: []
)
const unprepared = results.flatMap((result) =>
result.status === 'imported' || (result.status === 'uncopied' && acceptsOriginal)
? []
: [result]
)
const commonReason = unprepared.every((item) => item.reason === unprepared[0]?.reason)
? unprepared[0]?.reason
: undefined
return {
paths,
failures: unprepared.length > 0 ? [copyFailure(unprepared.length, commonReason)] : []
}
}

function failDroppedPathPreparation(
paths: readonly string[],
platform: NodeJS.Platform,
reason?: NativeFileDropCopyFailureReason
): PreparedDroppedPaths {
const ordinary = paths.filter((path) => !mayNeedDragTempCopy(path, platform))
const lost = paths.length - ordinary.length
return {
paths: ordinary,
failures: lost > 0 ? [copyFailure(lost, reason)] : []
}
}

async function copyDroppedPaths(
request: PrepareDroppedPathsRequest,
rendererSignal: AbortSignal,
deps: DroppedPathPreparationDeps
): Promise<PreparedDroppedPaths> {
const timeout = new AbortController()
const timer = setTimeout(
() => timeout.abort(new Error('Copying the dropped files took too long')),
deps.copyTimeoutMs ?? DRAG_TEMP_COPY_TIMEOUT_MS
)
const signal = AbortSignal.any([rendererSignal, timeout.signal])
try {
return await rejectOnDropAbort(
deps.getCopyEnvironment().then((env) => prepareDroppedPaths(request, env, signal)),
signal
)
} catch (error) {
if (rendererSignal.aborted) {
throw error
}
return failDroppedPathPreparation(
request.paths,
deps.platform,
timeout.signal.aborted ? 'timed-out' : undefined
)
} finally {
clearTimeout(timer)
}
}

export function rejectOnDropAbort<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => reject(signal.reason)
if (signal.aborted) {
onAbort()
} else {
signal.addEventListener('abort', onAbort, { once: true })
}
void work.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error) => {
signal.removeEventListener('abort', onAbort)
reject(error)
}
)
})
}

function copyFailure(pathCount: number, commonReason?: NativeFileDropCopyFailureReason) {
return {
byteLength: 0,
pathCount,
reason: 'temp-copy-failed' as const,
target: 'rejected' as const,
...(commonReason ? { commonReason } : {})
}
}
Loading
Loading