feat: async tasks — background PHP work with UI completion callbacks - #228
feat: async tasks — background PHP work with UI completion callbacks#228simonhamp wants to merge 13 commits into
Conversation
Adds AsyncTask::dispatch(static fn () => ...) (and a $this->async() shorthand
on NativeComponent) to run work on a background PHP thread and handle the
result back on the UI thread, where callbacks can mutate component state.
Builds on two things that already existed: the background-interpreter lanes
(worker/ephemeral/webview) and the native event channel that already delivers
camera results into a parked runloop. This wires them together and adds a
dedicated, concurrent async lane.
- Work closure must be static; a $this-bound closure is rejected by reflection
at dispatch time rather than failing silently in a background log.
- finished()/failed() are rebound to the live component. They are screen-scoped
and dropped if the user navigated away; shared('alias') opts out by delivering
a named event any active screen can handle via #[On].
- Runs immediately and concurrently on its own pool of PHP contexts — not the
queue worker, no SQLite, no queue, one attempt, no retries.
- Payload crosses via a temp file, so native stays a courier with two bridge
functions: AsyncTask.Dispatch and AsyncTask.Complete.
- Jump runs tasks in a dev-machine subprocess and drains a completion spool.
- AsyncTask::fake() runs work inline for tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…files CI's Code Style job flagged 8 files. Each contained a fully-qualified class reference where the class was imported or in the same namespace, which is exactly what the fully_qualified_strict_types fixer rewrites. - Docblock FQCNs replaced with imported short names. - Inline FQCNs in tests replaced with imports (AsyncTask::clearFake(), SerializableClosure). - NativeComponent: dropped the `use Closure;` added for async(). That import retroactively made the file's ten pre-existing `\Closure` usages shortenable, so the signature now uses `\Closure` to match the file's own convention — a two-line diff instead of churning unrelated lines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Had a proper read through this — the shape of it is right. Framing it as glue between the existing TSRM lanes and the Camera-style callback channel is accurate, and the PHP layer is in good shape. The design doc is genuinely useful too. The native side is where the work still is, roughly in line with the
Smaller ones worth grabbing whenever this gets picked back up: screen scoping via Given the amount of native surface here, and that the C/Kotlin/Swift lanes still haven't been compiled or run on a device, we're going to hold this until v4 is out rather than land it late in the cycle. Nothing wrong with the direction — it just deserves a release where the native lanes can get real device time and the extension question (a userland Leaving it open. Thanks @simonhamp — good writeup. |
Review feedback on #228. The PHP layer was sound; the gaps were in the native lanes and in the failure paths that silently delivered nothing — the worst outcome this API can have, since the UI is sitting on a spinner waiting for a callback that never arrives. Hot reload / shutdown safety: - Android `AsyncTaskExecutor.stop()` now joins its pool (and the new watchdog thread) before returning, like `PHPQueueWorker.stop()`, and reports whether it drained. Callers stop it immediately before `shutdownPersistentRuntime()`, which frees Zend state a live async context still references — returning early tore PHP down underneath a running task. - iOS `stop()` no longer bails when the pool is mid-boot. It signals the boot loop to stop, waits it out, then tears down every slot it finds. Bailing left the C slots allocated with `start()` refusing to run twice, so every later dispatch was dropped for the life of the app. Concurrency: - Dropped the `setenv("APP_RUNNING_IN_CONSOLE"/"PHP_SELF")` calls from the async lane in both `php_bridge.c` and `PHP.c`. Four pool threads flipping a process-wide, non-thread-safe env var clobber each other and the UI lane. The eval'd code sets the per-thread `$_SERVER` entries instead — which is what Laravel's `Env` reads — including before the bootstrap runs. Every dispatch now reaches exactly one outcome: - `AsyncTask.Dispatch`'s `success` flag is checked. No executor, no slot, no bridge, or a Jump subprocess that wouldn't launch fails the dispatch at the call site and fires `->failed()`. - The result is JSON round-tripped in the background context (`AsyncTaskRunner::normalizeResult()`), where a non-encodable value can still be reported as an ordinary task failure. `encodeCompletion()` backstops the envelope itself. - Every dispatch carries a deadline (default 60s, `->timeout($seconds)`, 0 to disable) plus the pre-built completion to post if it passes, so the native watchdogs stay dumb couriers. Jump sweeps overdue subprocesses on each runloop tick instead. The work isn't killed — the timeout unblocks the UI, and a late completion for an already-failed task is discarded. Smaller ones from the same review: - Screen scoping holds the origin as a `WeakReference`, closing the `spl_object_id` reuse hole where a popped screen's id could be handed to the next component and deliver its callback to an unrelated screen. - `$nativeActiveComponent` is weak and restored when a runloop exits, so it stops retaining a dead component. - `AsyncTask::fake()` runs the same JSON normalization as the device, so a test can't pass on a value a device would never deliver. - `__destruct()` can no longer throw; a start failure reports through the task's own `failed()` channel and the error log. - Async callbacks register with `durable: false`, so the "never touches SQLite" claim is now true — `NativeCallbacks::register()` was writing a cache copy that had nothing to survive to anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFbiJwd86MZCTLH5kJHZxo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFbiJwd86MZCTLH5kJHZxo
|
Thanks @shanerbaner82 — that's a good read of it, and all of it landed. Pushed to Happy to hold for v4; the native lanes still need real device time either way. But the three big ones were all latent crashes/hangs rather than polish, so they're fixed now rather than waiting to be re-derived later. Hot reload / shutdown
Concurrency vs. Gone from the async lane in both Failures are invisible Reframed as: every dispatch reaches exactly one outcome. Three paths could deliver nothing, and all three are closed:
The smaller ones
Design doc updated throughout; new Pest coverage for the weak scoping, the JSON round trip, the timeout contract and the encode fallback. The Generated by Claude Code |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFbiJwd86MZCTLH5kJHZxo
Three follow-ups from review. drainJumpCompletion() sorted spool files by name, but the names are random UUIDs — so "oldest first" was actually arbitrary order. Sort by filemtime with the name as a tiebreak, since mtime is one-second granular on some filesystems and two tasks can finish inside the same second. Spool files are written 0600 in a 0700 directory (and a directory left world-readable by an earlier run is tightened). The payload goes straight to unserialize() in the runner: a closure is signed with the app key, but a task subclass's constructor arguments are not. forTask() rejects a subclass with no handle() at dispatch time, matching the static-closure guard beside it — otherwise the mistake surfaces as a generic ->failed() from a background thread reading "Call to undefined method". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMnYeU9ZEGk2XR4FpciYRD
On device, AsyncTask.Complete posts a native event that unblocks nativephp_element_wait_event(). Under Jump there is no such wake path — the completion spool is just a directory the polyfill checks on entry. nextEventTimeout() returns -1 (block until the user does something) for any screen without a #[Poll], which is most of them. So the polyfill checked an empty spool, blocked indefinitely, and the completion written a moment later sat there undelivered. From the app it looks like a task that never finishes; dispatch several and they all land at once the moment an unrelated tap happens to wake the loop. While Jump runners are in flight (or a completion is spooled but not yet drained), clamp the wait to 150ms so the loop comes back and looks. Costs nothing when no async task is pending — hasPendingJumpRunners() is false and the timeout is passed through untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMnYeU9ZEGk2XR4FpciYRD
|
@simonhamp — took the updated branch for a run on Jump, the iOS simulator and an Android emulator. Most of it holds up well, but § Hot reload found a reproducible SIGABRT on Android that I think blocks merge. Details below, plus three small fixes I've pushed to the branch. First, credit where it's due: the rework closes almost everything from the last round. The Android crashReproduced by dispatching a 20s task and then triggering hot reload. From logcat: then the app dies. The caller ignores the answer it just asked for: if (asyncExecutor?.stop() == false) {
Log.e("HotReload", "Async pool did not drain before runtime reboot")
}
phpBridge.shutdownPersistentRuntime() // ← proceeds regardless
The join and the loud log are a genuine improvement — it's gone from a silent race to a detected one. It just isn't prevented yet. The structural problem is that the stop deadline (5s) and the task timeout (60s default) aren't coupled, so "task longer than 5s" silently means "hot reload is unsafe". Options as I see them:
I'd lean 2 falling back to 1, logging plainly that hot reload is waiting on a task rather than dying. Happy to implement whichever you prefer — I have the emulator set up and can verify it — but it changes hot-reload behaviour, so it's your call. Probably the same root cause: dispatching 6 concurrent tasks after a timed-out stop gave 4 completions and 2 that never returned, with only 3 distinct interpreter contexts where the pool is 4, showing What passed
Three fixes pushed (0e3f608, 57b1331)1. Jump tasks never completed. 2. 3. Spool permissions + a 722 tests, Pint clean, each with a regression test. Also worth noting for anyone testing this: the native lanes only reach the build via 🤖 Generated with Claude Code |
Native → PHP event channel coalesces concurrent completionsFound while testing this on Android (emulator). The async lane in this PR is correct — the loss is one layer down, in the shared C event channel.
uint8_t *p = r->event_heap; /* always writes at offset 0 */
...
atomic_store(&r->event_size, (uint32_t)pos);
atomic_store(&r->event_count, 1); /* a flag, never incremented */
pthread_cond_signal(&r->event_cond);
This PR is the first thing to post into that channel from several OS threads at once (4 pool threads + the watchdog), so it's the first thing to expose it. ReproSection 5 of the test bench — "Dispatch 6", six 1s tasks against the 4-slot pool:
The lost ones are always the ones posted within a few ms of another completion ( Run one at a time, everything in the bench passes:
So the PHP layer, the Kotlin executor, the watchdog and the boot/stop lifecycle all behave. It's purely the delivery channel dropping frames under concurrency. Not Android-specificiOS goes through the identical C function ( Fix (deferred)Make Two open decisions when we pick this up:
Deferring until after v4 ships. This is a cc @simonhamp |
Shane's finding on #228: nphp_element_post_event() is a single slot, not a queue — a second post landing before PHP drains the first overwrites it. This lane is the first thing posting into it from several OS threads at once, so it's the first to expose it; concurrent completions get dropped, the watchdog's own timeout event included. Every path above the channel does reach an outcome, so the design section stays, but claiming it flatly overstates what ships until the FIFO fix lands in build-scripts (deferred past v4 — needs a PHP rebuild). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFbiJwd86MZCTLH5kJHZxo
|
Good find, and I agree with deferring it — that's a One consequence worth naming: this puts a ceiling on the "every dispatch reaches exactly one outcome" property I added last round. Every path above the channel does reach an outcome, but the channel can drop the frame carrying it — and that includes the watchdog's own timeout event, which is the thing meant to be the last line of defence. So the failure mode it was built to prevent (spinner never stops) is still reachable under concurrency, just via a different route. I've caveated the design doc rather than leaving it reading as an unqualified guarantee (e9c58df); the section stands, with the bound stated and the FIFO fix referenced. Also worth flagging for whoever picks up the FIFO work: this lane posts from five threads, not four — the four pool threads plus the watchdog, which fires from its own thread precisely when a pool thread is stuck and not posting. That's the one poster guaranteed to be concurrent with nothing, so it's a useful case to have in mind when sizing the queue. On your two open decisions — both look like calls for the Generated by Claude Code |
shanerbaner82
left a comment
There was a problem hiding this comment.
This is an impressive piece of work — the API shape is right, and the PHP layer is genuinely well built: the static-closure reflection guard failing at the call site, the WeakReference screen-scoping (with the correct object-id-recycling rationale), normalizeResult() running in the background context so a non-encodable result fails loudly, the encodeCompletion() belt-and-braces fallback, the destructor discipline in PendingAsyncTask, and the Windows-safe array-form Symfony\Process under Jump. Cross-language plumbing was verified end-to-end: bridge names (AsyncTask.Dispatch/AsyncTask.Complete), watchdog keys, JNI descriptors, @_silgen_name signatures, and the success flag contract all line up, and the setenv-avoidance claim is true on both platforms. Notably, three independent read-throughs found no compilation blockers in the C, Swift, or Kotlin — the never-compiled code is closer to building than you'd expect.
Requesting changes because the PR's two central guarantees are each broken in several independent places, and a number of stated claims don't match the code. Organized by severity:
1. "Every dispatch reaches exactly one outcome" — six independent holes
1a. PHP — dispatch during mount() scopes to the wrong screen (both runloops). $this->mount() runs at NativeComponent.php:2230 but markActive($this) happens at :2238 (same shape on the Jump runloop at :2405 — its own comment at :2383 confirms mount precedes it). So the canonical "start loading when the screen opens" dispatch captures active() = the previous screen. The completion arrives, origin->get() !== $this, and it's dropped — and the timeout failure is dropped by the same check, so nothing ever unblocks the spinner. Fix: mark active before mount() in both runloops.
1b. iOS — watchdog disarmed on C-call return, not on delivery. AsyncTaskExecutor.swift:187-194 disarms after _async_php_run returns, on the premise that a completion "has already been posted from PHP". Two reachable paths violate that: the early strdup("") return when the slot isn't initialized, and a Zend bailout (memory limit, E_ERROR) that zend_first_try swallows — not a Throwable, so AsyncTaskRunner::run's catch never fires and no completion is posted. The run "ends", the only backstop is cancelled, spinner forever.
1c. Both platforms — stop() drops queued tasks after PHP was told success: true. Android drains the queue and calls inFlight.clear(), discarding the pre-built timeout failure envelopes it is already holding (AsyncTaskExecutor.kt:248-283); iOS stop() does pending.removeAll() + cancels the pending watchdogs its own doc comment says "will unblock the UI" (AsyncTaskExecutor.swift:256-270, also the boot-failure branch at :129). In both cases PendingAsyncTask believes the task is live. The executors should post the failure events they're discarding.
1d. Both platforms — no native watchdog↔completion dedup. PHP posts AsyncTask.Complete inside the run; disarm happens only after request teardown returns. A task finishing at the deadline delivers Finished then Failed for the same id. Single delivery currently survives only because the PHP side forgets on first delivery — which depends on in-order processing of an event channel the last commit's own message says can drop frames. Same fix both sides: disarm in AsyncFunctions.Complete (the payload already carries id; on iOS it's documented as such and never read).
1e. Jump — a subprocess that dies without spooling is silently forgotten. sweepJumpTimeouts() (AsyncTaskTransport.php:223-227, same shape in hasPendingJumpRunners()) drops a dead process's entry and its deadline without checking the exit code or whether a spool file exists. Child OOMs, hits a parse error mid-edit, fails to bootstrap, or is SIGKILLed → no completion, no timeout, runloop returns to timeout: -1. This is the dev-loop path, so it'll be hit constantly. disableOutput() also means there's no stderr to diagnose with (and it makes getErrorOutput() throw, so the fix needs output routed to a file or captured incrementally). Fix shape: on !isRunning() with no spool file, synthesize the failure completion before unsetting.
1f. Jump — end of the hosting request SIGKILLs every child. static::$jumpProcesses holds Symfony Process objects whose __destruct runs stop(0). Hot reload / device-gone give-up / root back-out kills all in-flight tasks with no failure delivered; conversely a completion spooled before death is drained by the next process (drainJumpCompletion() is indiscriminate; on macOS php -S forks a pool, so worker A can drain worker B's completion) into empty AsyncTaskRegistry/NativeCallbacks statics and vanishes silently.
2. The reboot crash this PR exists to prevent is still reachable
2a. Android — the drain result is checked and ignored. MainActivity.kt:941 (and :1032): if (asyncExecutor?.stop() == false) { log } then shutdownPersistentRuntime() anyway. A task past the 5s stop budget is still inside zend_eval_string when php_module_shutdown frees the state its TSRM context references — the exact SIGSEGV the KDoc on the executor says the synchronous wait prevents. Compounding it, native_async_thread_shutdown early-returns unless called from the owning thread (php_bridge.c:1424-1427), so there is no C entrypoint to reap an orphaned context: the gen-N−1 thread eventually finishes and runs php_request_shutdown + ts_free_thread against gen-N (or freed) state.
2b. iOS — the opposite trade-off, also unsafe. stop()'s queue.sync drains the entire per-slot backlog, uncancellable and unbounded — 20 queued 10s tasks block hot reload ~50s per slot (AsyncTaskExecutor.swift:275-279). Meanwhile the 30s bootFinished timeout path proceeds to reboot while the boot loop is still calling async_php_boot, and leaves the group entered (counter skewed for every later cycle) (:72-83, :249-251). An interrupted boot's slot is never reclaimed — in_use stays 1 forever, and at ASYNC_PHP_MAX_SLOTS 4 a few hot reloads with slow boots permanently brick the lane (every subsequent boot returns -2, every dispatch rides the watchdog) (PHP.c:1363-1401, 1453-1471).
2c. Both C lanes — taskId is interpolated into zend_eval_string unescaped (PHP.c:1276-1288, php_bridge.c:1493-1506) while the webview lane in the same files escapes every inlined parameter via webview_escape_php. The UUID invariant is real today but enforced nowhere in C/Swift/Kotlin; any id with a quote yields a swallowed parse error and a generic timeout, and any future caller letting an id cross a trust boundary gets PHP execution. One escape call per side closes the class. (Related: char eval_code[1024] truncates silently; the ephemeral lane uses 4096.)
2d. Races (narrower but real): iOS submit() enqueues after dropping the lock, so a stale block can run against a rebooted pool whose slot index was re-allocated (AsyncTaskExecutor.swift:185-187 vs :275-279); Android dispatch()/stop() share no lock, so a task can land in a queue nobody will drain after Dispatch returned success (AsyncTaskExecutor.kt:119-146 vs :237-250); Android's failed async_embed_init leaves a half-started request that the 500ms retry loop re-enters forever (php_bridge.c:1410-1422 + AsyncTaskExecutor.kt:158-167).
3. Claims vs. code
- "Queueing beyond the pool is logged rather than silent" — on Android the
LinkedBlockingQueueis unbounded sooffer()never fails and the "queue full" branch is dead code; thewaitinglog fires only at 2×poolSize outstanding (AsyncTaskExecutor.kt:62,:133-141). On iOS nothing logs or bounds backlog at all, and round-robin slot selection queues task 5 behind a busy slot 0 while slots 1–3 idle (AsyncTaskExecutor.swift:175-186). - Port asymmetries: Android deadlines use
System.currentTimeMillis()— a clock-set forward trips every watchdog, backward means never (AsyncTaskExecutor.kt:126); iOS correctly uses monotonicDispatchTime. iOS buffers dispatches during startup; Android hard-fails anything in the 2.5sWORKER_START_DELAY_MSwindow — so a first-screen dispatch deterministically hits->failed()(MainActivity.kt:123,:250-255). - "The pool boots lazily" (iOS comment) —
start()eagerly boots 4 full Laravel contexts at launch and after every reload. Worth either making true or deleting, given the memory footprint. - iOS
dispatch_semaphore_createpairs are never released across reboot cycles — 8 leaked semaphores per hot reload (PHP.c:1393-1394).
4. Packaging & tests
composer.jsonis missing both new dependencies:laravel/serializable-closure(used unguarded inPendingAsyncTask.php:8,AsyncTaskRunner.php:5) andsymfony/process(class_exists-guarded). Works today only because host apps pull them vialaravel/framework.- The suite's 4th skip is this PR's own test, and it can never run:
AsyncTaskTransportTest.php:91guards onfunction_exists('nativephp_call'), which the Jump polyfill defines in every testbench boot — so the refused-dispatch cleanup test has never executed, locally or in CI. - The fake diverges from the device in two ways that matter: it never serializes the work envelope (captured objects are shared by identity in tests, deep copies on device —
AsyncTaskRunner::invoke(unserialize(serialize($this->work)))instartFaked()closes it), and->shared()delivers nothing under fake (onlyassertShared()sees it). - A resource capture serializes silently to
i:0(no throw in PHP 8.4), so the "fails loudly at dispatch" guard misses the very example its comment names. sweepJumpTimeouts/spawnJumpRunnerhave zero test coverage; a few tests are effectively vacuous (assertNotDispatchedon a fresh fake; the "memory only" test hand-callsNativeCallbacks::registerrather than dispatching, so flippingdurable:inPendingAsyncTaskwouldn't fail it).- Security posture worth one look: task ids ride argv (visible in
pson the dev machine) and the payload isunserialize()d withoutallowed_classes; the 0700 spool dir is the real barrier, and the SerializableClosure signature only exists when APP_KEY is set. Cheap hardening:allowed_classes+ HMAC over the payload file. Orphaned payload files also have no GC on any path.
None of this needs a rethink — 1a is a two-line move, 1b/1d are "disarm in Complete", 1c is "post what you're already holding", 1e/1f are exit-code checks and process ownership, and the packaging/test items are mechanical. The on-device pass you already planned should then validate the lot. Happy to pair on any slice of it.
🤖 Review drafted with Claude Code
From @shanerbaner82's review. This is the PHP/packaging slice; the native executor findings are held pending a decision on the hot-reload trade-off. 1a — a task dispatched from mount() scoped to the WRONG screen. mount() runs before the runloop marks itself active, and the router calls it before runLoop() on the hot-swap path, so the canonical 'start loading when the screen opens' dispatch captured the screen being replaced. Its completion was then dropped by the origin check — and so was its timeout failure, by the same check, so nothing could unblock the spinner. Marked active before mount() in run(), and around mount()/onResume() + runLoop() in NativeRouter (markActive/restoreActive are @internal-public for it). 1e — a Jump runner that exited without spooling was dropped along with its deadline, so nothing ever reported on the task. That's the dev loop's likeliest failure: a parse error from a half-saved file, a fatal in bootstrap, an OOM, a SIGKILL. Now synthesized as a failure carrying the exit code and the tail of the runner's stderr, so the cause is visible instead of a generic timeout a minute later. disableOutput() is gone for the same reason — it made getErrorOutput() throw at exactly the moment it was needed. Packaging — composer.json was missing laravel/serializable-closure (used unguarded) and symfony/process. Both only resolved via the host app's laravel/framework. Tests — the refused-dispatch test guarded on function_exists( 'nativephp_call'), which the Jump polyfill defines in every testbench boot, so it had never run; it now drives the refusal through FakeBridge, with the accepted and unanswered cases alongside. The durability test hand-called NativeCallbacks::register(), so flipping durable: in PendingAsyncTask wouldn't have failed it; it now goes through a real dispatch. The fake also round-trips the work envelope through serialize/unserialize, so captured objects are deep copies in tests as they are on a device. Also corrected the serialize-guard comment: PHP 8.4 serializes a captured resource to i:0 without complaint, so that guard never caught the case its own comment named. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFbiJwd86MZCTLH5kJHZxo
|
Checked every finding against the code — the ones I've acted on I confirmed first-hand rather than taking on trust, and they were all real. 1a in particular is the best catch in the review: I wrote that ordering and read past it twice. Pushed (b7a4776) — the PHP and packaging slice:
Held, deliberately — the native executor slice (1b, 1c, 1d, 2a, 2b, 2c, 2d): Not because I disagree; I traced each one and they look right, including the Two things from my side if you take it:
CI running on b7a4776; I'll drive it green. Generated by Claude Code |
The full envelope round-trip broke 'passes an AsyncTaskException carrying the original message': unserializing a closure re-evaluates its source in the namespace ReflectionClosure reports, which for a closure written in a Pest test file is Pest's compiled namespace rather than the global one it was written in — so an unqualified 'new RuntimeException' resolved to P\Tests\Unit\RuntimeException. That's an artifact of re-evaluating a test-file closure in this process, not something a device does, so reproducing it here fails tests over a problem real dispatches don't have. Round-trip the task-subclass args, which are plain data and where the identity-sharing gap is real, and document the remaining closure divergence rather than papering over it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFbiJwd86MZCTLH5kJHZxo
|
Green on d2982ae (tests, Pint, PHPStan). One note, since you gave the fix precisely and I didn't land it as written: That's an artifact of re-evaluating a test-file closure in this process, not something a device does, so reproducing it would fail tests over a problem real dispatches don't have. What landed: task-subclass args are deep-copied (plain data, and the identity-sharing gap you described is real there), and the remaining closure divergence is documented on The other four items from §4 are in: Generated by Claude Code |
The single-slot channel this documented was fixed on main (7b38826 plus the extension-side change), so the caveat now describes a bound that no longer exists. Rewritten as history rather than a live limitation. Keeps one residual on the record: post_event returns 1 queued / 0 dropped and drops when PHP has stopped draining. The UI writer discards that by design, but a completion is the case that should check it — and AsyncTask.Complete doesn't yet, which leaves a dropped completion leaning on the watchdog again. Noted as a native follow-up rather than fixed here, since that lane is still uncompiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFbiJwd86MZCTLH5kJHZxo
|
Merged The event-channel FIFO landed (#265 plus the extension-side change). That's the fix @shanerbaner82 opened as deferred-past-v4 — so the caveat I added to the design doc in e9c58df described a bound that no longer exists. Rewritten as history rather than a live limitation (02a3a57). It also answers the bounded-vs-unbounded question in that thread, and leaves this PR one loose end. // A caller that DOES care — an async-task completion, say — should
// check it rather than copy this.
_ = nphp_element_post_event(type, callbackId, nodeId, data, UInt32(max(0, dataSize)))
Two knock-on effects on the review:
Still outstanding and unchanged: 2a (the hot-reload decision — skip / defer-and-retry / raise the deadline, plus not constructing a new CI running on 02a3a57. Generated by Claude Code |
What
Adds a way to run PHP work on a background thread and handle the result back on the UI thread, where the callback can update component state and the screen re-renders:
There's a
$this->async(...)shorthand onNativeComponent, and anAsyncTasksubclass form (BuildReport::dispatch($month)->finished(...)) for anything you'd rather not write inline.Why this is mostly glue
Both halves already existed and just weren't connected:
NativeCallbacks+fireNativeCallback+sendNativeEvent→nphp_element_post_event) already binds a callback to the live component and re-renders. That is->finished().What was missing was a way for a background context to say "done" back to the UI runloop, plus the API on top. This adds a dedicated concurrent async lane and wires it through.
Design decisions
$this. A bound closure is rejected by reflection at dispatch time, in the handler where you can see it — not silently in a background log.finished()/failed()are dropped if the originating screen is no longer topmost — firing a component-bound closure against the wrong live component is a footgun. The origin is held as aWeakReference, sincespl_object_ids are recycled and an id comparison could hand a callback to an unrelated screen.->shared('alias')opts out, delivering a named event any active screen handles via#[On('alias')].AsyncTask.Dispatch'ssuccessflag is checked), a non-encodable result fails as an ordinary task failure (the JSON round trip happens in the background context, where it can still be reported), and a hung task times out (default 60s,->timeout($seconds),0to disable).failed()handler, receiving anAsyncTaskExceptionthat carries the original message and class. One attempt, no retries — silent re-runs of a button press are surprising.queue:workpoll is too slow for interactive work), no database, no standard queue. A bounded pool of reusable PHP contexts; queueing beyond the pool is logged rather than silent.AsyncTask.DispatchandAsyncTask.Complete. The timeout travels as a deadline plus the pre-built completion to post if it passes, so even the watchdog doesn't teach native what an event looks like.dispatch()terminology for familiarity, but this is explicitly not a queued job.Full rationale in
docs/async-task-design.md.Alternative considered
Expose a
nativephp_post_event()PHP function in the extension — cleaner and zero native hop, since the background context would post the wake event itself instead of routing throughAsyncTask.Complete. Not taken here because thenativephpextension ships prebuilt, so a new extension function can't land from this repo. Worth revisiting when the extension is next rebuilt.Changes
PHP —
AsyncTask,PendingAsyncTask,AsyncTaskRunner/Transport/Registry,AsyncTaskFinished/AsyncTaskFailedevents,AsyncTaskException,native:async:runcommand, and async-completion handling inNativeComponent.Native — an
async_php_*lane in bothphp_bridge.c(Android) andPHP.c/PHP.h(iOS), plusAsyncTaskExecutorandAsyncTask.*bridge functions in Kotlin and Swift, wired into app boot and hot-reload runtime reboots. Both executors stop synchronously — draining in-flight tasks and shutting their contexts down before returning — because callers stop the pool immediately before a runtime reboot that frees Zend state those contexts reference. The lane avoidssetenventirely: it runs several contexts at once, andsetenv/getenvare neither thread-safe nor per-thread, so the per-thread$_SERVERentries carry the console environment instead.Jump — dispatches to a dev-machine subprocess and drains a completion spool into the runloop, since there's no device async lane on a laptop. It also sweeps overdue subprocesses each tick, standing in for the native watchdog.
Testing —
AsyncTask::fake()runs work inline-synchronously withassertDispatched()/assertNotDispatched()/assertDispatchedTimes()/assertShared(), and applies the same JSON normalization a device does, so a test can't pass on a value that wouldn't survive the real hop.Testing
super-native(dispatch → background run → callback → state update → re-render), including the failure path, the static-closure guard,shared()delivery and the screen-scope drop.native:run, not hot reload) to shake out build errors. That's still the main thing to check before this leaves draft.Docs for this land separately in the
nativephp.comrepo asdigging-deeper/async-tasks.🤖 Generated with Claude Code