From 3c3ccf75edc007936d7c6e2d72b4ff289a5a97df Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 8 Sep 2026 13:21:02 +0200 Subject: fix(client): write a download to .part and rename it when it completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 `.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 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- packages/meshbay-client/src/main.js | 40 +++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) (limited to 'packages/meshbay-client/src/main.js') 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 `.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; }); -- cgit v1.2.3