Skip to content

fix: don't abort on worker termination during module load - #1993

Open
edusperoni wants to merge 1 commit into
mainfrom
fix/worker-terminate-crashes
Open

fix: don't abort on worker termination during module load#1993
edusperoni wants to merge 1 commit into
mainfrom
fix/worker-terminate-crashes

Conversation

@edusperoni

@edusperoni edusperoni commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Description

Targets main (rebased after the V8 14.9 upgrade, #1987, merged); the bugs themselves are long-standing (verified — every fixed site was byte-identical on main before the upgrade).

Intermittent process aborts/crashes (~20% of cold full-suite runs on an emulator, always in the worker-heavy early phase) were root-caused to worker.terminate() landing while the worker is still loading its script. Once TerminateExecution() is armed, every V8 entry that runs JS returns an empty MaybeLocal — and three call sites on exactly this path unwrapped without checking. The proven signature (symbolized from a device tombstone) is Fatal error in v8::ToLocalChecked / Empty MaybeLocal on a worker thread, aborting from ModuleInternal::LoadModule.

Fixes

  • ModuleInternal.cppscript->Run() was unwrapped with ToLocalChecked() one line before the tc.HasCaught() guard meant to handle it; now checked first (matching the sibling compile sites in the same function). Same treatment for the unconditional __extends global lookup a few lines down.
  • NativeScriptException.cpp — the TryCatch constructor now bails out early on tc.HasTerminated() || tc.Message().IsEmpty() with a fallback message, and no longer builds a Persistent from an empty exception handle (which left ReThrowToV8 dereferencing an empty Local).
  • CallbackHandlers.cppCallWorkerScopeOnErrorHandle unwrapped the global onerror lookup with ToLocalChecked(); it runs precisely when a worker script fails to load. Now ToLocal() + early return.
  • Runtime.cppstruct sigaction was never zero-initialized, leaving sa_mask/sa_flags as stack garbage for both signal handlers (a source of run-to-run nondeterminism in crash behavior).

No behavior change for healthy paths: termination reporting was already correctly suppressed (isTerminating_ is set before TerminateExecution() and guarded in BackgroundLooper/CallWorkerScopeOnErrorHandle); no existing spec asserts an error for terminate-during-load.

Known remaining flake (out of scope): while re-verifying the reproduction on the unfixed build, one crash of a different signature was captured — a bionic Pointer tag ... was truncated SIGABRT on a worker thread right after isolate creation (heap corruption, not an empty-handle abort). It was not observed in 27 cold runs of the fixed build, but this PR does not claim to fix it; it's an open follow-up.

Related Pull Requests

Does your pull request have unit tests?

Yes — tests/testWorkerTerminateDuringLoad.js: the worker spins at module scope so terminate() deterministically lands inside the module-function call (the wide window; the script->Run() window is microseconds and can't be hit reliably), asserting no onerror fires and the process survives. It cannot fail spuriously. It lives in the app's tests/ because shared/Workers/ is a submodule shared with iOS. Suite: 606 specs, 0 failures (605 baseline + 1).

Statistical verification: unfixed build reproduces at ~20% per cold full-suite run (re-armed before fixing: 1/5); fixed build ran 27 cold full-suite runs with zero crashes and a clean dropbox/tombstone sweep.

Summary by CodeRabbit

  • Bug Fixes

    • Improved worker termination during module loading to prevent spurious error reports.
    • Improved handling of interrupted script execution and missing error details.
    • Added safer handling for worker error callbacks and runtime signal setup.
  • Tests

    • Added coverage for repeatedly terminating workers while modules are loading.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The runtime now checks V8 operations, handles terminated isolates and missing messages, and initializes signal-action state safely. The application adds a test that terminates workers during module loading and verifies that no error is reported.

Changes

Worker termination handling

Layer / File(s) Summary
Checked runtime error paths
test-app/runtime/src/main/cpp/CallbackHandlers.cpp, test-app/runtime/src/main/cpp/ModuleInternal.cpp, test-app/runtime/src/main/cpp/NativeScriptException.cpp, test-app/runtime/src/main/cpp/Runtime.cpp
Worker error-handler lookup and module-loading operations now check V8 failures. NativeScriptException handles terminated isolates and missing messages. Runtime::Init value-initializes sigaction.
Termination-during-load test
test-app/app/src/main/assets/app/tests/workerTerminateDuringLoadWorker.js, test-app/app/src/main/assets/app/tests/testWorkerTerminateDuringLoad.js, test-app/app/src/main/assets/app/mainpage.js
The test delays worker module initialization, terminates workers in three iterations, collects onerror messages, and asserts that the list is empty. The test is added to the startup sequence.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: nathanwalker

Poem

A rabbit watched the worker load,
Then safely stopped its busy road.
V8 checked each path with care,
No stray errors filled the air.
Three tests ran clean—hop, hop, hooray!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the fix for process aborts caused by worker termination during module loading.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Base automatically changed from feat/v8-14 to main August 3, 2026 15:27
worker.terminate() calls Isolate::TerminateExecution() on the worker
isolate from the parent thread, after which every V8 entry that runs JS
hands back an empty handle. Three call sites in the worker's script-load
path unwrapped those handles without checking:

- ModuleInternal::LoadModule called script->Run(...).ToLocalChecked() one
  line before the tc.HasCaught() guard meant to handle exactly that, so a
  terminate landing mid-load killed the process with "Fatal error in
  v8::ToLocalChecked / Empty MaybeLocal".
- The same function unwrapped the __extends lookup unconditionally.
- CallWorkerScopeOnErrorHandle, which runs precisely when a worker script
  fails to load, unwrapped the global "onerror" lookup.

NativeScriptException's TryCatch constructor then dereferenced
tc.Message() unconditionally. A terminated TryCatch exposes no message
object, so building the error to report turned the abort into a SIGSEGV,
which the runtime's own signal handler converted into an opaque "JNI
Exception occurred (SIGSEGV)" and no tombstone.

All four now test before unwrapping, matching the sibling compile sites
in LoadModule. Reporting already suppresses termination -- BackgroundLooper
guards on isTerminating_ and CallWorkerScopeOnErrorHandle returns early
for a terminating wrapper -- so a terminate during load unwinds as a
normal shutdown.

Also zero-initialises the sigaction struct used to install the SIGABRT and
SIGSEGV handlers, whose sa_mask and sa_flags were stack garbage.

The device suite hit this on roughly 20% of cold runs (pm clear + launch)
on an arm64 emulator, always in a worker spawned by the Workers suite
within the first seconds of the run; the faulting frame symbolised to
ModuleInternal::LoadModule. 27 cold runs on the fixed build are clean.
@edusperoni
edusperoni force-pushed the fix/worker-terminate-crashes branch from 3cf8944 to ae2d748 Compare August 3, 2026 16:29

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test-app/app/src/main/assets/app/tests/testWorkerTerminateDuringLoad.js`:
- Around line 18-28: Synchronize the termination timer in the test’s worker
lifecycle: update workerTerminateDuringLoadWorker.js to post a “module entered”
message immediately before its busy loop, then change the parent’s Worker
handling so the TERMINATE_AFTER timeout starts only from the corresponding
message event. Keep the existing worker.onerror collection and post-termination
iteration flow unchanged.

In `@test-app/runtime/src/main/cpp/CallbackHandlers.cpp`:
- Around line 1499-1503: Update the onerror lookup in the worker exception
handling path around CallbackHandlers so a failed Get does not return before
reporting non-terminating worker exceptions; preserve the terminating-worker
early exit, and fall through to extract and propagate the original exception
captured in innerTc. Add a regression test covering a global onerror getter that
throws and verifies the worker exception is reported.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: efe533d5-6e95-4c7f-ac10-7e64e7fae01d

📥 Commits

Reviewing files that changed from the base of the PR and between c08a91b and ae2d748.

📒 Files selected for processing (7)
  • test-app/app/src/main/assets/app/mainpage.js
  • test-app/app/src/main/assets/app/tests/testWorkerTerminateDuringLoad.js
  • test-app/app/src/main/assets/app/tests/workerTerminateDuringLoadWorker.js
  • test-app/runtime/src/main/cpp/CallbackHandlers.cpp
  • test-app/runtime/src/main/cpp/ModuleInternal.cpp
  • test-app/runtime/src/main/cpp/NativeScriptException.cpp
  • test-app/runtime/src/main/cpp/Runtime.cpp

Comment on lines +18 to +28
var worker = new Worker("./workerTerminateDuringLoadWorker.js");
worker.onerror = function (e) {
errors.push(e.message);
};

setTimeout(function () {
worker.terminate();
setTimeout(function () {
iteration(remaining - 1);
}, SETTLE_AFTER);
}, TERMINATE_AFTER);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Find relevant test and worker files:"
git ls-files | rg 'testWorkerTerminateDuringLoad|workerTerminateDuringLoadWorker|assets/app/tests'

echo
echo "Show test file:"
cat -n test-app/app/src/main/assets/app/tests/testWorkerTerminateDuringLoad.js

echo
echo "Locate workerTerminateDuringLoadWorker.js:"
fd -a 'workerTerminateDuringLoadWorker\.js$' . | sed 's#^\./##'

echo
echo "Show worker file(s):"
while IFS= read -r f; do
  echo "--- $f"
  cat -n "$f"
done < <(fd 'workerTerminateDuringLoadWorker\.js$' .)

echo
echo "Search related constants/usages:"
rg -n "TERMINATE_AFTER|SETTLE_AFTER|testWorkerTerminateDuringLoad|workerTerminateDuringLoadWorker" test-app/app/src/main/assets/app/tests test-app -S || true

Repository: NativeScript/android

Length of output: 6129


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Show workerTerminateDuringLoadWorker.js:"
cat -n test-app/app/src/main/assets/app/tests/workerTerminateDuringLoadWorker.js

echo
echo "Search related references:"
rg -n "TERMINATE_AFTER|SETTLE_AFTER|message|postMessage|terminate|" test-app/app/src/main/assets/app/tests/testWorkerTerminateDuringLoad.js test-app/app/src/main/assets/app/tests/workerTerminateDuringLoadWorker.js -S || true

Repository: NativeScript/android

Length of output: 4390


Synchronize termination with module entry.

workerTerminateDuringLoadWorker.js starts the spinning loop at top level, but the parent starts the TERMINATE_AFTER timer from new Worker(). If worker startup takes more than 150 ms, terminate() can run before the worker’s module body begins, so the test may pass without exercising termination during module evaluation.

Have the worker send a “module entered” message immediately before the busy loop, and start termination only after the parent receives that message.

🧰 Tools
🪛 ast-grep (0.45.0)

[error] 22-27: React's useState should not be directly called
Context: setTimeout(function () {
worker.terminate();
setTimeout(function () {
iteration(remaining - 1);
}, SETTLE_AFTER);
}, TERMINATE_AFTER)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(usestate-direct-usage)


[error] 24-26: React's useState should not be directly called
Context: setTimeout(function () {
iteration(remaining - 1);
}, SETTLE_AFTER)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(usestate-direct-usage)


[warning] 22-27: Avoid using the initial state variable in setState
Context: setTimeout(function () {
worker.terminate();
setTimeout(function () {
iteration(remaining - 1);
}, SETTLE_AFTER);
}, TERMINATE_AFTER)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] 24-26: Avoid using the initial state variable in setState
Context: setTimeout(function () {
iteration(remaining - 1);
}, SETTLE_AFTER)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-app/app/src/main/assets/app/tests/testWorkerTerminateDuringLoad.js`
around lines 18 - 28, Synchronize the termination timer in the test’s worker
lifecycle: update workerTerminateDuringLoadWorker.js to post a “module entered”
message immediately before its busy loop, then change the parent’s Worker
handling so the TERMINATE_AFTER timeout starts only from the corresponding
message event. Keep the existing worker.onerror collection and post-termination
iteration flow unchanged.

Comment on lines +1499 to +1503
Local<Value> callback;
if (!globalObject->Get(context, ArgConverter::ConvertToV8String(isolate, "onerror"))
.ToLocal(&callback)) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)CallbackHandlers\.cpp$|(^|/)CallbackHandlers\.h$|test' || true

echo "== locate relevant snippet =="
rg -n "onerror|innerTc|ToLocal\\(\\)|ToLocalChecked|CallbackHandlers\\.cpp" -S . || true

echo "== inspect CallbackHandlers.cpp around snippet =="
file="$PWD/test-app/runtime/src/main/cpp/CallbackHandlers.cpp"
if [ -f "$file" ]; then
  wc -l "$file"
  sed -n '1450,1545p' "$file" | cat -n -v
fi

Repository: NativeScript/android

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== CallbackHandlers.cpp outline and relevant sections =="
file="test-app/runtime/src/main/cpp/CallbackHandlers.cpp"
wc -l "$file"
sed -n '1400,1580p' "$file" | cat -n -v

echo "== related to worker error termination flags/constants =="
rg -n "terminate|isTerminating|OnError|ThrowError|HasCaught|innerTc|globalObject->Get|onerror" test-app/runtime/src/main/cpp/CallbackHandlers.cpp test-app/runtime/src/main/cpp -S || true

echo "== relevant tests mentioning onerror/worker error/error events =="
rg -n "onerror|workerTerminateDuringLoad|WorkerEvent|error.*worker|worker.*error|terminate" test-app/app/src/main/assets/app/test-app tests -S || true

Repository: NativeScript/android

Length of output: 25669


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== WorkerWrapper PassUncaughtExceptionFromWorkerToParent implementation =="
file="test-app/runtime/src/main/cpp/WorkerWrapper.cpp"
sed -n '250,320p' "$file" | cat -n -v

echo "== GiveWorkerOnErrorAChance implementation and usage =="
sed -n '620,760p' test-app/runtime/src/main/cpp/NativeScriptException.cpp | cat -n -v

echo "== Search for non-termination reporting path after CallWorkerScopeOnErrorHandle =="
rg -n "PassUncaughtExceptionFromWorkerToParent|CallWorkerScopeOnErrorHandle\\(.*tc\\)|CallWorkerScopeOnErrorHandle\\(.*message" test-app/runtime/src/main/cpp/CallbackHandlers.cpp test-app/runtime/src/main/cpp/WorkerWrapper.cpp test-app/runtime/src/main/cpp/NativeScriptException.cpp test-app/runtime/src/main/cpp/ModuleInternal.cpp -S || true

echo "== deterministic JS semantics for try/catch + throwing getter and early return reportability =="
node - <<'JS'
let caught;
try {
  const o = {};
  Object.defineProperty(o, "onerror", {
    get() {
      throw new Error("throwing onerror getter");
    }
  });
  let callback;
  try {
    callback = o.onerror;
  } catch (e) {
    caught = e.message;
    return false;
  }
  if (false === callback) {
    console.log("no lookup failure was suppressed");
  }
} catch (e) {
  console.log("outer catch", e && e.message);
}
console.log("caught?", caught);
JS

Repository: NativeScript/android

Length of output: 12625


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== WorketThreadCloseCallback context around original tc =="
sed -n '1368,1435p' test-app/runtime/src/main/cpp/CallbackHandlers.cpp | cat -n -v

echo "== WorkerObjectPostMessageCallback context around original tc =="
sed -n '1255,1305p' test-app/runtime/src/main/cpp/CallbackHandlers.cpp | cat -n -v

echo "== deterministic JS semantics for throw in getter caught by TryCatch and lookup failure suppressing later reporting =="
node - <<'JS'
let onerrorCalled = false;
let reported = false;
const o = {};
Object.defineProperty(o, "onerror", {
  get() {
    throw new Error("throwing onerror getter");
  }
});
let tc;
try {
  tc = { exception: null };
  let callback;
  try {
    callback = o.onerror;
  } catch (e) {
    tc.exception = e;
  }
  if (!tc.exception) return;
  reported = false;
} catch (e) {
  return;
}
try {
  {
    callback = o.onerror;
  }
} catch (e) {
  reported = false;
}
console.log("onerrorCalled", onerrorCalled, "reported", reported, "tc.exception.message", tc.exception.message);
JS

Repository: NativeScript/android

Length of output: 6099


Do not suppress worker exceptions when onerror lookup fails.

A throwing global onerror getter captures its exception in innerTc, and Line 1503 returns before the existing reporting path. Non-terminating worker failures should fall through to extract and pass the original worker exception unless the worker is terminating. Add a regression test with a throwing global onerror getter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-app/runtime/src/main/cpp/CallbackHandlers.cpp` around lines 1499 - 1503,
Update the onerror lookup in the worker exception handling path around
CallbackHandlers so a failed Get does not return before reporting
non-terminating worker exceptions; preserve the terminating-worker early exit,
and fall through to extract and propagate the original exception captured in
innerTc. Add a regression test covering a global onerror getter that throws and
verifies the worker exception is reported.

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.

1 participant