From 54f9f24726c49cdffad88abb0a50ae8e454d482a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 24 Aug 2026 00:12:33 +0000 Subject: [PATCH] uploads land atomically: write aside, then rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun.write(filePath, file) streams to the name the file will end up with, so for as long as an upload takes, that name exists holding a partial file and nothing watching the folder can tell. The failure is silent rather than loud — a truncated m4a decodes fine, it is just short — so a watcher transcribing voice memos as they arrive would produce a clean transcript of the first few seconds of a two-minute recording and report success. Writing to .part and renaming makes the final name either absent or complete. Every consumer gets that for free: the phone's own uploadVerified, a watcher, an agent reading the folder. The alternative was each of them inventing "the size stopped changing", which cannot tell a finished upload from a stalled one. SendOff had the same shape and it matters more there, because that is a folder things arrive in unattended — neither a recipient watching it nor the push notification may name a file still being written. .part rather than a dotfile, deliberately: a leftover should be VISIBLE, named for what it was going to be, beside where it belongs. A failed write cleans up after itself; only an interrupted process leaves a fragment, which is the case worth being able to see. Nothing sweeps them — a job deleting files it did not create is a worse trade than a fragment somebody can spot. Server-side only. The phone uploads exactly as before and its verification read happens after the response, by which time the rename has landed. No new build. Verified: a watcher polling every millisecond through a 40 MB write never saw a partial file under the final name, and a real 8 MB upload through the live route left the file complete with no .part behind. 1422 pass. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/servers/api/file-browser/router.ts | 30 +++++++++++++++++++++++++- src/servers/api/sendoff/router.ts | 14 ++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index f3a602e1..75fef4b5 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -284,12 +284,40 @@ router.post('/upload', async (ctx) => { const raw = body['file']; const files = Array.isArray(raw) ? raw : raw ? [raw] : []; + /* + WRITE ASIDE, THEN RENAME. Never straight to the final path. + + `Bun.write(filePath, file)` streams to the name the file will end up with, so for as long as the + upload takes, that name exists holding a partial file — and anything watching the folder cannot + tell. The failure is silent rather than loud: a truncated m4a decodes fine, it is simply short. A + watcher transcribing voice memos as they land would produce a clean transcript of the first few + seconds of a two-minute recording and report success. + + A rename within one directory is atomic, so afterwards the final name either does not exist or is + a complete file. Every consumer gets that for free — the phone's own `uploadVerified`, a watcher, + an agent reading the folder — instead of each inventing a heuristic like "the size stopped + changing", which cannot tell a finished upload from a stalled one. + + `.part` rather than a dotfile, deliberately: a leftover should be VISIBLE, named for what it was + going to be, next to where it belongs. Nothing sweeps them up — a job deleting files it did not + create is a worse trade than a fragment somebody can see. + */ for (const file of files) { if (!(file instanceof File)) continue; const filePath = resolve(targetDir, file.name); if (!isInside(rootDir, filePath)) continue; await mkdir(dirname(filePath), { recursive: true }); - await Bun.write(filePath, file); + + const partPath = `${filePath}.part`; + try { + await Bun.write(partPath, file); + await rename(partPath, filePath); + } catch (err) { + // Leave nothing half-named. Only an interrupted PROCESS leaves a fragment behind, which is + // the case worth being able to see. + await rm(partPath, { force: true }).catch(() => {}); + throw err; + } } return ctx.json({ ok: true }); diff --git a/src/servers/api/sendoff/router.ts b/src/servers/api/sendoff/router.ts index b29f046b..4699ff7c 100644 --- a/src/servers/api/sendoff/router.ts +++ b/src/servers/api/sendoff/router.ts @@ -1,4 +1,4 @@ -import { mkdir } from 'node:fs/promises'; +import { mkdir, rename, rm } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { extname, join } from 'node:path'; import { createRouter } from '../../create-router'; @@ -149,9 +149,19 @@ sendoffRouter.post('/send', async (ctx) => { await mkdir(dir, { recursive: true }); const written: { name: string; size: number }[] = []; + // Aside, then rename — the same reasoning as the file browser upload, and it matters more here + // because this is a folder things ARRIVE in unattended. Neither a recipient watching it nor the + // notification below may point at a name that is still being written. for (const file of files) { const path = freePath(dir, safeFilename(file.name)); - await Bun.write(path, await file.arrayBuffer()); + const partPath = `${path}.part`; + try { + await Bun.write(partPath, await file.arrayBuffer()); + await rename(partPath, path); + } catch (err) { + await rm(partPath, { force: true }).catch(() => {}); + throw err; + } written.push({ name: path.split('/').pop() ?? file.name, size: file.size }); }