Skip to content

Commit e5bc172

Browse files
committed
feat(notifications): #98 background-receive on iOS + Android (warm-path) (v0.5.318)
iOS uses application:didReceiveRemoteNotification:fetchCompletionHandler: with the OS completion block gated on the user's returned Promise — block is Block_copy'd via RcBlock and stashed by handle until a Promise.then trampoline (itself a real Perry closure with two i64 captures) fires the matching UIBackgroundFetchResult. Android routes through PerryFirebaseMessagingService.onMessageReceived to a new nativeNotificationBackgroundReceive JNI; both foreground and background callbacks fire because FCM doesn't split the two at the service layer. Cold start (process not yet loaded) and an Android equivalent of UIBackgroundFetchResult are #98 follow-ups. No-op stubs added on macOS / tvOS / visionOS / watchOS / GTK4 / Windows so cross-platform user code links cleanly.
1 parent a7bea50 commit e5bc172

19 files changed

Lines changed: 438 additions & 38 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
88

99
Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation.
1010

11-
**Current Version:** 0.5.317
11+
**Current Version:** 0.5.318
1212

1313
## TypeScript Parity Status
1414

@@ -149,6 +149,7 @@ First-resolved directory cached in `compile_package_dirs`; subsequent imports re
149149

150150
Keep entries to 1-2 lines max. Full details in CHANGELOG.md.
151151

152+
- **v0.5.318** — Closes #98 (warm-path): `notificationOnBackgroundReceive(cb: (payload: object) => Promise<void>): void` for remote notifications delivered while the app is backgrounded (or terminated, on iOS). New TS export in `types/perry/system/index.d.ts` + dispatch row in `crates/perry-codegen/src/lower_call.rs::PERRY_SYSTEM_TABLE` routing to `perry_system_notification_on_background_receive`. **iOS** (`crates/perry-ui-ios/src/`): new `application:didReceiveRemoteNotification:fetchCompletionHandler:` delegate method (`app.rs`); `notifications.rs` adds `dispatch_remote_payload_with_completion` that JSON-decodes the userInfo NSDictionary, invokes the user callback, and gates the iOS completion block on the returned Promise. The block is `Block_copy`'d via `RcBlock::copy` so it outlives the delegate stack frame; a per-handle id stored in `PENDING_COMPLETIONS: HashMap<i64, RcBlock<dyn Fn(u64)>>` lets a Promise.then trampoline (`perry_ios_notification_completion_trampoline`) look the block back up when the user's Promise settles. Trampoline is itself a real Perry closure constructed via `perry_runtime::closure::js_closure_alloc` with two i64 captures (handle, result-code), so it slots into `perry_runtime::promise::js_promise_then`'s `ClosurePtr` API directly — no second runtime path needed. Three terminal cases: no callback registered → `.NoData` immediately; callback returned a Promise → `.NewData` on resolve, `.Failed` on reject; callback returned synchronously → `.NewData` immediately. **Android** (`crates/perry-ui-android/src/`): new `notification_on_background_receive` registers via `crate::callback::register` keyed off `NOTIFICATION_BACKGROUND_RECEIVE_KEY`; new `Java_com_perry_app_PerryBridge_nativeNotificationBackgroundReceive` JNI dispatches through the same JSON-payload shape as the foreground handler and pumps microtasks up to 8 times after the call so synchronously-attached `.then` chains fire before the FCM service returns. `PerryFirebaseMessagingService.kt::onMessageReceived` now calls *both* `nativeNotificationReceive` and `nativeNotificationBackgroundReceive` so users who register one or both handlers see the right shape; FCM doesn't natively split foreground vs background at the service layer (unlike iOS's two delegate methods), so dual-fire is the closest match. **Stub platforms** — macOS, tvOS, visionOS, watchOS, GTK4, Windows: no-op `perry_system_notification_on_background_receive` exports added so cross-platform user code compiles + links. **Out of scope (#98 follow-ups)**: (1) cold-start on Android (FCM waking a terminated app — `UnsatisfiedLinkError` branch in the FCM service still drops the message; needs `Application.onCreate` to load the native lib at process spawn). (2) iOS cold-start boot benchmark. (3) Doze-mode cooperation analysis on Android. (4) An equivalent of UIBackgroundFetchResult on Android (no native equivalent — FCM service runtime budget governs how long async chains have to complete after the JNI call returns). Compile-link verified across 6 non-Windows perry-ui-* crates; iOS builds cleanly for `aarch64-apple-ios-sim`; perry-ui-windows pre-existing cross-compile-from-macOS issue (text.rs:332 `windows` crate resolution) is unrelated. Snippet added to `docs/examples/system/snippets.ts` (`// ANCHOR: notification-background`) demonstrating the canonical pattern: persist payload via `preferencesSet` then optionally hit a server with `await fetch(...)`.
152153
- **v0.5.317** — Closes #154: `using` / `await using` (ES2024 explicit resource management). Pre-fix, Perry parsed the binding form but lowered it as a plain `const` — `[Symbol.dispose]()` / `[Symbol.asyncDispose]()` hooks never ran, and the `Symbol.dispose` / `Symbol.asyncDispose` accessors weren't recognized as well-known symbols. Three pieces: (1) `symbol_well_known_key` (`crates/perry-hir/src/lower_decl.rs`) and the `Symbol.<name>` member-expression lowerer (`crates/perry-hir/src/lower.rs:9264`) extended to recognize `dispose` / `asyncDispose` — same `@@__perry_wk_<name>` SymbolFor pattern as the existing `iterator` / `toPrimitive` family. (2) `lower_class_method` renames computed-key methods `[Symbol.dispose]` → `__perry_dispose__` and `[Symbol.asyncDispose]` → `__perry_async_dispose__` — stable string-keyed names so the using-block desugarer can dispatch via plain method-call. (3) New `lower_stmts_using_aware` helper in `lower_decl.rs` rewires `lower_block_stmt` / `lower_block_stmt_scoped`: when a using-decl is found, lowers its bindings via the existing `lower_var_decl_with_destructuring` (so `Type::Named("Resource")` flows through and static class-method dispatch fires), then recursively lowers the remaining statements as a try-body wrapped in N nested `Stmt::Try { catch: None, finally: ... }` — one per binding, innermost-first — so disposal runs in reverse declaration order. Each finally checks `id !== null && id !== undefined` before calling the dispose method (per spec). For `await using`, the call is wrapped in `Expr::Await`. Class-method-captures-enclosing-fn-local has its own pre-existing codegen gap (filed as #212): the dispose method is silently dropped when `ctx.scope_depth > 0` AND the body references locals not in `own_locals` — preserves the pre-fix "test compiles, produces no disposed output" baseline for `test_gap_async_advanced.ts`'s class-in-async-fn pattern instead of newly breaking compile. Module-level classes (the canonical pattern) work end-to-end. New regression test `test-files/test_issue_154_using_dispose.ts` covers sync `using`, `await using`, multi-binding (`using a = e1, b = e2, c = e3` — rightmost disposes first), null-skip, byte-for-byte against `node --experimental-strip-types`. SuppressedError chaining (when body throws and a disposer also throws) is out of scope for v1 — Perry's existing `try { ... } finally { ... }` (no catch) doesn't currently re-propagate exceptions to outer catches anyway, so even spec-compliant disposer-error wrapping would behave the same.
153154
- **v0.5.316** — Closes #167: silent SIGSEGV in tight `buf.readInt32BE(i*4)` / `buf.writeInt32BE(...)` loops past ~250k–300k iterations on macOS arm64 (8 MB stack). Two `alloca [N x double]` sites in `crates/perry-codegen/src/lower_call.rs` (the `js_native_call_method` dispatch at line 1755 + the class-dispatch fallback at line 1203) emitted into whatever basic block `ctx.block()` / `blk` currently represented. When the call site lived inside a loop, that's the loop body — and LLVM lowers a non-entry-block alloca as a runtime `sub %rsp, N` with no matching `add %rsp, N`, so every iteration permanently shrank the stack by 16 bytes (the args-array size, AArch64 16-byte-aligned). At ~250k–500k iterations the stack was exhausted → SIGSEGV with no error output (the crash happens after a flushed `console.log`, exit code propagates as 0 through shell pipes — easy to miss). Math from the issue's investigation: write loop (2-arg call) + read loop (1-arg call) × 16 bytes/iter × 250k each ≈ 8 MB → survives barely; 300k → SIGSEGV. Fix: new `LlFunction::alloca_entry_array(elem_ty, count)` helper in `crates/perry-codegen/src/function.rs` (4 LOC, mirrors the existing scalar `alloca_entry`); both call sites now hoist the args-array alloca to the function entry block where it's executed exactly once at function prologue. Verified end-to-end on the issue's repro: pre-fix N=300k crashed silently after "fill ok"; post-fix N=100k / 300k / 500k / 1M / 2M all complete cleanly with correct sums. New regression test `test-files/test_issue_167_loop_alloca_stack_eat.ts` runs N=500k (decisive pre-fix failure, <100 ms post-fix). No runtime change — pure codegen IR-emission fix that affects every dynamic method-call site routing through `js_native_call_method` (Buffer / Uint8Array numeric ops, Map / Set methods on plain object fields, any user-class method call where the receiver class id misses the static dispatch tower).
154155
- **v0.5.315** — Closes #106 properly: `--target watchos-simulator --features watchos-game-loop` now links cleanly even when no native lib is configured. Pre-fix the runtime-only path failed with `Undefined symbols: _perry_register_native_classes / _perry_scene_will_connect` because `crates/perry-runtime/src/watchos_game_loop.rs` declared both as `extern "C"` imports and called them unconditionally from `main()` and the fallback `applicationDidFinishLaunching` — but no object in the runtime's own .a archive exported them, so the linker had nothing to resolve against. The original v0.5.114 commit (4b297092) shipped the contract assuming Bloom-style native libs would always be linked alongside; users who tried the issue's literal acceptance test with no native lib hit the wall and the issue stayed open. Fix: add weak no-op fallbacks via `core::arch::global_asm!` at the top of `watchos_game_loop.rs` — `.weak_definition _perry_register_native_classes` + `.weak_definition _perry_scene_will_connect`, both single-`ret` arm64 stubs (no params read since they take c_void). Mach-O resolution rule: weak symbol + strong symbol → strong wins, so any native lib's strong impls override these defaults at link time. With no native lib, the .app bundle now produces correctly (`/tmp/WatchOSGameLoopTest.app/WatchOSGameLoopTest` — 795 KB Mach-O arm64, exports `__perry_user_main` + the two weak stubs); add Bloom and the FFI hooks become live without touching anything else. Also, one-line UX cleanup at `crates/perry/src/commands/compile.rs:8602` — added `!is_watchos` to the strip-skip condition (the watchOS bundle code at line 8330 moves `exe_path` into the .app and removes the original, so the post-link `strip exe_path` was always emitting a noisy `can't open file` error after the success-path "Wrote watchOS app bundle" message). iOS has the same extern-without-fallback shape but isn't fixed here — separate scope; iOS users hitting the acceptance test always plumb in a native lib (Bloom/etc.), and a follow-up can mirror this pattern at `ios_game_loop.rs` if anyone reports the same UX gap there.

Cargo.lock

Lines changed: 27 additions & 27 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ opt-level = "s" # Optimize for size in stdlib
109109
opt-level = 3
110110

111111
[workspace.package]
112-
version = "0.5.317"
112+
version = "0.5.318"
113113
edition = "2021"
114114
license = "MIT"
115115
repository = "https://github.com/PerryTS/perry"

android-build/app/src/main/java/com/perry/app/PerryBridge.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,18 @@ object PerryBridge {
833833
@JvmStatic
834834
external fun nativeNotificationReceive(payloadJson: String)
835835

836+
/// Forwarded by `PerryFirebaseMessagingService.onMessageReceived` (#98)
837+
/// for every push payload that reaches the FCM service — Android's
838+
/// equivalent to iOS's
839+
/// `application:didReceiveRemoteNotification:fetchCompletionHandler:`
840+
/// path. Same JSON-payload shape as `nativeNotificationReceive`. The
841+
/// Rust side runs the user's Promise-returning callback and pumps
842+
/// microtasks until the synchronously-attached chain quiesces; the FCM
843+
/// service then returns and Android's normal background-runtime
844+
/// budget governs how much further async work can complete.
845+
@JvmStatic
846+
external fun nativeNotificationBackgroundReceive(payloadJson: String)
847+
836848
// --- Notifications (#94) ---
837849

838850
/**

android-build/app/src/main/java/com/perry/app/PerryFirebaseMessagingService.kt

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,20 @@ import org.json.JSONObject
1515
* thereafter) → forwards the token string to native via
1616
* `PerryBridge.nativeNotificationToken`, which dispatches to the JS
1717
* closure registered with `notificationRegisterRemote`.
18-
* - `onMessageReceived` fires for every push payload while the app is
19-
* foregrounded → serializes the data + notification fields to JSON and
20-
* forwards via `PerryBridge.nativeNotificationReceive`. Background
21-
* delivery (`fetchCompletionHandler:` equivalent) is `#98` territory and
22-
* isn't wired here.
18+
* - `onMessageReceived` fires for every push payload that reaches the
19+
* service (FCM doesn't natively distinguish foreground vs background at
20+
* this layer — both hit the same callback). Serializes the data +
21+
* notification fields to JSON, then forwards via:
22+
* - `PerryBridge.nativeNotificationReceive` for any handler registered
23+
* via `notificationOnReceive` — matches the foreground iOS shape.
24+
* - `PerryBridge.nativeNotificationBackgroundReceive` for any handler
25+
* registered via `notificationOnBackgroundReceive` (#98) — the
26+
* Promise-returning shape that the iOS
27+
* `application:didReceiveRemoteNotification:fetchCompletionHandler:`
28+
* delegate uses to gate its `UIBackgroundFetchResult` signal.
29+
* When the user's process isn't running yet (cold-start delivery), both
30+
* calls hit `UnsatisfiedLinkError`; logged and dropped — Application-level
31+
* native-lib loading is a #98 follow-up.
2332
*/
2433
class PerryFirebaseMessagingService : FirebaseMessagingService() {
2534
override fun onNewToken(token: String) {
@@ -41,6 +50,11 @@ class PerryFirebaseMessagingService : FirebaseMessagingService() {
4150
} catch (e: UnsatisfiedLinkError) {
4251
Log.w("PerryFirebase", "nativeNotificationReceive unavailable", e)
4352
}
53+
try {
54+
PerryBridge.nativeNotificationBackgroundReceive(json)
55+
} catch (e: UnsatisfiedLinkError) {
56+
// Same cold-start case; the foreground branch already logged.
57+
}
4458
}
4559

4660
private fun remoteMessageToJson(message: RemoteMessage): JSONObject {

crates/perry-codegen/src/lower_call.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5233,6 +5233,8 @@ static PERRY_SYSTEM_TABLE: &[UiSig] = &[
52335233
args: &[UiArgKind::Closure], ret: UiReturnKind::Void },
52345234
UiSig { method: "notificationOnReceive", runtime: "perry_system_notification_on_receive",
52355235
args: &[UiArgKind::Closure], ret: UiReturnKind::Void },
5236+
UiSig { method: "notificationOnBackgroundReceive", runtime: "perry_system_notification_on_background_receive",
5237+
args: &[UiArgKind::Closure], ret: UiReturnKind::Void },
52365238
UiSig { method: "notificationCancel", runtime: "perry_system_notification_cancel",
52375239
args: &[UiArgKind::Str], ret: UiReturnKind::Void },
52385240
UiSig { method: "notificationOnTap", runtime: "perry_system_notification_on_tap",

crates/perry-ui-android/src/lib.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,17 @@ pub extern "C" fn perry_system_notification_on_receive(callback: f64) {
10031003
system::notification_on_receive(callback);
10041004
}
10051005

1006+
/// Real impl (#98): register the JS closure that fires for background FCM
1007+
/// payloads. Routes through the same `PerryFirebaseMessagingService`
1008+
/// pipeline as foreground delivery — Android doesn't split the two at the
1009+
/// service layer — so the callback fires for every payload that reaches
1010+
/// `nativeNotificationBackgroundReceive`. See system.rs for the v1
1011+
/// trade-offs around Promise gating and cold-start.
1012+
#[no_mangle]
1013+
pub extern "C" fn perry_system_notification_on_background_receive(callback: f64) {
1014+
system::notification_on_background_receive(callback);
1015+
}
1016+
10061017
/// Schedule a fire-after-N-seconds notification via AlarmManager (#96).
10071018
#[no_mangle]
10081019
pub extern "C" fn perry_system_notification_schedule_interval(

0 commit comments

Comments
 (0)