Skip to content

Fix HermesNode property access on released JSI objects#825

Merged
brocollie08 merged 11 commits intomainfrom
hermes-crash
Apr 7, 2026
Merged

Fix HermesNode property access on released JSI objects#825
brocollie08 merged 11 commits intomainfrom
hermes-crash

Conversation

@kharrop
Copy link
Copy Markdown
Member

@kharrop kharrop commented Mar 17, 2026

This PR prevents a rare but high-impact Android crash in Hermes where the app can hit a native NullPointerException while reading JS backed objects. It does not change Player flow/state behavior; it only improves safety and diagnostics in a teardown race window.

Context

The crash occurs when native code tries to read a property from a Hermes JSI object that has already been released (often due to timing/lifecycle during teardown or navigation).

This shows up as a fatal NullPointerException inside Hermes/JSI property access (getProperty), which crashes the app process.

Testing

bazel test //jvm/hermes:test //jvm/core:test

Change Type (required)

Indicate the type of change your pull request is:

  • patch
  • minor
  • major
  • N/A

Does your PR have any documentation updates?

  • Updated docs
  • No Update needed
  • Unable to update docs

Release Notes

Added a defensive guard in HermesNode so property reads on a released runtime/object fail safely by throwing a PlayerRuntimeException with context instead of a fatal native NPE.

Added regression tests:

  • A deterministic unit test for “released JSI object property read”
  • A small concurrency test that reads player.state while player.release() is happening
📦 Published PR as canary version: 0.15.2--canary.825.33564

Try this version out locally by upgrading relevant packages to 0.15.2--canary.825.33564

@kharrop
Copy link
Copy Markdown
Member Author

kharrop commented Mar 17, 2026

/canary

2 similar comments
@kharrop
Copy link
Copy Markdown
Member Author

kharrop commented Mar 17, 2026

/canary

@kharrop
Copy link
Copy Markdown
Member Author

kharrop commented Mar 17, 2026

/canary

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 17, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 0.00%. Comparing base (2ce16b6) to head (236f900).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@     Coverage Diff     @@
##   main   #825   +/-   ##
===========================
===========================

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

intuit-svc added a commit to player-ui/player-ui.github.io that referenced this pull request Mar 17, 2026
@intuit-svc
Copy link
Copy Markdown
Contributor

Build Preview

Your PR was deployed by CircleCI #33564 on Tue, 17 Mar 2026 16:36:06 GMT with this version:

0.15.2--canary.825.33564

📖 Docs (View site)

}
}
internal fun <T> Value.blockingLock(block: Value.() -> T): T = when {
context.runtime.lock.isHeldByCurrentThread -> block()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

these shouldn't be impacted since they'll be on the same thread

jsiObject.asValue(runtime)
}.let { format.decodeFromRuntimeValue(deserializer, it) }
format.decodeFromRuntimeValue(deserializer, jsiObject.asValue(runtime))
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i wrapped the whole thing here and above. mapUndefineToNull as well as decoding can both run into similar race conditions since they need to reach the c++ layer

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Don't those operations re-enter evaluateInJSThreadBlocking though? Would we be able to remove that? Or is keeping it in the same block just activating the shortcut?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we need to make these updates for the other node impls?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

mapUndefinedToNull doesn't, decoding does, i moved decoding back out to minimize changes

@brocollie08 brocollie08 marked this pull request as ready for review March 31, 2026 17:04
@brocollie08 brocollie08 requested review from a team as code owners March 31, 2026 17:04
Copy link
Copy Markdown
Member

@sugarmanz sugarmanz left a comment

Choose a reason for hiding this comment

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

Looks fine — main concern is consistency across runtime impls. Even if functionally equivalent, the more it diverges, the more difficult it is to maintain.

jsiObject.asValue(runtime)
}.let { format.decodeFromRuntimeValue(deserializer, it) }
format.decodeFromRuntimeValue(deserializer, jsiObject.asValue(runtime))
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we need to make these updates for the other node impls?

// TODO: Put Dedicated Runtime Thread Context in the dispatcher context if we can?
return if (currentRuntimeThreadContext != null) {
block(currentRuntimeThreadContext)
ensureNotReleased { block(currentRuntimeThreadContext) }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do ensureNotReleased here? You're ignoring it for this case in graal:
https://github.com/player-ui/player/pull/825/changes#r3017129498

Just want to be consistent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

yea you right, this shouldn't be necessary

}

internal suspend fun <T> Runtime<*>.evaluateInJSThread(block: suspend RuntimeThreadContext.() -> T): T {
ensureNotReleased()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this whole function be wrapped in ensureNotReleased to avoid needing to call it multiple times?

runBlocking {
evaluateInJSThread(runtime, block)
}
runBlocking { runtime.ensureNotReleased { evaluateInJSThread(runtime, block) } }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we need the check here if we're sure evaluateInJSThread will wrap block with it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

it does, though evaluateInJSThread has the guard as well

@brocollie08 brocollie08 requested a review from sugarmanz April 6, 2026 23:48
@brocollie08 brocollie08 merged commit 3257038 into main Apr 7, 2026
16 of 17 checks passed
@brocollie08 brocollie08 deleted the hermes-crash branch April 7, 2026 19:00
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.

4 participants