Skip to content

[BUG] Crash on launch due to misconfigured filter #500

Description

@finnvoor

New issue checklist

  • I have reviewed the README and documentation
  • I have searched existing issues and this is not a duplicate
  • I have attempted to reproduce the issue and include an example project.

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}}
  1. 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.
  2. 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.
  3. 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:

  1. In the dashboard, open any campaign whose placement fires at launch (e.g. session_start).
  2. 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).
  3. Set the operator to >= and the value to 1. Save. The generated filter is
    (size(device.activeEntitlements) == 0) && (daysSince(app_install) >= 1).
  4. Ensure the audience preloads (IF_TRUE or ALWAYS).
  5. 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:

  1. 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.
  2. 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.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions