Skip to content

Serialise every lane's php_embed_init: two concurrent boots SIGSEGV the process - #272

Open
ICTtrying wants to merge 1 commit into
NativePHP:mainfrom
ICTtrying:fix/php-embed-init-race
Open

Serialise every lane's php_embed_init: two concurrent boots SIGSEGV the process#272
ICTtrying wants to merge 1 commit into
NativePHP:mainfrom
ICTtrying:fix/php-embed-init-race

Conversation

@ICTtrying

Copy link
Copy Markdown
Contributor

What goes wrong

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.

One lane out of four knows this. PHPBridge.bootPersistentRuntime() takes LaravelEnvironment.extractionLock, and that lock's own comment says why:

PHPBridge.bootPersistentRuntime takes this same lock so the persistent php_embed_init can never overlap the classic embed init/shutdown cycles […] the two paths use different native mutexes

The other three do not:

Lane Boot Serialised against the others?
persistent bootPersistentRuntime() yes, extractionLock
worker bootWorkerRuntime() nonativeWorkerBoot bare
webview WebviewPHPRuntime.boot() no — guards a persistent reboot only, via its own rebootInFlight
ephemeral no wrapper exists no — a plugin calls external fun nativeEphemeralBoot directly

The 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/mobile 4.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.

09:31:29.193 START … MainActivity … LAUNCH_SINGLE_TOP … BAL_ALLOW_GRACE_PERIOD
09:31:29.223 D/DeepLink  🔔 Notification URL: /tasks/create?widget=tasks&widget_instance=17
09:31:29.331 I/ActivityTaskManager  Displayed …/MainActivity for user 0: +145ms
09:31:29.422 I/PHP-Native  ephemeral_boot: initializing with bootstrap=…/persistent.php
09:31:29.429 I/PHP-Native  persistent_boot: initializing with bootstrap=…/persistent.php
09:31:29.430 F/libc       Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
                          in tid 11033 (nativephp-widge), pid 10969

Seven milliseconds apart. The tombstone:

#00 pc 000000000154742c  libphp_wrapper.so (_emalloc+44)
#01 pc 0000000001652808  libphp_wrapper.so (cwd_globals_ctor+32)
#02 pc 00000000014dfb04  libphp_wrapper.so (tsrm_update_active_threads+188)
#03 pc 00000000014dfc5c  libphp_wrapper.so (ts_allocate_fast_id+236)
#04 pc 0000000001657950  libphp_wrapper.so (zend_startup+64)
#05 pc 00000000014e38e4  libphp_wrapper.so (php_module_startup+340)
#06 pc 000000000165afac  libphp_wrapper.so (php_embed_init+96)
#07 pc 00000000011eee24  libphp_wrapper.so (ephemeral_embed_init+460)
#08 pc 00000000011eea88  libphp_wrapper.so (native_ephemeral_boot+176)

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, and dumpsys activity activities holds 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 inside handleDeepLinkIntent, which turned out to be entirely correct — it queues the intent properly and four separate am start measurements all deliver the route.

The fix

One process-wide lock — the existing extractionLock, exposed as PHPBridge.embedLifecycleLock — taken by every lane's boot and shutdown.

The shutdowns are included because a php_embed_shutdown landing inside another lane's init is the same collision from the other side, which is what the extractionLock comment already warns about.

The lock is not held for the life of a runtime, and runEphemeralArtisan deliberately 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 one php_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:

fun bootEphemeralRuntime(bootstrapPath: String = persistentBootstrapScript): Boolean
fun runEphemeralArtisan(command: String): String
fun shutdownEphemeralRuntime()

Verified on the device

Same tap, same cold app, with the equivalent lock applied on the plugin side (it can take extractionLock itself today — it is internal and the plugin's Kotlin compiles into the same module, which is the only reason a fix from that side is possible at all):

09:56:06.104 D/DeepLink   🔔 Notification URL: /tasks/create?widget=tasks&widget_instance=17&focus=title
09:56:06.302 I/PHP-Native persistent_boot: initializing
09:56:06.483 I/PHP-Native persistent_boot: PHP interpreter is now persistent and Laravel is booted
09:56:06.483 I/PHPBridge  Persistent runtime booted in 181ms
09:56:06.484 I/PHP-Native ephemeral_boot: initializing        ← 1 ms after the lock was released
09:56:06.615 D/DeepLink   🚀 Loading final URL after WebView setup: http://127.0.0.1/tasks/create?…

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 on main:

Test on main
every_native_php_boot_and_shutdown_is_serialised nativePersistentShutdown, nativeWorkerBoot, nativeWorkerShutdown, nativeEphemeralBoot, nativeEphemeralShutdown, nativeWebviewPhpBoot, nativeWebviewPhpShutdown all outside the lock
the_lock_is_declared_once_and_shared
the_ephemeral_lane_has_a_public_wrapper

They assert on the shipped Kotlin: every call site of a native boot or shutdown must sit inside a withLock block, 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.

Tests:  810 passed   (main: 807 passed)

The two ReleaseBuildBundleTest failures in my run are present on main as well — a ZipArchive environment issue on this machine, unrelated.

vendor/bin/pint --dirty is clean.

Not in this PR

  • No device-free reproduction. The failure is a native race inside libphp_wrapper.so; there is nothing in PHP to reproduce it with, and the .so is 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, call PHPBridge(context).nativeEphemeralBoot(bootstrap) on a background thread while starting MainActivity. Any plugin shipping a widget, a shortcut or a notification does that on every tap.
  • No change to the C layer. Serialising the callers is a Kotlin-side fix for a native invariant. Making php_embed_init itself safe to call concurrently would be the better fix and it is not in this repository.
  • No change to handleDeepLinkIntent. It queues the intent correctly and consumes it correctly; it was the symptom's address, not its cause. There is a separate observation — pendingDeepLink has 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_MS is untouched. The 2500 ms delay in front of PHPQueueWorker makes 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.

`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
ICTtrying marked this pull request as ready for review August 3, 2026 12:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants