Files
orca/src/main/ipc/runtime-upload-slice-boundaries.test.ts
T
mmarabelandNeil 68f0b2e835 feat(runtime): stream file uploads instead of buffering whole files (#16106)
* feat(runtime): stream file uploads instead of buffering whole files

Staging read each dropped file whole with readFile(), base64-encoded it
(a 4/3 expansion), and passed the string through IPC to the renderer,
which re-chunked it. Peak memory was ~2.3x the file size before a byte
moved, so a 25 MB per-file cap existed to protect the heap.

Staging now records identity only. The byte pump moves into main, where
the file handle and the runtime socket both live: 384 KiB slices (512 KiB
once base64-encoded, matching the chunk size the renderer used) appended
through the existing files.writeBase64Chunk RPC. Peak memory is one slice
regardless of file size, so the ceilings become user-safety limits on an
unattended transfer — 2 GB per file, 8 GB per drop — and over-limit errors
name both the size and the limit.

Because staging and streaming are separate calls, the staged entry carries
size, inode, device and mtime, and the streamer re-checks all four against
the pre-open lstat and against the handle it actually reads. A source
replaced or rewritten at the same size between the two calls is refused
rather than uploaded under the original name. The post-read check compares
mtime as well as size, so an in-place rewrite mid-transfer aborts before
commitUpload renames anything into place.

O_NOFOLLOW, realpath containment and stat identity are preserved, and the
pairing revision plus the runtime id ride every chunk, so a re-pair or a
replacement runtime aborts instead of appending the rest of the file to a
different host.

No wire change: files.writeBase64Chunk and its params are untouched, so
old and new hosts behave identically. The SSH import path is separate and
unchanged. The web client has no local filesystem to stream from and says
so instead of failing obscurely.

* fix(runtime): close the empty-upload and per-drop budget holes

Two gaps the first pass left open.

A zero-byte source returned before the post-transfer identity check, so a
file that gained content during the empty write's round trip committed as
an empty file at the user's chosen name. The empty chunk now falls through
to the same final check the slice loop uses.

Each staged source also started its own byte counter, so the 8 GB ceiling
capped one source rather than the drop: five 2 GB files staged cleanly at
10 GB total. The IPC handler now carries one budget across sourcePaths and
adds only what each source actually staged. The per-file ceiling is still
re-enforced where the bytes move; the drop total holds at staging because
identity enforcement means each file streams exactly the bytes measured.

* docs(runtime): name the invariants the upload helpers carry

* fix(runtime): name the source in errors and stop uploads with their window

Three problems an independent review turned up.

A dropped file's relative path is '', so the over-limit error read "'' is
3 GB, over the 2 GB per-file remote import limit" — the message this change
exists to fix, naming nothing. Errors now fall back to the file's own name;
the staged entry keeps '' so the destination path is unaffected. The
streamer had the same shape, falling back to the hidden .orca-upload-<nonce>
temp destination, a path the user never chose.

The byte loop used to live in the renderer and died with it. Moving it into
main meant closing or reloading the window left the rest of a multi-GB
transfer running, with the renderer's temp cleanup never reaching its
finally. An AbortSignal now rides the caller's lifetime and every chunk, is
re-checked per slice, and main sweeps the abandoned temp path itself when
the renderer is no longer there to do it.

Upload failures also reached the import result wrapped in Electron's
"Error invoking remote method '...'" prefix, because the throw crossed IPC
instead of happening in-renderer; extractIpcErrorMessage unwraps it.

An existing staging test asserted the empty-name message, so it encoded the
bug rather than catching it; it now asserts the file name.

* test(runtime): cover the containment check and the per-chunk host guards

The "escapes the dropped root" test only reached the lstat symlink guard,
so assertEntryInsideRoot had no coverage at all. The shape that actually
needs it is a regular file under a symlinked intermediate directory: lstat
sees a plain file, and realpath containment is the only thing that refuses
it. Disabling the guard now fails this test and nothing else.

Nothing asserted that the SSH target, connection generation and execution
host reach the writeBase64Chunk params either — the renderer tests stop at
the IPC boundary, so the streamer's half of that contract was untested.

* fix(runtime): survive a straggling append when sweeping an aborted upload

Aborting rejects the in-flight chunk locally, but the host may still apply
that append, and appends open with flag 'a' — which recreates the file the
sweep just deleted. The delete and the straggler also race: they are
separate calls on a queue that is not ordered between them.

Slices are strictly sequential, so at most one append can be outstanding.
A second pass after it has had time to land is therefore sufficient, not
merely a heuristic. The sweep moves out of filesystem-mutations.ts into its
own module so the behaviour is testable directly.

Found by an independent review pass, which also pointed out that the
"escapes the dropped root" test only reached the lstat symlink guard.

* fix(runtime): abort uploads only when the document commits, and honour manual disconnect per chunk

did-start-navigation fires before will-navigate blocks an external link or a
stray file drop, and the renderer survives those (verified against Electron 43
with a hidden window). Aborting there killed a healthy upload with a misleading
'window went away' error. did-navigate fires only once a new document has
replaced the caller.

The renderer's per-chunk calls used to go through the IPC handler that refuses
a manually disconnected environment; the loop in main made no such check, so a
disconnect mid-upload kept pushing the rest of the file. The handler now
resolves the selector to an environment id and the streamer checks it per slice.

Adds slice-boundary coverage against the real chunk schema and host write
flags, staging-to-stream on a real filesystem, and handler-level lifetime tests.

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-14 14:08:31 -07:00

384 lines
13 KiB
TypeScript

import {
appendFile,
mkdir,
mkdtemp,
readFile,
rm,
stat,
truncate,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FileWriteBase64Chunk } from '../../shared/rpc-contract/files-mutation-params'
import type { StagedRuntimeUploadFileIdentity } from '../../shared/runtime-upload-staging-contract'
// Why: real limits, real host write flags ('wx' then 'a') and the real chunk
// schema — the slice loop is exercised exactly at the boundaries it must respect.
vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} }))
type ChunkParams = { relativePath: string; contentBase64: string; append: boolean }
type CallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal }
type CallArgs = [
userDataPath: string,
environmentId: string,
method: string,
params: ChunkParams,
timeoutMs?: number,
expectedEnvironmentPairingRevision?: number,
envelope?: unknown,
options?: CallOptions
]
const callRuntimeEnvironment = vi.fn<(...args: CallArgs) => Promise<unknown>>()
// Why: vi.fn retains every call's params; a 2 GiB stream would pin ~2.8 GB of
// base64 in mock.calls and masquerade as a leak. Big tests swap in a plain fn.
let transportImpl: (...args: CallArgs) => Promise<unknown> = (...args) =>
callRuntimeEnvironment(...args)
vi.mock('./runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: (...args: CallArgs) => transportImpl(...args)
}))
const { RUNTIME_UPLOAD_SLICE_BYTES, streamExternalFileToRuntime } =
await import('./runtime-upload-file-stream')
const { stageOneSourceForRuntimeUpload } = await import('./filesystem-runtime-upload-staging')
const { REMOTE_IMPORT_MAX_FILE_BYTES, REMOTE_IMPORT_MAX_TOTAL_BYTES, formatByteCeiling } =
await import('./runtime-import-limits')
const SLICE = RUNTIME_UPLOAD_SLICE_BYTES
const WIRE_CHUNK_CHARS = 512 * 1024
const OK = { id: 'x', ok: true, result: {}, _meta: {} }
let workDir: string
let remoteDir: string
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), 'orca-upload-bounds-'))
remoteDir = join(workDir, 'remote')
await mkdir(remoteDir)
callRuntimeEnvironment.mockReset()
callRuntimeEnvironment.mockResolvedValue(OK)
transportImpl = (...args) => callRuntimeEnvironment(...args)
})
afterEach(async () => {
await rm(workDir, { force: true, recursive: true })
})
function chunkCalls(): ChunkParams[] {
return callRuntimeEnvironment.mock.calls
.filter(([, , method]) => method === 'files.writeBase64Chunk')
.map(([, , , params]) => params)
}
/** Mirrors the host: first chunk is an exclusive create, appends open with 'a'. */
function installRealHostWrites(): void {
callRuntimeEnvironment.mockImplementation(async (_u, _e, method, params) => {
if (method === 'files.writeBase64Chunk') {
const parsed = FileWriteBase64Chunk.parse({ worktree: 'wt-1', ...params })
await writeFile(
join(remoteDir, parsed.relativePath),
Buffer.from(parsed.contentBase64, 'base64'),
{
flag: parsed.append ? 'a' : 'wx'
}
)
}
return OK
})
}
async function identityOf(path: string): Promise<StagedRuntimeUploadFileIdentity> {
const s = await stat(path)
return { byteLength: s.size, inode: s.ino, deviceId: s.dev, modifiedAtMs: s.mtimeMs }
}
async function argsFor(sourceRootPath: string, entryRelativePath = '', relativePath = 'dest.tmp') {
const target = entryRelativePath ? join(sourceRootPath, entryRelativePath) : sourceRootPath
return {
userDataPath: '/user-data',
environmentId: 'env-1',
sourceRootPath,
entryRelativePath,
expected: await identityOf(target),
worktree: 'wt-1',
relativePath,
expectedEnvironmentPairingRevision: 7,
expectedEnvironmentRuntimeId: 'rt-1'
}
}
function patterned(size: number, seed: number): Buffer {
const buffer = Buffer.allocUnsafe(size)
for (let i = 0; i < size; i += 1) {
buffer[i] = (i * 31 + seed) & 0xff
}
return buffer
}
describe('slice boundaries', () => {
const sizes = [
1,
2,
3,
4,
SLICE - 1,
SLICE,
SLICE + 1,
2 * SLICE - 1,
2 * SLICE,
2 * SLICE + 1,
3 * SLICE + 7
]
for (const size of sizes) {
it(`streams ${size} bytes as ceil(size/slice) schema-valid chunks that the host reassembles exactly`, async () => {
installRealHostWrites()
const contents = patterned(size, size)
const source = join(workDir, `s-${size}.bin`)
await writeFile(source, contents)
const dest = `dest-${size}.tmp`
await expect(streamExternalFileToRuntime(await argsFor(source, '', dest))).resolves.toEqual({
byteLength: size
})
const calls = chunkCalls()
const expectedChunks = Math.ceil(size / SLICE)
expect(calls).toHaveLength(expectedChunks)
expect(calls.map((c) => c.append)).toEqual(calls.map((_, i) => i > 0))
for (const [index, call] of calls.entries()) {
const isLast = index === calls.length - 1
expect(call.contentBase64.length).toBeLessThanOrEqual(WIRE_CHUNK_CHARS)
if (!isLast) {
expect(call.contentBase64.length).toBe(WIRE_CHUNK_CHARS)
}
expect(call.relativePath).toBe(dest)
}
const remote = await readFile(join(remoteDir, dest))
expect(remote.equals(contents)).toBe(true)
})
}
it('sends a zero-byte file as one empty exclusive create the host schema accepts', async () => {
installRealHostWrites()
const source = join(workDir, 'empty.bin')
await writeFile(source, '')
await expect(
streamExternalFileToRuntime(await argsFor(source, '', 'empty.tmp'))
).resolves.toEqual({
byteLength: 0
})
expect(chunkCalls()).toHaveLength(1)
expect(chunkCalls()[0]).toMatchObject({
relativePath: 'empty.tmp',
contentBase64: '',
append: false
})
expect((await stat(join(remoteDir, 'empty.tmp'))).size).toBe(0)
})
it('carries the pairing revision, runtime id and signal on every chunk', async () => {
const source = join(workDir, 'guards.bin')
await writeFile(source, patterned(2 * SLICE + 1, 3))
await streamExternalFileToRuntime(await argsFor(source))
const chunkInvocations = callRuntimeEnvironment.mock.calls.filter(
([, , method]) => method === 'files.writeBase64Chunk'
)
expect(chunkInvocations).toHaveLength(3)
for (const [, environmentId, , , timeoutMs, revision, envelope, options] of chunkInvocations) {
expect(environmentId).toBe('env-1')
expect(timeoutMs).toBe(30_000)
expect(revision).toBe(7)
expect(envelope).toBeUndefined()
expect(options?.expectedEnvironmentRuntimeId).toBe('rt-1')
}
})
})
describe('staging → streaming end to end on a real filesystem', () => {
it('streams every staged entry of a dropped directory using the identity staging recorded', async () => {
installRealHostWrites()
const root = join(workDir, 'drop me')
await mkdir(join(root, 'sub', 'deeper'), { recursive: true })
const files: Record<string, Buffer> = {
'a.txt': Buffer.from('alpha'),
'..keep': Buffer.from('dot-dot-prefixed name is a valid child'),
'héllo wörld.bin': patterned(SLICE, 9),
'sub/empty': Buffer.alloc(0),
'sub/deeper/big.bin': patterned(2 * SLICE + 5, 11)
}
for (const [rel, body] of Object.entries(files)) {
await writeFile(join(root, rel), body)
}
const staged = await stageOneSourceForRuntimeUpload(root)
expect(staged.status).toBe('staged')
if (staged.status !== 'staged') {
return
}
const fileEntries = staged.entries.filter((e) => e.kind === 'file')
expect(fileEntries.map((e) => e.relativePath).sort()).toEqual(Object.keys(files).sort())
for (const entry of fileEntries) {
if (entry.kind !== 'file') {
continue
}
const dest = `up-${entry.relativePath.replace(/[^a-z0-9]/gi, '_')}.tmp`
await expect(
streamExternalFileToRuntime({
userDataPath: '/u',
environmentId: 'env-1',
sourceRootPath: staged.sourcePath,
entryRelativePath: entry.relativePath,
expected: {
byteLength: entry.byteLength,
inode: entry.inode,
deviceId: entry.deviceId,
modifiedAtMs: entry.modifiedAtMs
},
worktree: 'wt-1',
relativePath: dest
})
).resolves.toEqual({ byteLength: files[entry.relativePath]!.length })
const remote = await readFile(join(remoteDir, dest))
expect(remote.equals(files[entry.relativePath]!)).toBe(true)
}
})
it('streams a dropped single file using the identity staging recorded', async () => {
installRealHostWrites()
const source = join(workDir, 'single.bin')
const body = patterned(SLICE + 1, 5)
await writeFile(source, body)
const staged = await stageOneSourceForRuntimeUpload(source)
expect(staged.status).toBe('staged')
if (staged.status !== 'staged') {
return
}
const entry = staged.entries[0]!
expect(entry.kind).toBe('file')
if (entry.kind !== 'file') {
return
}
await expect(
streamExternalFileToRuntime({
userDataPath: '/u',
environmentId: 'env-1',
sourceRootPath: staged.sourcePath,
entryRelativePath: entry.relativePath,
expected: entry,
worktree: 'wt-1',
relativePath: 'single.tmp'
})
).resolves.toEqual({ byteLength: body.length })
expect((await readFile(join(remoteDir, 'single.tmp'))).equals(body)).toBe(true)
})
})
describe('source mutation during transfer', () => {
it('rejects a source that grows during the transfer and never claims success', async () => {
const source = join(workDir, 'growing.bin')
await writeFile(source, patterned(2 * SLICE, 1))
const args = await argsFor(source)
callRuntimeEnvironment.mockImplementation(async (_u, _e, method) => {
if (method === 'files.writeBase64Chunk' && chunkCalls().length === 1) {
await appendFile(source, 'extra')
}
return OK
})
await expect(streamExternalFileToRuntime(args)).rejects.toThrow(
"File changed during upload: 'growing.bin'"
)
})
it('rejects a source truncated during the transfer instead of sending a short file', async () => {
const source = join(workDir, 'shrinking.bin')
await writeFile(source, patterned(3 * SLICE, 2))
const args = await argsFor(source)
callRuntimeEnvironment.mockImplementation(async (_u, _e, method) => {
if (method === 'files.writeBase64Chunk' && chunkCalls().length === 1) {
await truncate(source, SLICE)
}
return OK
})
await expect(streamExternalFileToRuntime(args)).rejects.toThrow(
"File truncated during upload: 'shrinking.bin'"
)
expect(chunkCalls().length).toBeLessThan(3)
})
it('accepts a staged identity whose inode and device are unreported (0) when size and mtime match', async () => {
const source = join(workDir, 'no-ino.bin')
await writeFile(source, patterned(10, 4))
const args = await argsFor(source)
args.expected = { ...args.expected, inode: 0, deviceId: 0 }
await expect(streamExternalFileToRuntime(args)).resolves.toEqual({ byteLength: 10 })
})
it('still refuses a wrong inode when only the device is unreported', async () => {
const source = join(workDir, 'wrong-ino.bin')
await writeFile(source, patterned(10, 4))
const args = await argsFor(source)
args.expected = { ...args.expected, inode: args.expected.inode + 1, deviceId: 0 }
await expect(streamExternalFileToRuntime(args)).rejects.toThrow(
"File changed since it was staged: 'wrong-ino.bin'"
)
expect(chunkCalls()).toHaveLength(0)
})
it('stops before the next slice when the signal aborts while a chunk is in flight', async () => {
const source = join(workDir, 'abort.bin')
await writeFile(source, patterned(3 * SLICE, 6))
const controller = new AbortController()
callRuntimeEnvironment.mockImplementation(async (_u, _e, method, _p, _t, _r, _env, options) => {
if (method !== 'files.writeBase64Chunk') {
return OK
}
if (chunkCalls().length === 2) {
return new Promise((_resolve, reject) => {
options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), {
once: true
})
controller.abort(new Error('window gone'))
})
}
return OK
})
await expect(
streamExternalFileToRuntime({ ...(await argsFor(source)), signal: controller.signal })
).rejects.toThrow('window gone')
expect(chunkCalls()).toHaveLength(2)
})
})
describe('formatByteCeiling bounds', () => {
it.each([
[0, '0 B'],
[1, '1 B'],
[1023, '1023 B'],
[1024, '1 KB'],
[1025, '1.1 KB'],
[25 * 1024 * 1024, '25 MB'],
[25 * 1024 * 1024 + 1, '25.1 MB'],
[REMOTE_IMPORT_MAX_FILE_BYTES, '2 GB'],
[REMOTE_IMPORT_MAX_FILE_BYTES + 1, '2.1 GB'],
[REMOTE_IMPORT_MAX_TOTAL_BYTES, '8 GB'],
[REMOTE_IMPORT_MAX_TOTAL_BYTES + 1, '8.1 GB'],
[1024 ** 5, '1024 TB']
])('%i → %s', (bytes, text) => {
expect(formatByteCeiling(bytes)).toBe(text)
})
})