Skip to content

fix(download): await temp-file unlink + cp() to stop ENOENT race + un…#1019

Open
NadeemIqbal wants to merge 1 commit into
wonday:masterfrom
NadeemIqbal:fix/issue-1018-cache-race
Open

fix(download): await temp-file unlink + cp() to stop ENOENT race + un…#1019
NadeemIqbal wants to merge 1 commit into
wonday:masterfrom
NadeemIqbal:fix/issue-1018-cache-race

Conversation

@NadeemIqbal
Copy link
Copy Markdown

Fixes #1018.

Summary

_downloadFile had two latent bugs that surface together on Android 14 + New Architecture. Rewriting the function with proper async/await throughout closes both — no public API change.

Root cause

1. ENOENT race on .pdf.tmp

The pre-fetch cleanup was fire-and-forget:

const tempCacheFile = cacheFile + '.tmp';
this._unlinkFile(tempCacheFile);   // returns a Promise, not awaited

this.lastRNBFTask = ReactNativeBlobUtil.config({ path: tempCacheFile, ... }).fetch(...)

_unlinkFile is async, so the deletion of tempCacheFile runs concurrently with the native open(path) inside ReactNativeBlobUtil.fetch. On Android 14 + New Architecture the timing flips and open lands while the file is mid-delete, surfacing as:

Error: /data/user/0/<pkg>/cache/<sha1>.pdf.tmp: open failed: ENOENT (No such file or directory)

2. Uncaught rejection bypassing onError

The success path was structured as:

this.lastRNBFTask = config().fetch().progress().catch(handler);
this.lastRNBFTask
    .then(async (res) => {
        ...
        ReactNativeBlobUtil.fs.cp(tempCacheFile, cacheFile)   // not awaited
            .then(() => { ... })
            .catch(async (error) => { throw error });          // re-throw escapes
    })
    .catch(...);

The inner cp(...).then(...).catch(err => { throw err }) is never awaited from the outer .then. Any cp rejection re-thrown from the inner .catch becomes an unhandled promise rejection instead of propagating to the outer .catch_onError is never called → the onError prop never fires.

Fix

Rewrite _downloadFile with async/await throughout — single try/catch covering the whole flow:

  • await the temp-file unlink before starting the fetch (closes the ENOENT race)
  • await the fetch, stat check, cacheFile unlink, and cp into the final location, all inside one try
  • Single catch cleans up both temp + final files and calls _onError exactly once

this.lastRNBFTask is still assigned to the in-flight fetch task before await, so the cancellation paths in setNativeProps / componentWillUnmount still find a cancellable task.

API impact

None. Public component API unchanged. No new dependencies.

Test plan

  • Android 14 (API 34) with newArchEnabled=true<Pdf source={{ uri: '<remote-url>', cache: true }} /> on fresh install no longer crashes with ENOENT
  • Forced network failure (airplane mode / 404 URL) → error flows through the onError prop (was: escaped as Uncaught (in promise))
  • Android 13 + old architecture — no regression
  • iOS — no regression (this path is shared, but the bug only manifested on Android)
  • Repeated unmount/remount mid-download — cancel() still aborts the fetch correctly

Syntax verified locally via node --check index.js.

…handled rejection

`_downloadFile` had two latent bugs that surface together on Android 14
+ New Architecture (wonday#1018):

1. The pre-fetch `_unlinkFile(tempCacheFile)` was fire-and-forget, so
   ReactNativeBlobUtil.config(...).fetch(...) raced the in-flight delete
   and could open(path) before the unlink completed, surfacing as
   `ENOENT (No such file or directory)` on the `.pdf.tmp` file.

2. The success path was structured as `.fetch(...).catch(handler).then(...)`
   and the inner `cp(tempCacheFile, cacheFile).catch(err => { throw err })`
   was never awaited. Any cp rejection escaped the chain as an
   "Uncaught (in promise)" instead of flowing through `_onError`, so the
   `onError` prop was never called.

Rewrite `_downloadFile` to use proper async/await throughout:

- await the temp-file unlink before starting the fetch (closes the race
  that triggers ENOENT)
- await the fetch task, the stat check, the cacheFile unlink, and the
  cp into the final cache location, all inside a single try/catch
- single failure path that cleans up both files and calls `_onError`
  exactly once, so unhandled rejections can no longer escape

No public API change. The existing `lastRNBFTask` reference is still
assigned to the in-flight fetch task before `await`, so the cancellation
paths in `setNativeProps` / `componentWillUnmount` continue to work.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Android 14 / New Arch] PDF download crashes with ENOENT on CacheDir temp file

1 participant