Skip to content

Fix crashes from forced casts on explicit-null Nightscout fields (full-schema uploader compatibility) - #794

Closed
markjoldham wants to merge 3 commits into
loopandlearn:devfrom
markjoldham-org:fix/devicestatus-forced-cast-crash
Closed

markjoldham wants to merge 3 commits into
loopandlearn:devfrom
markjoldham-org:fix/devicestatus-forced-cast-crash

Conversation

@markjoldham

@markjoldham markjoldham commented Oct 26, 2026 •

Copy link
Copy Markdown

Problem

LoopFollow crashes on launch, and again during normal treatment-graph loading, when the Nightscout backend is fed full-schema documents (every field present, null when unused) instead of the traditional sparse style (unused fields simply omitted). I hit this after reconfiguring my pipeline so Nocturne, not Trio, writes to Nightscout directly (previously: Trio → Nightscout, with Nocturne only reading from Nightscout; now: Trio → Nocturne → Nightscout). The exact mechanism Nocturne uses to write to Nightscout isn't confirmed, but the resulting change in document shape is, against my own Nightscout data (see below).

Full root-cause writeup with data: see #793.

Fixes #793.

Root cause (short version)

JSONSerialization decodes a JSON null as NSNull, a real non-nil object, not Swift's nil. Several places in the Nightscout-parsing code do:

if dict["key"] != nil {
    let value = dict["key"] as! SomeType
}

!= nil is true for NSNull, so the force-cast (as!) that follows crashes with swift_dynamicCastFailure whenever the field is explicitly null rather than absent. Full-schema uploaders (like Nocturne) always include every field, null when unused — e.g. devicestatus.loop is now always present as null for a non-Loop rig, and treatments[].timestamp is now always present as null alongside created_at. Sparse uploaders never sent those keys at all, so this was never previously reachable.

Confirmed against two real crash reports (an initial crash, and a second, distinct crash that surfaced only after the first was fixed) and against live production data spanning the uploader-switch transition (pinpointed to an instantaneous cutover at 2026-10-26T02:13:47Z).

What changed

  • Controllers/Nightscout/DeviceStatus.swift — pump/loop/openaps top-level casts changed from as! to as?, so an explicit-null or unexpected value is treated as absent instead of crashing.
  • Controllers/Nightscout/Treatments/{SMB,Carbs,Bolus,Notes}.swift and Stats/StatsDataFetcher.swift (3 call sites) — the timestamp/created_at presence-check-then-force-cast pattern replaced with a single safe-cast fallback chain:
    guard let date = currentEntry["timestamp"] as? String ?? currentEntry["created_at"] as? String else {
        continue
    }

Testing

  • Verified the fix against the exact crash signatures in both submitted .ips reports (initial crash: DeviceStatus.swift; second crash, which surfaced only after the DeviceStatus fix was already applied: treatment-timestamp).
  • Verified against live Nightscout data from before and after the uploader switch, including the full-schema devicestatus and treatments shapes shown in the issue.

Known follow-up (not included in this PR)

The same != nil + as! pattern exists in a few other spots that aren't currently reachable with my data (Loop-only device-status fields, sensor/cannula-age parsing) but are equally fragile under full-schema uploaders. Listed in the issue for a possible follow-up pass.


🤖 Generated with Claude Code

marionbarker and others added 3 commits September 20, 2026 11:14
lastDeviceStatus?["pump"], ["loop"], and ["openaps"] were force-cast
(as!) to [String: AnyObject]?. When Nightscout returns a non-dictionary
value for one of these keys (e.g. explicit null), the forced cast
throws a Swift runtime fatal error (swift_dynamicCastFailure), crashing
the app on the main thread shortly after launch during device status
processing. Switched to safe optional casts (as?) so a missing or
unexpected value is simply treated as absent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SMB/Carbs/Bolus/Notes processing and StatsDataFetcher checked
currentEntry["timestamp"] != nil before force-casting it to String,
but that check passes for an explicit JSON null (NSNull), which is
not nil in Swift. Uploaders that always include a "timestamp" key
(set to null when unused, falling back to created_at) made every
treatment with a null timestamp crash with swift_dynamicCastFailure
on the main thread.

Replaced with a single safe-cast fallback chain (timestamp as? String
?? created_at as? String) that correctly skips a null/missing
timestamp and falls through to created_at instead of crashing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ This PR targets the main branch. We do not accept PRs directly to main — please retarget your PR to the dev branch instead.

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ This PR targets the main branch. We do not accept PRs directly to main — please retarget your PR to the dev branch instead.

@markjoldham
markjoldham changed the base branch from main to dev October 26, 2026 23:20
@bjorkert

Copy link
Copy Markdown
Member

Thanks for the thorough write-up in #793, the root cause analysis is spot on.

The base is now dev, which is right, but the branch itself was created from main (v7.1.0) and is 63 commits behind dev. That is why it shows conflicts. Most of what this PR changes is already on dev: #759 added NightscoutUtils.treatmentDate(from:), which skips NSNull and falls back to created_at (it also handles numeric timestamps), and it is used in Bolus, SMB, Carbs, Notes and all three StatsDataFetcher call sites. The same PR also removed the force casts in SAge, CAge, IAge, DeviceStatusLoop and DeviceStatusOpenAPS that you listed as follow-ups.

What is not fixed on dev is the top-level pump/loop/openaps casts in DeviceStatus.swift, so your first crash still reproduces there. That part of the PR is exactly what we need.

Could you merge dev into your branch (a merge is fine, no need to rebase or force-push), take dev's version of the five treatment/stats files when resolving the conflicts, and keep only the DeviceStatus.swift change? The PR should end up as a three-line diff.

One optional addition while you are in there: DeviceStatusOpenAPS.swift (and DeviceStatusLoop.swift) check lastLoopRecord["failureReason"] != nil. It won't crash, but if a full-schema uploader ever sends "failureReason": null inside openaps, the loop status would show "X" permanently. lastLoopRecord["failureReason"] as? String != nil would cover that. If your data doesn't include that field it is fine to leave it out.

@marionbarker

marionbarker commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

@markjoldham I know you are busy, so I would push the rebaselined version of your change but I don't have privileges.

@marionbarker

Copy link
Copy Markdown
Collaborator

closing this in favor of #797.

marionbarker added a commit that referenced this pull request Oct 28, 2026
…ied from PR #794 (#797)

* Modifications from PR #794

* Treat explicit-null failureReason and pump clock as missing, drop force cast in test helper

---------

Co-authored-by: Jonas Björkert <jonas@bjorkert.se>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

App crashes on launch / during Nightscout sync after Nocturne became the writer of Nightscout data (instead of Trio directly)

3 participants