Skip to content

fix(download): stream to a temp file then move into place#220

Merged
chhoumann merged 4 commits into
masterfrom
fix/download-temp-then-move
Jun 25, 2026
Merged

fix(download): stream to a temp file then move into place#220
chhoumann merged 4 commits into
masterfrom
fix/download-temp-then-move

Conversation

@chhoumann

Copy link
Copy Markdown
Owner

The remaining crash

#113 fixed the download downloader's own peak heap (whole-file buffer → bounded 4 MiB Range chunks, released in 2.17.1). But the crash was still reported on mobile, and on a matched Android setup (Obsidian 1.12.7 + Waypoint 3.0.1) it reproduces deterministically.

Root cause: the streaming download appended each chunk straight to the final vault path. That makes the growing, half-written .mp3 visible to Obsidian's file watcher - a single 47 MB download fires 1 create + ~11 modify events on the media file (measured). Watcher plugins (Waypoint here, but equally Dataview, Obsidian Git, MOC/index generators) re-read/re-scan the file on every event, and ~11 re-reads of a growing media file on a memory-constrained mobile WebView - racing the chunked writer - OOM-kills the renderer. The trigger is the event storm, not any one plugin.

The fix: stream to a temp, then move into place

Stream every chunk to a dot-prefixed sibling temp (.{name}.{token}.podnotes-partial) that Obsidian keeps out of the file index entirely - so it fires zero watcher events while it grows - then adapter.rename the completed, size-verified file into place. Watchers then see exactly one create of an already-complete file - the same shape the pre-#113 atomic createBinary path produced.

Validation (Android emulator, Obsidian 1.12.7 + Waypoint 3.0.1)

build + Waypoint result events on final .mp3 peak heap
old + off ✓ byte-perfect 1 create + 11 modify 30 MB
old + on 💥 CRASH @ 13.7 s / 25 MB - -
new + off ✓ byte-perfect 1 create + 0 modify 22 MB
new + on no crash 1 create + 0 modify 22 MB

Triangulated across 3 CDN families (megaphone, simplecast, wnyc) with Waypoint on - all complete, 0 modify events, no orphan temp, the moved file is index-resolvable (playback works), heap stays bounded. (Note: podtrac/simplecast ad-stitch per-UA, so total size varies per request; byte-perfection is verified by PodNotes' internal total === probe.totalSize gate, not an external size compare.)

Compatibility

Tests

771 unit tests pass; lint + typecheck clean. New coverage: partialPathFor/isPartialPath, moveIntoPlace (rename / copy+remove / throw-no-buffer), sweepStalePartials (orphan vs active vs real file; best-effort), and end-to-end streaming asserting the dotfile temp, the single rename, no orphan, index-resolvability, move-failure cleanup, and the stale-partial sweep.

Fixes the crash reported on #113.

Appending each range chunk straight to the final vault path made the
growing, half-written media file visible to Obsidian's file watcher: a
single download fired ~12 create/modify events on the .mp3. Watcher
plugins (Waypoint, Dataview, Obsidian Git, MOC/index generators) re-scan
the file on every event, and on a memory-constrained mobile WebView that
re-scan storm - racing the chunked writer - OOM-crashes the app. Reported
against Waypoint, but the cause is the event storm, not any one plugin.

Stream every chunk to a dot-prefixed sibling temp that Obsidian keeps out
of the file index (zero watcher events while it grows), then rename the
completed, size-verified file into place - exactly one create of the
finished file, the same shape the pre-#113 atomic createBinary path had.

- prefer adapter.rename (in-place, buffers nothing, preserving the #113
  memory win); fall back to copy+remove; never re-buffer the whole file,
  which would reintroduce the OOM this path exists to avoid
- unique-per-attempt temp names + an active-set-guarded folder sweep, so
  colliding or concurrent downloads can't corrupt each other's temps and
  partials orphaned by a killed download get reclaimed
- size verification still runs before the move; on failure the temp is
  removed, never the final path

Validated on Android (Obsidian 1.12.7 + Waypoint 3.0.1): the matched
scenario that OOM-crashed now completes, modify events on the final file
drop from ~12 to 0 (one create), byte-perfect across megaphone, simplecast
and wnyc, heap bounded, no orphan temp left behind.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 25, 2026

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 948ab77
Status: ✅  Deploy successful!
Preview URL: https://97b27ac1.podnotes.pages.dev
Branch Preview URL: https://fix-download-temp-then-move.podnotes.pages.dev

View logs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: af6ebe773c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/download/streaming.ts Outdated
const dir = slash === -1 ? "" : filePath.slice(0, slash + 1);
const name = slash === -1 ? filePath : filePath.slice(slash + 1);
const token = `${Date.now().toString(36)}-${(partialCounter++).toString(36)}`;
return `${dir}.${name}.${token}${PARTIAL_SUFFIX}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve filename budget for the temp suffix

For streaming-capable adapters, a title/template that safeDownloadFilePath already caps to the filesystem's 255-unit filename limit now gets expanded again here by adding the leading dot, token, and .podnotes-partial suffix before writeBinary runs. Those long episodes previously wrote to the capped final path, but now the temp component exceeds the same per-file limit and the download fails with an overlong filename; truncate the temp basename or reserve space for the suffix when constructing it.

Useful? React with 👍 / 👎.

partialPathFor prepended a dot and appended ".<token>.podnotes-partial" to
the already-length-capped final name, which could push the temp's name
segment past the 255-unit ENAMETOOLONG limit for a maxed-out title - so a
very long episode that downloaded fine before could fail on the new
temp-then-move write path. Put the unique token first (so it survives the
tail truncation) and run the temp path through enforceMaxPathLength.
rename/copy/remove/list are public, required members of Obsidian's
DataAdapter - only appendBinary is runtime-only - so the optional guards
and the copy+remove fallback in moveIntoPlace guarded a state that cannot
occur (anything past the writeBinary+appendBinary gate is a full
DataAdapter with rename). Extend the real DataAdapter for the single
appendBinary bolt-on, correct the now-accurate comment, finalize with a
plain adapter.rename, and drop the unreachable fallback - which also
removes a latent "copy succeeded but remove threw, so a complete download
is reported failed" bug. Collapse the streaming branch's three
catch->delete->rethrow blocks into one finally guard.
@chhoumann chhoumann merged commit 5aee276 into master Jun 25, 2026
2 checks passed
github-actions Bot pushed a commit that referenced this pull request Jun 25, 2026
## [2.17.2](2.17.1...2.17.2) (2026-06-25)

### Bug Fixes

* **download:** stream to a temp file then move into place ([#220](#220)) ([5aee276](5aee276))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.17.2 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant