diff --git a/.cspell-wordlist.txt b/.cspell-wordlist.txt index c75488d219..c3daee061e 100644 --- a/.cspell-wordlist.txt +++ b/.cspell-wordlist.txt @@ -347,3 +347,5 @@ unclip phonemizations phonemizes həlˈoʊ +NSURL +backgrounding diff --git a/packages/react-native-executorch/package.json b/packages/react-native-executorch/package.json index 9f42f1b735..1f7af98e13 100644 --- a/packages/react-native-executorch/package.json +++ b/packages/react-native-executorch/package.json @@ -118,11 +118,17 @@ "@huggingface/jinja": "^0.5.9" }, "peerDependencies": { + "@kesha-antonov/react-native-background-downloader": ">=4.4.0", "react": "*", "react-native": "*", "react-native-blob-util": "^0.24.0", "react-native-worklets": "^0.10.0" }, + "peerDependenciesMeta": { + "@kesha-antonov/react-native-background-downloader": { + "optional": true + } + }, "devDependencies": { "@babel/core": "^7.25.1", "@react-native/babel-preset": "0.83.6", diff --git a/packages/react-native-executorch/src/fetcher/backgroundDownloader.ts b/packages/react-native-executorch/src/fetcher/backgroundDownloader.ts new file mode 100644 index 0000000000..9414b55164 --- /dev/null +++ b/packages/react-native-executorch/src/fetcher/backgroundDownloader.ts @@ -0,0 +1,97 @@ +import { NativeModules, Platform, TurboModuleRegistry } from 'react-native'; + +// Optional integration with `@kesha-antonov/react-native-background-downloader`. +// +// An in-process transfer on iOS dies with the app: measured on device, suspending +// it tore the connection down ONE SECOND later, 43 MB into a 314 MB file, and only +// a background `NSURLSession` keeps going. That session is native work, and a +// download helper is not the part of this library that should be growing native +// code for it — there are React Native packages that do nothing else. +// +// So the fetcher uses one WHEN THE APP HAPPENS TO HAVE IT INSTALLED. It is an +// optional peer dependency: apps that install it get transfers that survive +// backgrounding (and an app kill), apps that don't keep the in-process path and +// pull in nothing. + +// The slice of the library's API the fetcher uses, declared structurally so this +// file typechecks with the dependency absent. +export interface BackgroundDownloadTask { + id: string; + state: 'PENDING' | 'DOWNLOADING' | 'PAUSED' | 'DONE' | 'FAILED' | 'STOPPED'; + begin(handler: (params: { expectedBytes: number }) => void): BackgroundDownloadTask; + progress( + handler: (params: { bytesDownloaded: number; bytesTotal: number }) => void + ): BackgroundDownloadTask; + done( + handler: (params: { bytesDownloaded: number; bytesTotal: number }) => void + ): BackgroundDownloadTask; + error(handler: (params: { error: string; errorCode: number }) => void): BackgroundDownloadTask; + start(): void; + // Keeps the bytes fetched so far as resume data, and resolves once that data + // has been written — not merely once the transfer has been asked to stop. + pause(): Promise; + resume(): Promise; + // Cancels and discards, unlike `pause`. + stop(): Promise; +} + +export interface BackgroundDownloader { + createDownloadTask(options: { + id: string; + url: string; + destination: string; + }): BackgroundDownloadTask; + // Tasks the session is still holding, including ones started by a previous + // launch of the app and ones paused with resume data. + getExistingDownloadTasks(): Promise; + // Releases the OS's background-session completion handler for a finished task. + // Resolves through the native module, so it can also reject. + completeHandler(id: string): void | Promise; +} + +// `null` once resolution has been attempted and come up empty; `undefined` while +// it has not been attempted at all. +let cached: BackgroundDownloader | null | undefined; + +// The library, or null when the app has not installed it (or installed the JS +// without linking the native side, as in Expo Go). Resolved once and remembered. +export function loadBackgroundDownloader(): BackgroundDownloader | null { + if (cached !== undefined) return cached; + cached = null; + + // Android transfers already run on the system DownloadManager, which survives + // backgrounding and an app kill on its own, so nothing here applies. + if (Platform.OS !== 'ios') return cached; + + try { + // A `require` inside a try/catch is Metro's own escape hatch for optional + // dependencies (`resolver.allowOptionalDependencies`, which React Native's + // Metro config turns on): a module that isn't installed is left unresolved + // instead of failing the bundle, and the throw lands right here. The name + // has to stay a literal — Metro collects dependencies statically and rejects + // a `require` of anything it cannot read off the call itself. + const required = require('@kesha-antonov/react-native-background-downloader'); + const candidate = (required?.default ?? required) as Partial; + + // Having the JS is not the same as having the native module: without a + // `pod install` (or in Expo Go) every call would throw, so fall back to the + // in-process path instead. + const isLinked = + TurboModuleRegistry.get('RNBackgroundDownloader') != null || + NativeModules.RNBackgroundDownloader != null; + + // Older majors expose a different surface (`download`, and a `pause` that + // drops the fetched bytes). Treat anything but the shape used below as + // absent rather than half-supporting it. + const hasApi = + typeof candidate?.createDownloadTask === 'function' && + typeof candidate?.getExistingDownloadTasks === 'function' && + typeof candidate?.completeHandler === 'function'; + + if (isLinked && hasApi) cached = candidate as BackgroundDownloader; + } catch { + // Not installed — the fetcher stays on its in-process backend. + } + + return cached; +} diff --git a/packages/react-native-executorch/src/fetcher/fetcher.ts b/packages/react-native-executorch/src/fetcher/fetcher.ts index 98da7fdb09..ca54748127 100644 --- a/packages/react-native-executorch/src/fetcher/fetcher.ts +++ b/packages/react-native-executorch/src/fetcher/fetcher.ts @@ -2,6 +2,11 @@ import { Platform } from 'react-native'; import RNBlobUtil from 'react-native-blob-util'; import * as telemetry from './telemetry'; +import { + loadBackgroundDownloader, + type BackgroundDownloader, + type BackgroundDownloadTask, +} from './backgroundDownloader'; import { RnExecuTorchError } from '../core/error'; const IS_ANDROID = Platform.OS === 'android'; @@ -25,8 +30,8 @@ export interface DownloadOptions { /** Called with overall progress in `[0, 1]` as bytes arrive. */ onProgress?: (progress: number) => void; /** - * Aborts the download. On iOS the bytes fetched so far are kept on disk so a - * later {@link download} of the same source resumes instead of restarting. + * Aborts the download. On iOS the bytes fetched so far are kept so a later + * {@link download} of the same source resumes instead of restarting. */ signal?: AbortSignal; /** @@ -185,6 +190,12 @@ async function downloadUrl(url: string, cb: DownloadUrlCallbacks): Promise {}); + // An interrupted iOS attempt also leaves state behind, and every bit of it + // is something a later download would CONTINUE from: the staged `.partial` + // and, with the background downloader in play, a paused task holding resume + // data. Clear it, or "download it again" quietly resumes the very attempt + // the caller is trying to replace. + if (!IS_ANDROID) await discardIosPartialDownload(dest); } else if (await RNBlobUtil.fs.exists(dest)) { // Cache hit — nothing to download. const size = await fileSize(dest); @@ -228,9 +239,18 @@ async function startDownload(url: string, dest: string, entry: InFlightDownload) }, }; - const path = IS_ANDROID - ? await downloadUrlViaAndroidDownloadManager(url, dest, cb) - : await downloadUrlViaIosStream(url, dest, cb); + // On iOS the optional background downloader is preferred when the app has it: + // it is the only one of the two that keeps running once the app is suspended. + const backgroundDownloader = IS_ANDROID ? null : loadBackgroundDownloader(); + + let path: string; + if (IS_ANDROID) { + path = await downloadUrlViaAndroidDownloadManager(url, dest, cb); + } else if (backgroundDownloader) { + path = await downloadUrlViaBackgroundSession(backgroundDownloader, url, dest, cb); + } else { + path = await downloadUrlViaIosStream(url, dest, cb); + } // Neither backend is guaranteed to emit a last sample at 100%: blob-util // throttles progress events, and DownloadManager is polled, so the final @@ -328,7 +348,168 @@ async function downloadUrlViaAndroidDownloadManager( return dest; } -// iOS backend: blob-util streams via the iOS URL session straight to disk. +// One background task per destination file, under an id that stays the same +// across app launches: that is what lets a later call adopt a transfer this +// process never started. +function backgroundTaskIdFor(dest: string): string { + return dest.split('/').pop()!; +} + +// The task the session is still holding for `id`, when it is one worth +// continuing — it may be running, paused with resume data, or already finished. +// A failed or stopped leftover is cleared instead, so a fresh task can take the +// id rather than colliding with a corpse. +async function adoptableBackgroundTask( + downloader: BackgroundDownloader, + id: string +): Promise { + const tasks = await downloader.getExistingDownloadTasks().catch(() => []); + const task = tasks.find((candidate) => candidate.id === id); + if (!task) return undefined; + if (task.state === 'DOWNLOADING' || task.state === 'PAUSED' || task.state === 'DONE') { + return task; + } + await task.stop().catch(() => {}); + return undefined; +} + +// Clears what an interrupted iOS attempt leaves behind, so the next download of +// this file starts from zero instead of continuing it. Backs `forceDownload`. +async function discardIosPartialDownload(dest: string): Promise { + const downloader = loadBackgroundDownloader(); + if (downloader) { + const id = backgroundTaskIdFor(dest); + const tasks = await downloader.getExistingDownloadTasks().catch(() => []); + // `stop`, not `pause`: the point is to throw the fetched bytes away. + await Promise.all( + tasks.filter((task) => task.id === id).map((task) => task.stop().catch(() => {})) + ); + } + await RNBlobUtil.fs.unlink(`${dest}.partial`).catch(() => {}); + await RNBlobUtil.fs.unlink(`${dest}.chunk`).catch(() => {}); +} + +// iOS backend used when the app installs the optional background downloader (see +// ./backgroundDownloader). The transfer runs on a background NSURLSession, so it +// keeps going while the app is suspended, and the library persists its task +// state, so it survives the app being killed too. +// +// iOS only offers background transfers as DOWNLOAD tasks, which stage into their +// own private file and hand it over whole at the end. There is no partially +// written file to append to, so this backend cannot resume through the HTTP +// Range request the in-process one uses: an interrupted transfer continues from +// NSURLSession's resume data, which is what a PAUSED task holds. +async function downloadUrlViaBackgroundSession( + downloader: BackgroundDownloader, + url: string, + dest: string, + cb: DownloadUrlCallbacks +): Promise { + const part = `${dest}.partial`; + const id = backgroundTaskIdFor(dest); + const expected = await expectedBytesFor(url, cb.expectedBytes); + + // A transfer that finished while the app was not running was moved here by the + // session, with no caller left to promote it. Finish that job rather than + // fetching the whole file again. + if (expected > 0 && (await fileSize(part)) === expected) { + await RNBlobUtil.fs.mv(part, dest); + cb.onBytes?.(expected, expected); + return dest; + } + + if (cb.signal?.aborted) throw abortError(); + + const adopted = await adoptableBackgroundTask(downloader, id); + const task = adopted ?? downloader.createDownloadTask({ id, url, destination: part }); + + await new Promise((resolve, reject) => { + let settled = false; + const settle = (finish: () => void) => { + if (settled) return; + settled = true; + cb.signal?.removeEventListener('abort', onAbort); + finish(); + }; + + // Hands the OS's background-session completion handler back. iOS asks for it + // once per finished transfer and keeps waiting until it gets it. + const release = () => { + try { + // Nothing to release when the handler was never armed for this launch, + // and that is reported either way round, so ignore both. + Promise.resolve(downloader.completeHandler(id)).catch(() => {}); + } catch { + // Ignored, as above. + } + }; + + const onAbort = () => { + // A pause keeps the fetched bytes as resume data, where `stop` would throw + // them away, and it settles only once that data has been written: a + // download started right after an abort would otherwise look for resume + // data that isn't there yet and start over from zero. + const rejectAborted = () => settle(() => reject(abortError())); + task.pause().then(rejectAborted, rejectAborted); + }; + cb.signal?.addEventListener('abort', onAbort); + + task + .begin(({ expectedBytes }) => { + // The length the transfer itself reports, before any of the body has + // landed — hence 0 received. + if (expectedBytes > 0) cb.onBytes?.(0, expectedBytes); + }) + .progress(({ bytesDownloaded, bytesTotal }) => { + // A resumed task counts from the resume point up, so these are already + // absolute. A total of 0 means the length isn't known yet. + cb.onBytes?.(bytesDownloaded, bytesTotal > 0 ? bytesTotal : 0); + }) + .done(() => { + release(); + settle(resolve); + }) + .error(({ error }) => { + release(); + settle(() => + reject( + cb.signal?.aborted + ? abortError() + : RnExecuTorchError('DOWNLOAD_FAILED', `Download of ${url} failed: ${error}`) + ) + ); + }); + + if (!adopted) { + task.start(); + } else if (adopted.state === 'PAUSED') { + task.resume().catch((e) => settle(() => reject(e))); + } else if (adopted.state === 'DONE') { + // It finished with nobody listening, so no `done` event is coming: the + // file is already staged at `part`. + release(); + settle(resolve); + } + }); + + // The session reports success once it has written A file, not once it has + // written the RIGHT one: a truncated body still completes. Checking here is + // what keeps a short file from being renamed into the cache, where the + // existence-only hit check would serve it forever and the truncated .pte would + // only fail much later, at load. + const assembled = await fileSize(part); + if (expected > 0 && assembled !== expected) { + throw incompleteError(url, assembled, expected); + } + + await RNBlobUtil.fs.mv(part, dest); + return dest; +} + +// iOS backend used when that optional dependency is absent: blob-util streams +// via the iOS URL session straight to disk. It does NOT survive the app being +// suspended — iOS tears the connection down about a second later — so an +// interrupted transfer is picked up by the next `download` call instead. // Interrupted downloads resume from a `.partial` file via an HTTP Range request. // `canResume` is set to `false` on an internal retry to avoid recursing forever // if partial-file assembly ever fails. @@ -588,6 +769,13 @@ function substituteRemoteSources(node: T, resolved: ReadonlyMap=4.4.0`): the fetcher uses it automatically when it is present, moving + * transfers onto a background `NSURLSession` that survives suspension and an app + * kill. Nothing else changes, and nothing is needed on Android. * @category Utils / Functions * @typeParam T The shape of the value being resolved. * @param source A URL, a local path, or any nested object/array holding them. diff --git a/yarn.lock b/yarn.lock index 1b707d43ac..62676160b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13287,10 +13287,14 @@ __metadata: test-renderer: "npm:^1.2.0" typescript: "npm:~5.9.2" peerDependencies: + "@kesha-antonov/react-native-background-downloader": ">=4.4.0" react: "*" react-native: "*" react-native-blob-util: ^0.24.0 react-native-worklets: ^0.10.0 + peerDependenciesMeta: + "@kesha-antonov/react-native-background-downloader": + optional: true languageName: unknown linkType: soft