[RNE Rewrite] fix(ts): release native resources when a create<Task> fails - #1386
Merged
Merged
Conversation
12 tasks
barhanc
reviewed
Aug 27, 2026
msluszniak
force-pushed
the
@ms/dispose-on-construction-failure
branch
from
August 27, 2026 07:11
ac50f46 to
98b652e
Compare
msluszniak
added a commit
that referenced
this pull request
Aug 27, 2026
## Description Adds TS API tests: - hooks - task pipelines - core primitives - the resource fetcher - model registry. Also adds necessary skills. To find details about this approach please follow: `__tests__/README.md`. No source changes: the three contract violations the suites originally surfaced (`SpecMatch.dim`, the missing `'worklet'` directive on `getRegisteredBackends`, and `randomNormal`'s millisecond-resolution default seed) have all since been fixed on `rne-rewrite`, so `src/` is untouched by this PR. During the testing I spotted a problem that is addressed in #1386. ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [ ] Bug fix (change which fixes an issue) - [ ] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [x] Other (chores, tests, code style improvements etc.) ### Tested on - [ ] iOS - [ ] Android ### Testing instructions ```sh yarn workspace react-native-executorch test yarn typecheck yarn lint ``` Expected: 28 suites, 3072 tests, 4 snapshots, 0 skipped; typecheck and lint clean. `yarn prepare` still emits only `src/` into `lib/`. ### Screenshots N/A ### Related issues Closes #1352. ### Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings ### Additional notes **Layout** | Path | Contents | | --- | --- | | `core/` | tensor, model, runtime, the coded error type, and the spec matcher (symbol binding, variants, runtime constraints, authoring errors) | | `fetcher/` | caching, forced re-download, byte-weighted progress, failures, cancellation, requests shared between concurrent callers, iOS resume, the Android DownloadManager backend, telemetry | | `tasks/` | one suite per pipeline, plus the shared construction-failure behavior | | `hooks/` | `useModel`, `useResourceDownload`, and the task hooks end to end | | `extensions/` | box and point scaling, box decoding, seeded generators | | `api/` | export snapshot, registry rules, label constants, source-level conventions | | `support/` | the fake runtime, the mocks, the fixtures | **Deliberately not covered:** the numerical behavior of the native operators (that is `cpp/tests/`, and duplicating it here would only test the fake); the parts of a pipeline whose behavior depends on real weights: Whisper's decode loop, the VAD rolling window and the SDXS diffusion step get schema acceptance, rejection and full disposal instead; and the worklet thread hop, since worklets run inline. The `'worklet'` directive convention that makes that hop possible is enforced by parsing `src/` with the TypeScript compiler. That line is drawn per pipeline, not per suite. The privacy filter's logits are weights but its BIOES decode and sliding window are not, so they run end to end; the LLM's generation belongs to the native runner but the chat session's history, KV cache bookkeeping and tool loop are covered against a scripted one; Kokoro's waveform is weights but its argument validation, chunking and streaming are not; PaddleOCR's probability map is weights but the quad decode, CTC collapse and reading order run over a map the test paints. The fake runtime grew `llm.createLLMRunner`, `speech.createPhonemizer`, `math.gather`, `cv.extractDbnetTextQuads`, `cv.rectifyQuad` and `fs.readFile` to make that possible.
A factory allocates as it goes: it loads a model, maybe a tokenizer, a phonemizer, an LLM runner or a nested pipeline, validates the schema, pre-allocates its tensors, and only at the end hands back a `dispose`. Anything that threw in between left the caller with no reference to what was already allocated, and native memory is not garbage collected, so it stayed alive for the rest of the process. `useModel` re-runs its factory whenever the config changes, so an app pointed at a mismatched model leaked a full resource set per attempt. `createResourceScope` gives a factory one teardown path for both outcomes: it tracks each resource as it is created and releases them in reverse order, and the same function becomes the pipeline's `dispose`. Every factory now allocates through a scope and wraps its body in try/catch. Kokoro already did this with a local array and moves to the shared helper, which also closes a smaller hole it had: its two models were loaded before the try block, so one load rejecting stranded the other. Parallel loads elsewhere track inside each promise for the same reason. `createTokenizer` is unchanged. It loads a tokenizer and returns with nothing in between that can throw, so it has no window to leak through. The helper is internal, not exported from `src/index.ts`, so the public surface is unchanged. Test-side, `constructionFailure.test.ts` was recording the leak as current behavior. It now asserts the opposite and covers all fifteen factories rather than five, checking every kind of handle so a failure names the factory and the resource. The per-pipeline suites and `hooks/taskHooks.test.ts` drop the `allowNativeLeaks()` calls they needed for the same reason: nothing in the suite leaks any more, so the global leak check now asserts this on every construction-failure test for free.
msluszniak
force-pushed
the
@ms/dispose-on-construction-failure
branch
from
August 27, 2026 08:01
98b652e to
f5a59f1
Compare
- core/lifetime docs no longer reference extension-layer concepts - example moved under @example, drop the unused @module tag - export createResourceScope so custom pipelines can use the pattern - load models sequentially: Promise.all rejects on the first failure but leaves the other loads running, so their resources land after the scope has already been disposed
Avoids shadowing the built-in TS `Disposable` for consumers importing from the package root.
msluszniak
force-pushed
the
@ms/dispose-on-construction-failure
branch
from
August 27, 2026 09:43
8ce3b13 to
b786c69
Compare
barhanc
approved these changes
Aug 27, 2026
barhanc
left a comment
Member
There was a problem hiding this comment.
Just a style comment. Otherwise looks solid.
The straight-forward sequential await needs no explanation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Fixes the finding recorded in #1355: a
create<Task>that throws part-way through construction abandoned everything it had already allocated.A factory allocates as it goes, and only hands back a
disposeat the very end. Anything that threw in between left the caller with no reference to what was already there, and native memory is not garbage collected, so it stayed alive for the rest of the process. Depending on the task that is a model, a tokenizer, a phonemizer, an LLM runner, or a whole nested pipeline (Whisper owns both a tokenizer and a VAD).useModelre-runs its factory whenever the config changes, so an app pointed at a mismatched model leaked a full resource set per attempt.src/core/lifetime.tsaddscreateResourceScope, which gives a factory one teardown path for both outcomes: it tracks each resource as it is created, releases them in reverse order, and the same function becomes the pipeline'sdispose. Every factory now allocates through a scope and wraps its body intry/catch:createKokoroTextToSpeechalready did this with a local array and moves to the shared helper. That also closes a smaller hole it had: its two models were loaded before thetryblock, so one load rejecting stranded the other. Parallel loads elsewhere now track inside each promise for the same reason.createTokenizeris unchanged. It loads a tokenizer and returns with nothing in between that can throw, so it has no window to leak through.The helper is internal, so the public export surface is unchanged.
Introduces a breaking change?
Type of change
Tested on
Testing instructions
yarn workspace react-native-executorch test yarn typecheck yarn lintExpected: 28 suites, 3117 tests, 4 snapshots, 0 skipped; typecheck and lint clean.
To see the tests bite, stash
src/and re-run: every construction-failure case fails.Screenshots
N/A
Related issues
Follows up the finding recorded in #1355.
Checklist
Additional notes
Stacked on
@ms/api-tests(#1355), because that is where the suite recording this behavior lives. Merge #1355 first and this retargets torne-rewritecleanly.tasks/constructionFailure.test.tswas written to record the leak as current behavior so it would fail loudly the day a factory started cleaning up. That is this PR, so it now asserts the opposite, and covers all fifteen factories rather than five. It checks every kind of handle, so a failure names the factory and the resource rather than just reporting that something leaked.The per-pipeline suites and
hooks/taskHooks.test.tsdrop theallowNativeLeaks()calls they needed for the same reason. Nothing in the suite leaks any more, so the setup file's global leak check now asserts this on every construction-failure test for free.The diff is large mostly because indenting a factory body inside
trytouches every line of it. The behavioral change per file is the scope, thetrackcalls and thecatch.