Serialise every lane's php_embed_init: two concurrent boots SIGSEGV the process - #272
Open
ICTtrying wants to merge 1 commit into
Open
Serialise every lane's php_embed_init: two concurrent boots SIGSEGV the process#272ICTtrying wants to merge 1 commit into
ICTtrying wants to merge 1 commit into
Conversation
`php_embed_init` is process-global however many TSRM contexts the four PHP lanes end up owning: it walks the module registry and allocates thread-resource ids. Two of them running at once corrupt that state rather than sharing it, and the process takes a SIGSEGV in `ts_allocate_fast_id`. Only the persistent lane took a lock against this — `extractionLock`, whose own comment describes the hazard. `bootWorkerRuntime` called `nativeWorkerBoot` bare, `WebviewPHPRuntime.boot` guarded against a persistent *reboot* only, and the ephemeral lane — documented as the "generic background TSRM context for plugin use" — had no wrapper at all, so a plugin reaches it through the raw external and gets no serialisation with it. Every boot and every shutdown now takes one process-wide lock, exposed as `PHPBridge.embedLifecycleLock`. The lock is not held for the life of a runtime: the lanes are built to run concurrently once they exist, and they do. Adds `bootEphemeralRuntime()` / `runEphemeralArtisan()` / `shutdownEphemeralRuntime()` so a plugin has a supported entry point to the lane instead of the raw JNI.
ICTtrying
marked this pull request as ready for review
August 3, 2026 12:23
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.
What goes wrong
php_embed_initis process-global however many TSRM contexts the four PHP lanes end up owning — it walks the module registry and allocates thread-resource ids. Two of them running at once corrupt that state rather than sharing it, and the process takes a SIGSEGV.One lane out of four knows this.
PHPBridge.bootPersistentRuntime()takesLaravelEnvironment.extractionLock, and that lock's own comment says why:The other three do not:
bootPersistentRuntime()extractionLockbootWorkerRuntime()nativeWorkerBootbareWebviewPHPRuntime.boot()rebootInFlightexternal fun nativeEphemeralBootdirectlyThe ephemeral lane is the one the C source calls a "generic background TSRM context for plugin use", with its cold path documented as "WorkManager started the process after the app was killed". It is the lane a plugin is told to use, it is the only lane with no Kotlin wrapper at all, and using it as documented crashes the host app whenever the user opens the app at the same moment.
Measured
Galaxy S23, SM-S918B, Android 16 / One UI,
nativephp/mobile4.0.1. The app's process is killed, a home-screen widget is tapped. The widget's tap handler does the two things a widget tap does: it enqueues background work (WorkManager → the ephemeral lane) and it opens the app.Seven milliseconds apart. The tombstone:
Reproduced twice with the same two lines and the same stack.
The user-visible symptom is not a crash, which is what made it hard to find. The activity is destroyed with the process, so
pendingDeepLink— a field on that activity — goes with it. The widget's deeplink simply never happens: no window, no error, anddumpsys activity activitiesholds no record for the package. WorkManager then restarts the process to retry the job, so the widget even repaints correctly. Everything looks like a dropped navigation. I spent the first half of this investigation insidehandleDeepLinkIntent, which turned out to be entirely correct — it queues the intent properly and four separateam startmeasurements all deliver the route.The fix
One process-wide lock — the existing
extractionLock, exposed asPHPBridge.embedLifecycleLock— taken by every lane's boot and shutdown.The shutdowns are included because a
php_embed_shutdownlanding inside another lane's init is the same collision from the other side, which is what theextractionLockcomment already warns about.The lock is not held for the life of a runtime, and
runEphemeralArtisandeliberately does not take it: the lanes are built to run concurrently once they exist, and they do. Only the init and the shutdown have to be exclusive. A background job that runs for a minute does not block a cold app launch for a minute — it blocks it for the length of onephp_embed_init, which measured 181 ms here.It also adds a supported entry point to the ephemeral lane, so a plugin never has to reach for the raw external again:
Verified on the device
Same tap, same cold app, with the equivalent lock applied on the plugin side (it can take
extractionLockitself today — it isinternaland the plugin's Kotlin compiles into the same module, which is the only reason a fix from that side is possible at all):The ephemeral boot waits and starts one millisecond after the persistent boot releases the lock. No crash, and the route runs — confirmed on the device rather than by a window appearing:
{"probe":"quickadd.opened","at":"2026-08-03 07:56:06","widget":"tasks","instance":17,"focus":"title"}Before: 7 ms apart, SIGSEGV, no route. After: serialised, no crash, route 511 ms after the tap.
Tests
tests/Unit/PhpEmbedLifecycleLockTest.php. Three cases, all three fail onmain:mainevery_native_php_boot_and_shutdown_is_serialisednativePersistentShutdown,nativeWorkerBoot,nativeWorkerShutdown,nativeEphemeralBoot,nativeEphemeralShutdown,nativeWebviewPhpBoot,nativeWebviewPhpShutdownall outside the lockthe_lock_is_declared_once_and_sharedthe_ephemeral_lane_has_a_public_wrapperThey assert on the shipped Kotlin: every call site of a native boot or shutdown must sit inside a
withLockblock, checked by walking the file with a stack of the lines that opened each still-open brace, so it survives reformatting.I want to be plain about what that is. It is a guard against the lock being dropped again, not a proof that the runtime is correct. There is no JVM or instrumentation harness in this repository to hold a real concurrency test, and the failure itself is a native TSRM race that only a device or emulator can show. If you would rather have an instrumentation test, or would rather not have a source-shape assertion in the PHP suite at all, say so and I will change it — the fix stands on the tombstone either way.
The two
ReleaseBuildBundleTestfailures in my run are present onmainas well — aZipArchiveenvironment issue on this machine, unrelated.vendor/bin/pint --dirtyis clean.Not in this PR
libphp_wrapper.so; there is nothing in PHP to reproduce it with, and the.sois not built from this repository. What is here instead is the tombstone, the two log lines that bracket it, and the timing before and after. To reproduce it yourself: from a plugin, callPHPBridge(context).nativeEphemeralBoot(bootstrap)on a background thread while startingMainActivity. Any plugin shipping a widget, a shortcut or a notification does that on every tap.php_embed_inititself safe to call concurrently would be the better fix and it is not in this repository.handleDeepLinkIntent. It queues the intent correctly and consumes it correctly; it was the symptom's address, not its cause. There is a separate observation —pendingDeepLinkhas exactly one consumer, so any failure of the boot pipeline loses the deeplink with no log — but that is a hardening question and not this crash, so it is not mixed in here.WORKER_START_DELAY_MSis untouched. The 2500 ms delay in front ofPHPQueueWorkermakes the worker lane usually miss the persistent boot, which is why this has stayed rare for core's own lanes. It is a timing accident rather than a guarantee, and the lock is what makes it a guarantee.