summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-08 13:21:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-08 13:21:02 +0200
commit3c3ccf75edc007936d7c6e2d72b4ff289a5a97df (patch)
treecfad17ef2f10cad15430195c7f87813d500998a1
parent79520f5f2f9a38ef719fca4dc0f95b001b477e38 (diff)
downloadmeshbay-3c3ccf75edc007936d7c6e2d72b4ff289a5a97df.tar.gz
fix(client): write a download to .part and rename it when it completes
`save:abort` deleted a cancelled download, but nothing covered the application being quit, killed or crashing mid-transfer: the write stream was abandoned and a truncated file kept the final name — the exact thing save:abort's own comment calls worse than no file at all, because it looks complete to whoever opens it next. Downloads go to `<target>.part` and are renamed after the stream has flushed, which is the convention the node already uses for uploads (`_do_file_upload`). A crash now leaves a self-evidently unfinished file. `before-quit` also clears any `.part` still open, synchronously — it does not wait for promises — so a deliberate quit leaves nothing at all. Verified by hand: .part during the transfer, survives SIGKILL, renamed on completion, and gone after a cancel or a clean quit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
-rw-r--r--packages/meshbay-client/src/main.js40
1 files changed, 36 insertions, 4 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 0a5723b..9ff0069 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -734,6 +734,19 @@ function registerBridge() {
const completedPaths = new Map();
let sinkId = 0;
+ // A clean quit still has to tidy up: the `.part` convention above means a
+ // crash leaves an obviously-unfinished file rather than a plausible one, but
+ // quitting deliberately should leave nothing at all. Synchronous on purpose —
+ // `before-quit` does not wait for promises, and an async cleanup here would
+ // race the process exiting and finish nothing.
+ app.on('before-quit', () => {
+ for (const [id, sink] of sinks) {
+ try { sink.stream.destroy(); } catch { /* already closed */ }
+ try { fs.unlinkSync(sink.partial); } catch { /* already gone */ }
+ sinks.delete(id);
+ }
+ });
+
/** `name`, or the first "name (n).ext" that is not taken — never an overwrite. */
function freeName(dir, filename) {
if (!fs.existsSync(path.join(dir, filename))) return filename;
@@ -826,8 +839,17 @@ function registerBridge() {
target = result.filePath;
}
+ // Written to `<target>.part` and renamed on completion, never straight to
+ // the final name. `save:abort` already deleted a cancelled download, but
+ // nothing covered the app being quit, killed or crashing mid-transfer: the
+ // stream was simply abandoned and a truncated file kept the final name,
+ // which is the exact thing save:abort's own comment says is worse than no
+ // file at all — it looks complete to whoever opens it next. A leftover
+ // `.part` is self-evidently unfinished, and it is the same convention the
+ // node already uses for uploads (`_do_file_upload`).
const id = String(++sinkId);
- sinks.set(id, { stream: fs.createWriteStream(target), path: target });
+ const partial = target + '.part';
+ sinks.set(id, { stream: fs.createWriteStream(partial), path: target, partial });
return { id, name: path.basename(target), path: target };
});
@@ -847,8 +869,16 @@ function registerBridge() {
const sink = sinks.get(String(id));
if (!sink) return false;
sinks.delete(String(id));
- completedPaths.set(String(id), sink.path);
await new Promise((resolve) => sink.stream.end(resolve));
+ // The rename is what publishes the download. Only after the stream has
+ // flushed, or the file bearing the final name would still be short.
+ try {
+ fs.renameSync(sink.partial, sink.path);
+ } catch (err) {
+ console.error('[MeshBay] could not finalise download:', err.message);
+ return false;
+ }
+ completedPaths.set(String(id), sink.path);
return true;
});
@@ -865,8 +895,10 @@ function registerBridge() {
sinks.delete(String(id));
await new Promise((resolve) => sink.stream.close(resolve));
// A cancelled download leaves a truncated file, which is worse than none:
- // it looks like a complete one to whoever opens it next.
- try { fs.unlinkSync(sink.path); } catch { /* already gone */ }
+ // it looks like a complete one to whoever opens it next. Only the `.part`
+ // exists at this stage — the final name is only taken by the rename in
+ // save:end — so this removes that.
+ try { fs.unlinkSync(sink.partial); } catch { /* already gone */ }
return true;
});