New issue checklist
General information
Superwall version: 4.16.1 (SuperwallKit) / superscript-ios-next 1.0.14 (libcel 1.0.14)
- iOS version(s): iOS 17.2 → 27.0. Not version-specific — observed on 26.5.2, 26.6, 26.5.1, 26.5, 27.0, 18.7.9, 17.2, 26.0.1, 26.3
- CocoaPods/Carthage version (if applicable): N/A, Swift Package Manager
- Xcode version: 27.0 (27A5228h)
- Devices/Simulators affected: All devices. Reproducible on macOS via the
Superscript package directly. Production crashes on iPhone18,3 / 16,2 / 15,4 / 18,1 / 14,3 / 15,3 / 14,5 / 18,2 / 13,2 (i.e. every device that loaded the config)
- Reproducible in the demo project? (Yes/No): Yes, any app with a single malformed audience filter in its dashboard config. Standalone 20-line repro included below (no Superwall account needed)
- Related issues: superwall/superscript#48, same class of panic-in-
cel_eval crash, reported 2026-05-20, still open with no replies
Describe the bug
A malformed audience filter authored in the Superwall dashboard causes an unrecoverable SIGABRT launch-crash loop in production apps. The crash is uncatchable from Swift.
superscript/cel_eval calls .unwrap() on an evaluation error instead of returning it. Because the libcel xcframework is built with panic = abort, the Rust panic calls std::process::abort() and kills the host process. CELEvaluator.evaluateExpression already handles failure correctly via guard let … else { return noMatch }, but a Rust abort is not a Swift error, no guard, try?, or do/catch can intercept it.
The trigger is a single unquoted argument in a computed-property call:
daysSince(app_install) → panic → abort → SIGABRT
daysSince("app_install") → {"Ok":{"type":"bool","value":true}}
- The dashboard's audience property picker accepts free text (placeholder: "Search or type a property…") and stores it verbatim as the condition LHS with no validation.
- That text is compiled into
expression_cel verbatim and pushed to all devices via /api/v1/static_config, no app release required, and no way for the app developer to intervene.
- The audience had
preload: IF_TRUE, so ConfigLogic.getActiveTreatmentPaywallIds evaluates the filter during startup preloading, the app dies before any UI appears, and dies again on every relaunch because the same config is refetched.
Impact: 531 crashes across 72 users in 27 minutes, no client-side code change was involved.
This is a critical availability issue: any dashboard user with campaign-edit access can hard-brick the shipped app of every user, instantly, with a typo in a web form.
Steps to reproduce
Minimal standalone repro (no Superwall account required), SPM package depending only on superscript-ios-next, mirroring how CELEvaluator builds its ExecutionContext:
import Foundation
import Superscript
final class Host: HostContext {
func computedProperty(name: String, args: String, callback: ResultCallback) {
callback.onResult(result: #"{"type":"int","value":5}"#)
}
func deviceProperty(name: String, args: String, callback: ResultCallback) {
callback.onResult(result: #"{"type":"int","value":5}"#)
}
}
let expr = CommandLine.arguments.dropFirst().first ?? ""
let computed = Dictionary(uniqueKeysWithValues:
["minutesSince", "hoursSince", "daysSince"].map { ($0, [["type": "string", "value": "event_name"]]) })
let ctx: [String: Any] = [
"variables": ["map": ["device": ["type": "map", "value": [
"activeEntitlements": ["type": "list", "value": []],
"daysSince_app_install": ["type": "int", "value": 5]
]]]],
"computed": computed,
"device": computed,
"expression": expr
]
let json = String(decoding: try JSONSerialization.data(withJSONObject: ctx), as: UTF8.self)
print(evaluateWithContext(definition: json, context: Host()))
$ ./celrepro 'daysSince("app_install") >= 1'
{"Ok":{"type":"bool","value":true}}
$ ./celrepro 'daysSince(app_install) >= 1'
thread '<unnamed>' (3679658) panicked at src/lib.rs:491:86:
called `Result::unwrap()` on an `Err` value: UndeclaredReference("app_install")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Abort trap: 6 # exit 134
Reproducing the production crash end to end:
- In the dashboard, open any campaign whose placement fires at launch (e.g.
session_start).
- Add an audience. In the property picker, type
daysSince(app_install) and press Enter (it matches no option, so it's stored as a raw property).
- Set the operator to
>= and the value to 1. Save. The generated filter is
(size(device.activeEntitlements) == 0) && (daysSince(app_install) >= 1).
- Ensure the audience preloads (
IF_TRUE or ALWAYS).
- Cold-launch the app on any device/OS.
Expected: the filter fails to evaluate, EvaluationResult returns .failure, CELEvaluator returns noMatch, the audience doesn't match, the app keeps running. Optionally the dashboard rejects the expression at save time.
Actual: SIGABRT during startup, before any UI. The app crashes on every subsequent launch because the same config is refetched, an unrecoverable loop for every user the filter reaches, with no recovery path short of a dashboard edit.
Also reproducible with: device.daysSince(app_install) >= 1. Note this is the exact string the beta dashboard displays for a correctly-configured property (Di() renders stored device.daysSince_app_install as device.daysSince(app_install)), so a user retyping what the UI shows them produces a crashing filter.
Other Information
Symbolicated stack (Sentry, fatal / unhandled, mechanism: signal):
SIGABRT: Signal 6, Code 0
CELEvaluator.evaluateExpression (CELEvaluator.swift:83)
evaluateAstWithContext
uniffi_cel_eval_fn_func_evaluate_with_context
cel_eval::evaluate_with_context
cel_eval::evaluate_ast
cel_interpreter::objects::Value::member
cel_eval::ast::JSONExpression::from
core::result::unwrap_failed
core::panicking::panic_fmt
__rustc::rust_begin_unwind
std::panicking::panic_with_hook
__rustc::rust_panic
__rustc::__rust_start_panic
__rustc::__rust_abort
std::process::abort ← process killed here
swift::swift_Concurrency_fatalError
abort
__pthread_kill
Where the Swift guard cannot help CELEvaluator.swift:78-88:
guard
let resultData = evaluateWithContext(
definition: jsonString,
context: evaluationContext
).data(using: .utf8), // ← never returns; process aborts inside
let result = try? JSONDecoder().decode(EvaluationResult.self, from: resultData)
else {
return noMatch
}
The .failure branch below it is exactly the right behaviour, it's simply unreachable when the evaluator aborts.
Suggested fixes, in priority order:
- Don't abort the host process. Replace the
.unwrap() at cel_eval src/lib.rs:491:86 with error propagation so UndeclaredReference (and any other evaluation error) is returned as {"Err": …}. Additionally build libcel with panic = unwind and wrap the uniffi entry points (evaluate_with_context, evaluate_ast_with_context, evaluate_ast) in catch_unwind, converting panics to Err. This alone downgrades every future malformed filter from a launch crash to a no-match, the Swift layer already handles it. Also applicable to Superwall-Android, which shares superscript.
- Validate
expression_cel server-side on save. The dashboard currently emits CEL that hard-aborts your own evaluator. Running the generated expression through the evaluator before persisting would have blocked this in the UI. For scale: evaluating all 46 of our live audience filters takes ~2 s.
- Harden the dashboard property picker. Pressing Enter on unmatched text writes it into the filter unvalidated (
S = () => { b && (s({type:"property", value: b}), L()) }). Validate on commit, and align the display format with the storage format, showing device.daysSince(app_install) for a property stored as device.daysSince_app_install teaches users the syntax that crashes.
- Dashboard audit log (
GET /v2/audit-logs) shows the causal edit at 08:34:15.144Z:
{"op":"replace","path":"expression",
"before":"device.daysSinceInstall > 1","after":"daysSince(app_install) >= 1"}
{"op":"replace","path":"ruleConditions.conditions.0.lhs.value",
"before":"device.daysSinceInstall","after":"daysSince(app_install)"}
First Sentry event: 08:35:15Z (+60 s). Filter corrected ~09:02; last Sentry event 09:02:10Z (+8 s).
New issue checklist
READMEand documentationGeneral information
Superwallversion: 4.16.1 (SuperwallKit) / superscript-ios-next 1.0.14 (libcel1.0.14)Superscriptpackage directly. Production crashes on iPhone18,3 / 16,2 / 15,4 / 18,1 / 14,3 / 15,3 / 14,5 / 18,2 / 13,2 (i.e. every device that loaded the config)cel_evalcrash, reported 2026-05-20, still open with no repliesDescribe the bug
A malformed audience filter authored in the Superwall dashboard causes an unrecoverable
SIGABRTlaunch-crash loop in production apps. The crash is uncatchable from Swift.superscript/cel_evalcalls.unwrap()on an evaluation error instead of returning it. Because thelibcelxcframework is built withpanic = abort, the Rust panic callsstd::process::abort()and kills the host process.CELEvaluator.evaluateExpressionalready handles failure correctly viaguard let … else { return noMatch }, but a Rust abort is not a Swift error, noguard,try?, ordo/catchcan intercept it.The trigger is a single unquoted argument in a computed-property call:
expression_celverbatim and pushed to all devices via/api/v1/static_config, no app release required, and no way for the app developer to intervene.preload: IF_TRUE, soConfigLogic.getActiveTreatmentPaywallIdsevaluates the filter during startup preloading, the app dies before any UI appears, and dies again on every relaunch because the same config is refetched.Impact: 531 crashes across 72 users in 27 minutes, no client-side code change was involved.
This is a critical availability issue: any dashboard user with campaign-edit access can hard-brick the shipped app of every user, instantly, with a typo in a web form.
Steps to reproduce
Minimal standalone repro (no Superwall account required), SPM package depending only on
superscript-ios-next, mirroring howCELEvaluatorbuilds itsExecutionContext:Reproducing the production crash end to end:
session_start).daysSince(app_install)and press Enter (it matches no option, so it's stored as a raw property).>=and the value to1. Save. The generated filter is(size(device.activeEntitlements) == 0) && (daysSince(app_install) >= 1).IF_TRUEorALWAYS).Expected: the filter fails to evaluate,
EvaluationResultreturns.failure,CELEvaluatorreturnsnoMatch, the audience doesn't match, the app keeps running. Optionally the dashboard rejects the expression at save time.Actual:
SIGABRTduring startup, before any UI. The app crashes on every subsequent launch because the same config is refetched, an unrecoverable loop for every user the filter reaches, with no recovery path short of a dashboard edit.Also reproducible with:
device.daysSince(app_install) >= 1. Note this is the exact string the beta dashboard displays for a correctly-configured property (Di()renders storeddevice.daysSince_app_installasdevice.daysSince(app_install)), so a user retyping what the UI shows them produces a crashing filter.Other Information
Symbolicated stack (Sentry,
fatal / unhandled,mechanism: signal):Where the Swift guard cannot help
CELEvaluator.swift:78-88:The
.failurebranch below it is exactly the right behaviour, it's simply unreachable when the evaluator aborts.Suggested fixes, in priority order:
.unwrap()atcel_evalsrc/lib.rs:491:86with error propagation soUndeclaredReference(and any other evaluation error) is returned as{"Err": …}. Additionally buildlibcelwithpanic = unwindand wrap the uniffi entry points (evaluate_with_context,evaluate_ast_with_context,evaluate_ast) incatch_unwind, converting panics toErr. This alone downgrades every future malformed filter from a launch crash to a no-match, the Swift layer already handles it. Also applicable to Superwall-Android, which sharessuperscript.expression_celserver-side on save. The dashboard currently emits CEL that hard-aborts your own evaluator. Running the generated expression through the evaluator before persisting would have blocked this in the UI. For scale: evaluating all 46 of our live audience filters takes ~2 s.S = () => { b && (s({type:"property", value: b}), L()) }). Validate on commit, and align the display format with the storage format, showingdevice.daysSince(app_install)for a property stored asdevice.daysSince_app_installteaches users the syntax that crashes.GET /v2/audit-logs) shows the causal edit at08:34:15.144Z:{"op":"replace","path":"expression", "before":"device.daysSinceInstall > 1","after":"daysSince(app_install) >= 1"} {"op":"replace","path":"ruleConditions.conditions.0.lhs.value", "before":"device.daysSinceInstall","after":"daysSince(app_install)"}08:35:15Z(+60 s). Filter corrected ~09:02; last Sentry event09:02:10Z(+8 s).