iOS: fix the three High OTA and profile-builder bugs (#141, #142, #143) - #252
Conversation
#141 — manual OTA upload was broken for any file outside the sandbox. The fileImporter callback opened the security scope, scheduled the upload in a detached Task, and returned; `defer` closed the scope at that point, so `Data(contentsOf:)` ran afterwards and failed with a permission error. Anything picked from iCloud Drive or Files hit this. Rather than stretching the scope across the task, the bytes are read inside the callback while the scope is held and `uploadOTA` now takes `Data`. The lifetime becomes impossible to get wrong instead of merely correct today. Both failure paths — denied scope, unreadable file — now report through viewModel.error rather than returning silently. #142 — the shared URLSession sets timeoutIntervalForResource = 30, but a 1.5-2MB image is accepted only as fast as the ESP32 erases and writes flash, and installOTA returns only once the device has pulled the image from GitHub. URLSession cancelled the task at the deadline, mid-transfer, leaving a partial OTA write behind a generic timeout message. OTA gets its own session (120s request, 600s resource) and `request()` takes an optional session override. Two sessions rather than one relaxed one: a control request that has not answered in 10s should still fail fast, not hang for ten minutes. #143 — a zero ramp rate crashed the app. `Int(Double.infinity)` is a fatal error in Swift, not a garbage value, so charting such a profile trapped in computeProfilePath, and estimatedDuration reached JSONEncoder as infinity and failed the save with an EncodingError naming neither the segment nor the field. FiringSegment.validationError is now the single statement of what a usable segment is, mirroring the web UI's bounds — a *magnitude* floor of 1 C/hr so cooling segments are held to the same limit, which is stricter than the firmware's reject-only-zero. It gates the save, renders under the offending field as it is typed, and both division sites skip segments that fail it rather than trapping. The step count is also bounded: 1 C/hr over the full range is ~69,600 five-minute steps. Two adjacent trap sites of the same kind are hardened while here: formattedDescription and maxTemp both fed non-finite Doubles to `Int(_:)`. Not verified by compilation: this container is Linux, so there is no Swift toolchain. CI's build-ios job is the only compile check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PyJ47o5sWvpb9vMk1RNKkb
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c976aca5e2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| was selected (#143). Rendering the rest of the profile is more use | ||
| than a crash, and the builder now refuses to save such a segment | ||
| in the first place. */ | ||
| if segment.validationError != nil { continue } |
There was a problem hiding this comment.
Preserve chartable legacy ramp segments
For profiles already stored with a finite, nonzero ramp magnitude below 1°C/hr, the firmware still accepts and fires the segment, but validationError causes the dashboard to omit it entirely. The new step limit makes these slow ramps safe to chart, so skipping them produces a planned path with the wrong temperature and timing—and subsequent segments start from the wrong point. Reserve this skip for genuinely non-computable values such as zero or non-finite rates rather than applying the stricter builder-save policy to profiles loaded from the controller.
Useful? React with 👍 / 👎.
| let steps = max(10, Int(rampTimeMinutes / 5)) | ||
| /* Bounded for the same reason the web path is: 1°C/hr is legal and | ||
| implies ~69,600 five-minute steps across a full-range firing. */ | ||
| let steps = min(maxProfilePathPoints, max(10, Int(rampTimeMinutes / 5))) |
There was a problem hiding this comment.
Budget chart points across the whole profile
For a multi-segment profile, every segment can independently append maxProfilePathPoints entries, so the firmware-supported 16-segment maximum can still produce over 32,000 freshly UUID-identified LineMarks whenever DashboardTab recomputes the path. This defeats the stated whole-path ceiling and can leave the Swift Charts view expensive or unresponsive for slow profiles; divide a fixed total budget among the segments, as the web path builder does, rather than applying the full allowance to each one.
Useful? React with 👍 / 👎.
| /// the short deadlines where they belong: a control request that has not | ||
| /// answered in 10s should fail fast, not hang for ten minutes. | ||
| let otaConfig = URLSessionConfiguration.default | ||
| otaConfig.timeoutIntervalForRequest = 120 |
There was a problem hiding this comment.
Apply the OTA timeout to update checks
When fetching the release manifest takes more than 10 seconds, checkOTA() still uses the short shared session and fails before the controller responds, so the normal UI never exposes the Install action. This is reachable with the firmware's default 15-second OTA transfer timeout—and that setting permits up to 120 seconds—because handle_ota_check synchronously waits for ota_check() (components/web_server/api_handlers.c:1142-1145, components/ota/Kconfig:21-24). Pass otaSession to checkOTA() as well as installOTA().
Useful? React with 👍 / 👎.
Three review findings, all correct. **The point cap was per segment, not per path.** `min(maxProfilePathPoints, …)` was applied inside the segment loop, so the firmware's 16-segment maximum could still produce ~32,000 freshly identified LineMarks on every recompute — the opposite of what the cap is for, and my own comment claimed a whole-path ceiling it did not implement. The budget is now divided across the computable segments. **Slow-but-legal segments were being dropped from the chart.** Skipping on `validationError` applied the builder's save policy to rendering, so a profile already stored with, say, 0.5C/hr — which the firmware accepts and will fire — lost that segment from the plot, and with it the correct starting temperature and time for every segment after it. A wrong path is worse than a slow one. Split in two: `isComputable` is the firmware's rule (finite, nonzero) and decides what can be charted; `validationError` stays the stricter save-time policy. Only zero and non-finite are genuinely uncomputable, and those are exactly the values that trapped in `Int(_:)`. The step cap makes the slow case affordable, which is what makes charting it reasonable. `estimatedDuration` moves to the same predicate, so a slow ramp contributes its real duration instead of vanishing from the estimate. `steps` gains a `max(1, …)` floor: with a shared budget the `min` can now reach zero, and `1...steps` traps on an empty range. **checkOTA needed the OTA session too.** `handle_ota_check` waits synchronously for `ota_check()` to fetch the manifest from GitHub, bounded by CONFIG_OTA_HTTP_TIMEOUT_MS — 15s by default, settable to 120s. Against the shared session's 10s request deadline the app gave up before the controller answered, so Install never appeared even with an update waiting. Still not compiled: no Swift toolchain on Linux; CI's build-ios job is the only compile check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PyJ47o5sWvpb9vMk1RNKkb
|
All three correct. Fixed in P2 — the cap was per segment, not per path. Right, and my own comment claimed a whole-path ceiling it didn't implement: P2 — slow-but-legal segments dropped from the chart. The best of the three, because it's a correctness bug I introduced while fixing a crash. Skipping on Split accordingly: That change also required a P2 — Still not compiled — no Swift toolchain here, so Generated by Claude Code |
Closes #141. Closes #142. Closes #143.
Grouped because #141 and #142 are both in the OTA path and touch the same two files; #143 rides along as the third High iOS bug. #147 is deliberately not here — see the bottom.
#141 · Manual OTA upload was broken for any file outside the sandbox
The
fileImportercallback opened the security scope, scheduled the upload in a detachedTask, and returned — anddeferclosed the scope at that point, soData(contentsOf:)ran afterwards and failed with a permission error. Anything picked from iCloud Drive or Files hit this, which is the normal case.Fixed by reading the bytes inside the callback, while the scope is held, and changing
uploadOTAto takeData. Stretching the scope across the task would also have worked, but passing bytes makes the lifetime impossible to get wrong rather than merely correct today. Both failure paths — scope denied, file unreadable — now report throughviewModel.errorinstead of returning silently.#142 · The 30s resource timeout cancelled uploads mid-flash
timeoutIntervalForResource = 30on the shared session, against a 1.5–2 MB image that the ESP32 accepts only as fast as it can erase and write flash.installOTAis worse: it returns only once the device has pulled the image from GitHub. URLSession cancelled the task at the deadline, mid-transfer, leaving a partial OTA write behind a generic timeout.OTA now has its own session (120 s request, 600 s resource), and
request()takes an optional session override soinstallOTAuses it too. Two sessions rather than one relaxed one — a control or status request that hasn't answered in 10 s should still fail fast, not hang for ten minutes.#143 · A zero ramp rate crashed the app
Int(Double.infinity)is a fatal error in Swift, not a garbage value. SocomputeProfilePathtrapped the moment such a profile was charted, andestimatedDurationreachedJSONEncoderas infinity and failed the save with anEncodingErrornaming neither the segment nor the field.FiringSegment.validationErroris now the single statement of what a usable segment is, mirroring the web UI's bounds:> 0would have rejected every cooling segment0.1 °C/hrpasses that and describes a 5800-hour firingtargetTempin 1…1400, finiteholdTimeIt gates the save, renders under the offending field as it is typed rather than two screens later, and both division sites skip segments that fail it instead of trapping. The step count is bounded too — 1 °C/hr across the full range is ~69,600 five-minute steps.
Two adjacent sites of the same kind hardened while here:
formattedDescriptionandmaxTempboth fed potentially non-finite Doubles toInt(_:).Verification — please read
This was not compiled. The session runs in a Linux container with no Swift toolchain, so CI's macOS
build-iosjob is the only compile check on this diff. I checked what I could statically (call sites of both changed signatures, brace balance, actor isolation of theviewModel.errorwrites against the existingconnection.apiClientaccess in the same closure), but that is not a substitute. The three fixes are also behavioural in ways only a device shows: a Files-picked upload, an upload that outlives 30 s, and a profile charted with a bad rate.Noticed, not fixed
Int(profile.maxTemp)inProfileCardViewandProfileDetailViewwould trap the same way, but only on a non-finite value arriving from the API — the firmware computes that field, so it isn't reachable without a broken server. Left alone rather than widening this diff into three more view files.Generated by Claude Code