uploads land atomically: write aside, then rename
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 <name>.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 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user