Fix abort when a property call argument fails to resolve - #54
Conversation
A malformed audience filter such as `daysSince(app_install)` (unquoted argument, i.e. an undeclared reference) hit an `.unwrap()` in the computed/device property closure. With `panic = "abort"` that killed the host app on launch, and refetching the same config made it a crash loop (superwall/Superwall-iOS#500). - Propagate argument-resolution errors as `ExecutionError` instead of unwrapping; `execute_with` already degrades undeclared references to `Null`, so the filter simply doesn't match. - Switch the release profile to `panic = "unwind"` (and the build-std target lists in build_ios.sh to `panic_unwind`) and wrap every FFI entry point in `catch_unwind`, returning `{"Err": ...}` for any future panic instead of aborting the host process. - Return a serialized error from `parseToAst` for unparseable input rather than panicking. - Bump to 1.0.15: superscript-ios-next already has a 1.0.14 tag and its release workflow skips existing tags. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ddf39a3 to
7739ad1
Compare
There was a problem hiding this comment.
Important
The root-cause fix is correct and well-tested, but two things deserve a decision before merge: the catch_unwind safety net is inert on the wasm32-unknown-unknown artifact published from this same repo, and the nightly watchOS/visionOS panic_unwind legs cannot be exercised until after merge — which is also the release.
Reviewed changes — full diff of ddf39a3 (4 files) plus the surrounding execute_with / prop_for code, the build scripts, and the release workflows. I ran cargo test --lib locally: 114 pass.
- Argument-resolution errors propagate instead of panicking — the computed/device property closure now collects args into
Result<Vec<_>, ExecutionError>and?-propagates (src/lib.rsnew 530-537). Verified againstcel-interpreter0.8.1: a closure'sExecutionErroris returned verbatim byValue::resolve'sFunctionCallarm (noFunctionErrorwrapping), soExecutionError::UndeclaredReferencereaches theerror_msg.contains("Undeclared reference")degrade inexecute_withand yieldsNull. The happy path is bit-for-bit equivalent, and both old and new code short-circuit on the first argument in the same order. recovering_from_panicsguard on all four FFI entry points — thecel.udlnamespace exports exactly these four, so coverage is complete; the_implsplit keeps the bodies unchanged. Payload downcast handles both&strandStringpanic payloads.- Release profile
panic = "abort"→"unwind", with-Zbuild-stdlists updated —Cargo.tomlnew 41-43 andbuild_ios.shnew 98/101/105/108/110. parse_to_astreturns{"Err": …}instead of.unwrap()-panicking — success shape is unchanged (bare serialized AST), and the doc comment was updated to match.- Version → 1.0.15, CHANGELOG entry — 1.0.14 skipped, with the reason recorded as a
Cargo.tomlcomment.
On test quality: of the six new tests, the three undeclared_reference ones are genuine regression tests (restoring the .unwrap() turns {"Ok":{"type":"Null"}} into the guard's {"Err": "Expression evaluation panicked: …"}). The other three are a happy-path control and unit coverage for the two new/rewritten helpers — useful, but not regression coverage for the .unwrap() itself.
⚠️ The catch_unwind net does not protect the WASM/npm artifact, but the changelog promises it does
wasm32-unknown-unknown hard-codes panic-strategy: abort in its target spec, and Cargo silently drops a profile's panic = "unwind" for targets that can't unwind rather than erroring. So on the JS/npm distribution built from this same repo, recovering_from_panics cannot catch anything and a panic still traps the module. The root-cause fix (the ?-propagation) does apply there, so the #500 class is genuinely fixed on every target — it's the belt-and-braces layer and the changelog's "any future evaluator panic is returned as an {"Err": ...} result instead of killing the host process" (CHANGELOG new line 8) that don't hold for WASM consumers.
Technical details
# `panic = "unwind"` is a no-op on `wasm32-unknown-unknown`, so the FFI panic guard is inert there
## Evidence
Independently reproduced on this branch with the repo's own stable toolchain (1.97.1):
```
$ rustc --target wasm32-unknown-unknown -C panic=unwind --crate-type cdylib probe.rs
error: the crate `panic_unwind` does not have the panic strategy `unwind`
```
`cargo build --lib --release --target wasm32-unknown-unknown -v` still succeeds and emits **no** `-C panic=` flag at all — Cargo omits it silently, per <https://doc.rust-lang.org/cargo/reference/profiles.html#panic> ("the actual value depends on the default of the target platform"). Reaching real unwind support on this target requires nightly `-Zbuild-std`, per <https://doc.rust-lang.org/rustc/platform-support/wasm32-unknown-unknown.html#unwinding>; neither `build_wasm.sh` nor `.github/workflows/build-test-PR-superscript-npm.yml` (stable, no `-Zbuild-std`) does that.
## Affected sites
- `CHANGELOG.md:8` — "guards all FFI entry points … so any future evaluator panic is returned as an `{"Err": ...}` result instead of killing the host process". Untrue for the npm/WASM package.
- `Cargo.toml:41-42` — "Must stay \"unwind\": the FFI entry points rely on `catch_unwind` so a panic … can't abort the host app." Correct for the uniffi targets, misleading as a blanket statement.
- `src/lib.rs:60-65` — same wording in the `recovering_from_panics` doc comment.
- `src/lib.rs` ~402-420 (pre-existing, not in this diff) — the `#[cfg(target_arch = "wasm32")]` `prop_for` still has two `.expect("Failed to serialize args …")` calls that will trap the module rather than surface an `Err`. Listed for context, not as something this PR must fix.
## Required outcome
- The user-facing claim matches reality: panics are caught on the uniffi (iOS/Android) targets; the WASM target still traps on panic and is protected only by the error-propagation fix.
## Suggested approach (optional)
- Scope the CHANGELOG bullet and the two Rust/Cargo comments to the uniffi entry points, and note that `wasm32-unknown-unknown` is abort-only so panic-freedom there depends on not panicking rather than on catching.
## Open questions for the human
- Is a follow-up wanted to close the WASM gap — either `-Zbuild-std` for the wasm build, or replacing the remaining `.expect(...)`/`.unwrap(...)` calls on the wasm path with error returns? The npm CI job builds and smoke-tests the bundle but never feeds it a malformed expression, so this gap is currently invisible to CI.⚠️ The riskiest part of this change cannot be verified before merge, and merge is the release
trigger-supercel-ios.yml fires only on push: branches: [master] — there is no workflow_dispatch — so the four nightly -Zbuild-std … panic_unwind legs are first exercised by the dispatch that is the release. The evidence says this should work (none of the watchOS/visionOS target specs override panic_strategy, so they already default to unwind, and panic-unwind is a default -Zbuild-std-features value), but arm64_32-apple-watchos is tier 3 with no published precedent for panic_unwind and a history of unwind-symbol link failures (rust-lang/rust#103508).
Technical details
# No pre-merge validation path for the nightly Apple `panic_unwind` builds
## Affected sites
- `build_ios.sh:98,101,105,108,110` — `panic_abort` → `panic_unwind` on the visionOS and watchOS `-Zbuild-std` invocations. Unverifiable on Linux; the local toolchain is stable rustc 1.97.1 with no Xcode SDKs.
- `.github/workflows/trigger-supercel-ios.yml` — `on: push: branches: [master]` only, so the downstream `build_ios.sh` run happens after merge.
## What was checked
- rustc target specs for `arm64_32-apple-watchos`, `armv7k-apple-watchos`, `aarch64-apple-watchos-sim`, `x86_64-apple-watchos-sim`, `aarch64-apple-visionos{,-sim}` and the shared `spec/base/apple/mod.rs`: none set `panic_strategy`, and `TargetOptions::default()` is `PanicStrategy::Unwind`. So the profile setting is not fighting the target.
- `library/std/Cargo.toml`: `panic_abort` is a non-optional dep and `panic_unwind` sits behind the `panic-unwind` feature, which <https://doc.rust-lang.org/cargo/reference/unstable.html#build-std-features> lists as a default `-Zbuild-std-features`. The crate-list swap in `build_ios.sh` is therefore consistent but largely documentary — `-Cpanic=unwind` from the profile is what actually decides.
- `library/panic_unwind` / `library/unwind` exclude only `os=none`, `uefi`, `espidf`, `nvptx64`, `avr`; no Apple OS is excluded.
## Required outcome
- The full xcframework (all slots, including `arm64_32-apple-watchos` and `x86_64-apple-watchos-sim`) is known to build and link with `panic_unwind` before the change reaches consumers.
## Suggested approach (optional)
- Run `./build_ios.sh` once on a macOS machine from this branch, or add `workflow_dispatch` to `trigger-supercel-ios.yml` (with a ref input) so the downstream build can be exercised pre-merge.
- Worth capturing the resulting `.a` sizes while you're there, since the PR expects growth from unwind tables and there is no size gate in CI to catch a surprise.ℹ️ A malformed dashboard filter now fails silently, with no signal to anyone
The abort was awful, but it was also the reason #500 was found. After this change a filter like daysSince(app_install) >= 1 evaluates to {"Ok":{"type":"Null"}}, indistinguishable at the SDK boundary from a legitimately-null result, so the audience silently never matches. The PR explicitly defers dashboard-side validation of expression_cel to separate work, which leaves no path by which a broken filter gets noticed at all.
Technical details
# Degrading to `Null` loses the only existing signal that a filter is malformed
## Affected sites
- `src/lib.rs` new 530-537 — argument-resolution failures now flow into the pre-existing `error_msg.contains("Undeclared reference")` → `Ok(Value::Null)` degrade in `execute_with`, which is indistinguishable from a genuine `Null`.
## Required outcome
- A decision on whether "degraded because the expression was malformed" needs to be observable somewhere, rather than an unconditional silent non-match.
## Open questions for the human
- Is the intent that dashboard-side validation is the only detection mechanism, or should the Rust layer emit something distinguishable (a log line via the host context, or a distinct result shape) so a bad `expression_cel` in production is discoverable? Note the `catch_unwind` path is fine here — it returns a message — it is specifically the `Null` degrade that is silent.ℹ️ Nitpicks
wasm/Cargo.toml:22still setspanic = "abort", which Cargo ignores for non-root workspace members (warning: profiles for the non root package will be ignoredshows up on every build). It now visibly contradicts the root profile, so the newCargo.tomlcomment's "keep these in sync" instruction points at an incomplete set — deleting the dead block, or naming it in the comment, would keep that instruction honest.- The reason 1.0.14 is skipped lives only in a
Cargo.tomlcomment;CHANGELOG.mdjumps1.0.15→1.0.13with no explanation. A one-line note in the changelog would save the next person the archaeology.
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ One observation on the force-push. The two prior
### ⚠️concerns are unaffected by it and still stand.
Reviewed changes — range-diff of ddf39a3 → 7739ad1. The only delta is in Cargo.toml: the two-line comment above version = "1.0.15" explaining why 1.0.14 was skipped has been removed. The version bump, the panic = "unwind" switch and its comment, build_ios.sh, src/lib.rs and CHANGELOG.md are byte-identical to what I reviewed.
ℹ️ The skipped 1.0.14 is now unexplained anywhere in the repo
Dropping the comment removes the only record of why the version jumps 1.0.13 → 1.0.15. CHANGELOG.md has no 1.0.14 entry either, so the next person to touch the version — or anyone auditing why a tag is missing from the release history — has nothing to go on but the superscript-ios-next tag list. If the comment was cut for tidiness, a one-line ## 1.0.14 — skipped, see … note in the changelog would preserve the reasoning without cluttering the manifest.
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ No critical issues — one accuracy nit inline, plus one coverage gap below.
Reviewed changes — no prior pullfrog review exists on this PR, so the whole branch was reviewed (1 commit, 4 files) rather than a delta. cargo test --lib passes locally at 7739ad1 (114 passed, 0 failed).
- Propagated argument-resolution errors — the computed/device property closure in
execute_withcollects resolved args intoResult<Vec<_>, ExecutionError>and?-propagates instead of.unwrap()ing, sodaysSince(app_install)becomes anUndeclared referenceerror that the existing downgrade turns intoNull. - Switched the release panic strategy to
unwindand updated the five-Zbuild-stdlists inbuild_ios.shfrompanic_aborttopanic_unwind. - Guarded all four uniffi entry points with a new
recovering_from_panicshelper that serializes a caught panic to{"Err":"Expression evaluation panicked: …"}. - Made
parse_to_astreturn a serialized error on parse failure instead of.unwrap()ing it; the success shape (bare AST JSON) is unchanged. - Bumped the crate to 1.0.15 and added the matching CHANGELOG section.
- Added six
--libtests — the incident filter in three shapes, a quoted-argument control that still resolves through the host, the panic guard, andparse_to_ast("daysSince(").
Worth recording since it is load-bearing and non-obvious: cargo only emits -C panic=<x> when <x> is not unwind, so this diff removes the previous -C panic=abort and lets each target's spec default apply. Every Apple target in build_ios.sh and every Android target in build_android.sh defaults to unwind, so catch_unwind genuinely becomes live on those platforms.
ℹ️ Nothing in CI runs the Rust tests, so both the new regression tests and the panic strategy are unenforced
The four workflows only build the wasm/npm package and dispatch downstream releases — none invokes cargo test, so the six new tests run on developer machines only. Since merging to master auto-dispatches the iOS and Android builds, a later edit that restores panic = "abort" (or drops recovering_from_panics) would silently re-arm the abort-on-panic crash with no check failing.
Technical details
# The abort-safety property has no automated enforcement
## Affected sites
- `.github/workflows/` — `build-test-PR-superscript-npm.yml`, `build-test-publish-superscript-npm.yml`, `trigger-supercel-ios.yml`, `trigger-supercel-android.yml`. None runs `cargo test`; the only cargo invocations are inside `build_wasm.sh`.
- `Cargo.toml:41` — `panic = "unwind"` is now load-bearing for the whole fix, but only a code comment protects it.
- `src/lib.rs:1688-1691` — `test_panic_guard_converts_panic_to_err_json` passes under the dev profile (unwind by default) even if the release profile is switched back to abort, so it cannot detect that regression.
## Required outcome
- CI fails if the `--lib` tests break.
- CI fails if the shipped profile stops unwinding.
## Suggested approach
- Add a `cargo test --lib` step to the PR workflow (or a new Rust workflow).
- Note that `cargo test --release` would *not* catch a panic-strategy regression: cargo drops `-C panic` for test targets, so the release profile's `panic` value never reaches the test binary. A compile-time guard is the direct check:
```rust
#[cfg(all(panic = "abort", not(target_family = "wasm")))]
compile_error!("cel-eval requires panic = \"unwind\": the FFI entry points rely on catch_unwind");
```
The `not(target_family = "wasm")` exclusion is required because `wasm32-unknown-unknown` declares `"panic-strategy": "abort"` in its target spec and cannot unwind.
## Open questions for the human
- Is the absence of a `cargo test` job deliberate (run elsewhere), or just never added?
- The three targets under `tests/` don't compile on clean `master` either. Are they slated for repair or deletion? Until one happens, a `cargo test` job would need `--lib` scoping.ℹ️ Nitpicks
tests/integration_tests.rs:289disables a test with the comment "Temporarily disabled due toparse_to_astpanicking on some invalid expressions" — that reason no longer holds after this change, though re-enabling is blocked on making that file compile again.
Claude Opus | 𝕏
- Qualify CHANGELOG and comments: catch_unwind is inert on wasm32-unknown-unknown (abort-only target spec), so the wasm/npm build is protected by the error-propagation fix, not the guard - Drop the ignored [profile.release] in wasm/Cargo.toml (cargo ignores profiles in non-root workspace members) - Note the 1.0.14 version skip in the changelog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — range-diff of 7739ad1 → ac6cc18, plus the current state of the touched files. The delta is documentation and dead-config removal; no evaluator, FFI, or build-flag behavior changed.
- Scoped the panic-guard claims to the native targets —
CHANGELOG.md:8, theCargo.toml:39-41comment, and therecovering_from_panicsdoc comment (src/lib.rs:65-66) now state that thecatch_unwindnet covers the uniffi iOS/Android builds and thatwasm32-unknown-unknownis abort-only, where protection comes from the error-propagation fix instead. That matches what was verified: cargo emits-C panic=<x>only when<x> != unwind, and the wasm target spec hard-codesabort. - Recorded why 1.0.14 was skipped —
CHANGELOG.md:11now notes the tag was already used by thesuperscript-ios-nextrelease pipeline, so the version jump is explained in the repo again. - Deleted the ignored
[profile.release]fromwasm/Cargo.toml— confirmed inert before and after:cargo metadatashowswasmis a member of the root workspace (cargo ignores non-root member profiles, andbuild_wasm.sh:10still resolves that root), and the root profile'sopt-level = "z"/lto = "fat"/codegen-units = 1/stripmatch what was removed, so the wasm artifact is unchanged.
The earlier body-level items — no CI enforcement for panic = "unwind" or the new --lib tests, no pre-merge run of the nightly Apple panic_unwind legs, and the silent Null degrade for a malformed filter — are untouched by this delta and still stand as decisions for you rather than defects in the code.
Claude Opus | 𝕏
|
Addressing the three review findings:
Size cost of unwind, isolated on ℹ️ silent 🤖 Generated with Claude Code |

Summary
Root-cause fix for superwall/Superwall-iOS#500: a malformed audience filter authored in the dashboard (e.g.
daysSince(app_install)— unquoted argument, i.e. an undeclared reference) hit an.unwrap()in the computed/device property closure (src/lib.rs:491). Withpanic = "abort"in the release profile, that panic calledstd::process::abort()and killed the host app during launch preloading — an unrecoverable crash loop, uncatchable from Swift/Kotlin, re-triggered on every relaunch because the same config is refetched.Changes
Resultand returns theExecutionError.execute_withalready degradesUndeclared referenceerrors toNull, so the filter simply doesn't match — the Swift/Kotlin layers already handle that correctly.panic = "unwind"(and the-Zbuild-stdlists inbuild_ios.shfrompanic_aborttopanic_unwind). All four uniffi entry points (evaluateWithContext,evaluateAstWithContext,evaluateAst,parseToAst) now wrap their bodies incatch_unwind, returning{"Err": "Expression evaluation panicked: …"}instead of aborting the host process. This is required inside the Rust bodies: the udl declares these as plain string-returning, so a panic reaching uniffi's scaffolding would still fatal-error on the Swift side. Likely also covers the panic class in iOS crashed #48.parseToAstreturns a serialized error for unparseable input instead of panicking. Success shape unchanged.Cargo.tomland skips existing tags — 1.0.14 would silently not publish).Tests
Six new tests in
cargo test --lib(114 pass), including the exact incident filter mirrored from the SDK's execution context:daysSince(app_install) >= 1,device.daysSince(app_install) >= 1, and(size(device.activeEntitlements) == 0) && (daysSince(app_install) >= 1)→{"Ok":{"type":"Null"}}(no abort)daysSince("app_install") >= 1still resolves via the host →true{"Err": …};parseToAst("daysSince(")→{"Err": …}Note: the three targets under
tests/(integration_tests,coverage_tests,display_tests) don't compile on cleanmastereither (they useuse super::*;and private types) — pre-existing, untouched here.Notes for review
-Zbuild-stdwatchOS/visionOS builds aren't verifiable locally; the dispatch-triggered CI run ofbuild_ios.shafter merge is the real check for those targets.masterauto-dispatches builds to Superscript-iOS (legacy), superscript-ios-next, and Android — merge is effectively release..exactpin in Superwall-iOSPackage.swift/podspec (and the Android equivalent). The dashboard-side hardening suggested in [BUG] Crash on launch due to misconfigured filter Superwall-iOS#500 (validateexpression_celon save, property-picker free-text commit) is separate work.🤖 Generated with Claude Code