Repository navigation
Description
Summary
LoopFollow crashes shortly after launch (and again during normal operation) when its Nightscout backend is fed documents in a full, fixed-schema style — every field always present, with JSON null for anything unused — instead of the traditional sparse style where unused fields are simply omitted from the document.
I hit this after reconfiguring how my data flows between Trio, Nocturne, and Nightscout:
- Before: Trio → Nightscout (Trio uploaded directly to Nightscout), and Nightscout → Nocturne (Nocturne only read/consumed from Nightscout, it did not write to it).
- After: Trio → Nocturne (Trio now sends to Nocturne instead), and Nocturne → Nightscout (Nocturne now writes to Nightscout). So Nightscout's
devicestatus/treatmentsdocuments are now authored by Nocturne instead of by Trio directly.
I don't know the exact mechanism Nocturne uses to write to Nightscout (whether it goes through the normal Nightscout REST API or some other path) — what's confirmed below is the resulting change in document shape, verified directly against my own Nightscout data. Within 24–48 hours of this change, the app began crashing on launch, and after an initial fix, crashing again during normal treatment-graph updates.
Environment
- LoopFollow: two separate crash reports — an initial crash, and a second, distinct crash that surfaced only after the first was fixed
- Nightscout: self-hosted (Heroku), MongoDB-backed
- AID: Trio → Nocturne → Nightscout (previously Trio → Nightscout directly; reconfigured ~24–48h before symptoms started; Nocturne's write mechanism not confirmed)
Root cause
The two uploaders encode "this field has no value" completely differently:
| Classic / sparse uploader | Nocturne / full-schema uploader | |
|---|---|---|
| Unused field | Key omitted entirely | Key present, value null |
devicestatus.loop |
key does not exist | "loop": null |
treatments[].timestamp |
key does not exist (only created_at present) |
"timestamp": null (plus created_at) |
LoopFollow's Nightscout-parsing code was written assuming the first style: a key is either present with real data, or it's absent. In several places the code does:
if dict["key"] != nil {
let value = dict["key"] as! SomeType // force cast
}When JSONSerialization decodes a JSON null, it produces NSNull — a real, non-nil object — not Swift's nil. So dict["key"] != nil is true for an explicit null, and the subsequent as! force-cast to a concrete type crashes with a Swift runtime fatal error (swift_dynamicCastFailure → abort()), because NSNull cannot be cast to String, [String: AnyObject], etc.
This was never hit before because the old uploader never sent those keys at all when they had no value, so the != nil check correctly returned false and the force-cast was never reached.
Evidence from live data
Pulled directly from my Nightscout instance (read-only token), comparing records from before and after the uploader switch:
devicestatus — old uploader (≈1 month ago), sparse:
{
"_id": "...", "created_at": "...", "device": "...",
"openaps": { ... }, "pump": { ... }, "uploader": { ... }, "utcOffset": 0
}7 top-level keys. No loop key at all.
devicestatus — new uploader (current), full schema:
{
"_id": "...", "cgm": null, "connect": null, "createdAt": "...",
"created_at": "...", "device": "Trio", "id": null, "insulinPen": null,
"isCharging": null, "loop": null, "meter": null, "mills": ...,
"mmtune": null, "openaps": { ... }, "override": null, "pump": { ... },
"radioAdapter": null, "srvCreated": ..., "srvModified": ...,
"uploader": { ... }, "uploaderBattery": ..., "utcOffset": 0, "xdripjs": null
}22 top-level keys, 11 of them explicit null, including "loop": null — this key is now always present.
I binary-searched the transition and found it's an instantaneous cutover, not a gradual rollout:
- Last old-schema (
devicestatushas noloopkey) record:2026-10-26T02:13:46.951Z - First new-schema (
devicestatus.looppresent,null) record:2026-10-26T02:13:48.576Z
treatments — new uploader (current): every bolus/carb/SMB entry now carries both fields, with timestamp explicitly null:
{
"eventType": "Bolus",
"insulin": 7.4,
"created_at": "2026-10-26T19:57:32.786Z",
"timestamp": null,
"enteredBy": "Trio",
...
}(plus ~70 other fields the Nightscout treatment schema defines, all null when unused).
Confirmed crash sites
1. DeviceStatus.swift — top-level pump/loop/openaps (initial crash)
if let lastPumpRecord = lastDeviceStatus?["pump"] as! [String: AnyObject]? { ... }
if let lastLoopRecord = lastDeviceStatus?["loop"] as! [String: AnyObject]? { ... }
if let lastLoopRecord = lastDeviceStatus?["openaps"] as! [String: AnyObject]? { ... }"loop": null now being always present turns this into a guaranteed crash on every launch, on the main thread, inside webLoadNSDeviceStatus()'s device-status handling — matching the crash report's stack (dispatch_main_queue_drain → app frames → swift_dynamicCastFailure).
2. Treatment timestamp parsing (second crash, surfaced only after fix #1 was applied)
Repeated in SMB.swift, Carbs.swift, Bolus.swift, Notes.swift, and 3 call sites in StatsDataFetcher.swift:
if currentEntry["timestamp"] != nil {
bolusDate = currentEntry["timestamp"] as! String
} else if currentEntry["created_at"] != nil {
bolusDate = currentEntry["created_at"] as! String
} else {
continue
}With "timestamp": null present on virtually every treatment document under the new uploader, this crashes the first time any bolus/carb/SMB/note graph data is loaded or refreshed.
Fix
Both sites are fixed by replacing != nil + as! with a safe-cast fallback chain, e.g.:
guard let bolusDate = currentEntry["timestamp"] as? String ?? currentEntry["created_at"] as? String else {
continue
}and as? instead of as! for the DeviceStatus.swift dictionary casts. A PR with this fix will be linked here shortly.
Other latent (not-yet-crashing) sites with the same pattern
Found via a grep for as! across the Nightscout parsing code; not confirmed as currently reachable with my data, but fragile given how pervasive explicit-null encoding is under the new uploader style, and worth a follow-up pass:
SAge.swift(lines ~46, ~65)CAge.swift(line ~42)IAge.swift(line ~47)DeviceStatusLoop.swift(lines ~68, ~111) — only reached for Loop-typedevice, not TrioDeviceStatusOpenAPS.swift:223— gated behind a key (recommendedTempBasal) Trio doesn't currently send
Suggested general guideline
Anywhere LoopFollow parses a Nightscout JSON document, dict["key"] != nil is not a safe guard for "field has a usable value" — it only tests for key presence, and an explicit JSON null (now common with full-schema uploaders like Nocturne) passes that check. The safe pattern is as? casting directly (which returns nil for both a missing key and an NSNull value), not a separate presence check followed by a force cast.
Activity
bjorkert commentedon Nov 1, 2026
Fixed by #797, merged into dev.