Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Strand/Data/StressLoadCancellation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation

/// A cancelled screen load must not publish a result from a read or detached calculation that kept
/// running. Check both sides of the suspension so obsolete loads also stop before their next read.
/// Kotlin twin: `com.noop.ui.StressLoadCancellation`.
@MainActor
enum StressLoadCancellation {
static func read<Value>(_ work: () async -> Value) async -> Value? {
guard !Task.isCancelled else { return nil }
let value = await work()
guard !Task.isCancelled else { return nil }
return value
}
}
55 changes: 36 additions & 19 deletions Strand/Screens/StressView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ struct StressView: View {
}

private func load() async {
storedSeries = await repo.series(key: "stress", source: "my-whoop")
guard let series = await StressLoadCancellation.read({
await repo.series(key: "stress", source: "my-whoop")
}) else { return }
storedSeries = series
loaded = true
rebuildModelIfNeeded()
await loadDaytime()
Expand All @@ -112,7 +115,9 @@ struct StressView: View {
let to = Int(Date().timeIntervalSince1970)
let tz = TimeZone.current.secondsFromGMT(for: Date())

let hr = await repo.hrSamples(from: from, to: to, limit: 200_000)
guard let hr = await StressLoadCancellation.read({
await repo.hrSamples(from: from, to: to, limit: 200_000)
}) else { return }
// Too few HR samples: empty the timeline AND clear the advanced readouts in lockstep. Without this
// reset a later refresh that hits this path would leave the Advanced HRV card showing stale values
// next to an empty timeline (the readouts are only recomputed past this guard).
Expand All @@ -122,11 +127,15 @@ struct StressView: View {
freqHRV = nil
return
}
let rr = await repo.rrIntervals(from: from, to: to, limit: 200_000)
guard let rr = await StressLoadCancellation.read({
await repo.rrIntervals(from: from, to: to, limit: 200_000)
}) else { return }
// Wrist accelerometer for the motion gate: an ambulatory hour is EXERTION, not stress, so it
// is masked rather than scored (DaytimeStress). Same store read as R-R; empty on hardware or
// imports with no gravity, which is exactly the "no masking, prior behaviour" degradation.
let gravity = await repo.gravitySamplesUnion(from: from, to: to, limit: 200_000)
guard let gravity = await StressLoadCancellation.read({
await repo.gravitySamplesUnion(from: from, to: to, limit: 200_000)
}) else { return }

// Score today's hours against the PERSONAL cross-day daytime baseline ONLY when the user has
// opted in (Settings → Experimental) AND enough worn history exists (Oura-style
Expand All @@ -135,14 +144,14 @@ struct StressView: View {
// chooseable lens, not a silent default. The mode is resolved only AFTER the HR-count guard above,
// so the trailing-history reads are never paid on a day with no scorable timeline — and are never
// paid at all while the toggle is OFF (the default), keeping the read byte-identical to before.
let mode = await DaytimeStressMode.selected(
repo: repo,
startOfToday: startOfDay,
calendar: cal,
personalBaseline: PuffinExperiment.stressPersonalBaselineEnabled
)
if case .baselineRelative = mode { daytimeUsesPersonalBaseline = true }
else { daytimeUsesPersonalBaseline = false }
guard let mode = await StressLoadCancellation.read({
await DaytimeStressMode.selected(
repo: repo,
startOfToday: startOfDay,
calendar: cal,
personalBaseline: PuffinExperiment.stressPersonalBaselineEnabled
)
}) else { return }
// includeTimeline: the SLIDING read, so the screen's line moves in half-hours instead of
// stepping through whole clock hours (#2144). The scored unit is still a full hour; this only
// decides how often that hour is re-read, so a thin ten minutes costs the windows that overlap
Expand All @@ -159,10 +168,16 @@ struct StressView: View {
// detached task is decorative and the work races the UI for cores anyway. StressDayCurve
// learned that on this same issue; the continuation in UnescalatedWork is what keeps the
// priority honest.
daytime = await runUnescalated(priority: .userInitiated) {
DaytimeStress.analyze(hr: hr, rr: rr, gravity: gravity, tzOffsetSeconds: tz, mode: mode,
includeTimeline: true)
}
guard let timeline = await StressLoadCancellation.read({
await runUnescalated(priority: .userInitiated) {
DaytimeStress.analyze(hr: hr, rr: rr, gravity: gravity, tzOffsetSeconds: tz, mode: mode,
includeTimeline: true)
}
}) else { return }
daytime = timeline
// Publish the mode with the timeline it describes, after the same cancellation check.
if case .baselineRelative = mode { daytimeUsesPersonalBaseline = true }
else { daytimeUsesPersonalBaseline = false }

// ADDITIVE advanced readouts, computed on-demand from the SAME `rr` (no extra fetch, no
// DB / schema change, and no effect on the 0..3 score above). Each engine returns nil when
Expand All @@ -177,9 +192,11 @@ struct StressView: View {
// main actor and publish when done; their card is hidden until then, exactly as it is when a gate
// is unmet. Same `runUnescalated` reasoning as above, and the default `.utility` is real here
// because nothing escalates it: this is the phase that must yield to the UI.
let advanced = await runUnescalated {
(index: StressIndex.components(rr: rr), freq: HRVFreqDomain.freqDomain(rr: rr))
}
guard let advanced = await StressLoadCancellation.read({
await runUnescalated {
(index: StressIndex.components(rr: rr), freq: HRVFreqDomain.freqDomain(rr: rr))
}
}) else { return }
stressIndex = advanced.index
freqHRV = advanced.freq
}
Expand Down
116 changes: 116 additions & 0 deletions StrandTests/StressLoadCancellationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import XCTest
@testable import Strand

@MainActor
final class StressLoadCancellationTests: XCTestCase {
func testSuccessfulReadAndTwoPhaseOrderStayIntact() async {
var phases: [Int] = []
if let core = await StressLoadCancellation.read({ 21 }) { phases.append(core) }
if let lenses = await StressLoadCancellation.read({ 34 }) { phases.append(lenses) }
XCTAssertEqual(phases, [21, 34])
}

func testSuccessfulAbsentValueIsNotCancellation() async {
let result: Int?? = await StressLoadCancellation.read({ nil as Int? })
XCTAssertEqual(result, .some(.none))
}

func testAlreadyCancelledLoadDoesNotStartARead() async {
var reads = 0
let task = Task { @MainActor in
await StressLoadCancellation.read {
reads += 1
return 7
}
}
task.cancel()
let result = await task.value
XCTAssertNil(result)
XCTAssertEqual(reads, 0)
}

func testLateCancelledReadCannotReplaceFreshState() async {
let gate = StressFixtureSuspension()
var published: [String] = []
let old = Task { @MainActor in
if let value = await StressLoadCancellation.read({
await gate.holdStressFixture()
return "old"
}) { published.append(value) }
}
await gate.waitForStressFixture()
old.cancel()
if let fresh = await StressLoadCancellation.read({ "fresh" }) { published.append(fresh) }
await gate.releaseStressFixture()
await old.value
XCTAssertEqual(published, ["fresh"])
}

func testCancelledCoreDoesNotStartAdvancedWork() async {
let gate = StressFixtureSuspension()
var advancedReads = 0
var published: [Int] = []
let task = Task { @MainActor in
guard let core = await StressLoadCancellation.read({
await gate.holdStressFixture()
return 1
}) else { return }
published.append(core)
if let lenses = await StressLoadCancellation.read({
advancedReads += 1
return 2
}) { published.append(lenses) }
}
await gate.waitForStressFixture()
task.cancel()
await gate.releaseStressFixture()
await task.value
XCTAssertEqual(published, [])
XCTAssertEqual(advancedReads, 0)
}

func testLateAdvancedReadCannotRestoreOldLenses() async {
let gate = StressFixtureSuspension()
var published: [String] = []
let old = Task { @MainActor in
guard let core = await StressLoadCancellation.read({ "old-core" }) else { return }
published.append(core)
if let lenses = await StressLoadCancellation.read({
await gate.holdStressFixture()
return "old-lenses"
}) { published.append(lenses) }
}
await gate.waitForStressFixture()
old.cancel()
if let fresh = await StressLoadCancellation.read({ "fresh-core" }) { published.append(fresh) }
await gate.releaseStressFixture()
await old.value
XCTAssertEqual(published, ["old-core", "fresh-core"])
}
}

/// Deliberately ignores task cancellation, like a detached calculation or a completed database read.
private actor StressFixtureSuspension {
private var held: CheckedContinuation<Void, Never>?
private var waiting: CheckedContinuation<Void, Never>?
private var started = false

func holdStressFixture() async {
await withCheckedContinuation { continuation in
held = continuation
started = true
waiting?.resume()
waiting = nil
}
}

func waitForStressFixture() async {
if started { return }
await withCheckedContinuation { waiting = $0 }
}

func releaseStressFixture() {
held?.resume()
held = nil
}
}
9 changes: 8 additions & 1 deletion StrandTests/StressPersonalBaselineSurfaceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@ final class StressPersonalBaselineSurfaceTests: XCTestCase {
let today = try source("Strand/Screens/TodayView.swift")
let liquidToday = try source("Strand/Liquid/LiquidTodayView.swift")

XCTAssertTrue(detail.contains("let mode = await DaytimeStressMode.selected("))
XCTAssertNotNil(detail.range(
of: #"DaytimeStressMode\.selected\([\s\S]*?personalBaseline:\s*PuffinExperiment\.stressPersonalBaselineEnabled"#,
options: .regularExpression
))
XCTAssertNotNil(detail.range(
of: #"DaytimeStress\.analyze\([\s\S]*?mode:\s*mode,"#,
options: .regularExpression
))
XCTAssertTrue(producer.contains("let mode = await DaytimeStressMode.selected("))
XCTAssertTrue(producer.contains("tzOffsetSeconds: tz, mode: mode,"))
for body in [today, liquidToday] {
Expand Down
24 changes: 24 additions & 0 deletions android/app/src/main/java/com/noop/ui/StressLoadCancellation.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.noop.ui

import kotlin.coroutines.coroutineContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ensureActive

/**
* Keep failed-read fallback behaviour, but never turn cancellation into a screen update. Check both
* sides of the suspension because CPU work and some reads may finish after their caller is cancelled.
* Swift twin: StressLoadCancellation.
*/
internal object StressLoadCancellation {
suspend fun <Value> read(work: suspend () -> Value): Value? {
coroutineContext.ensureActive()
val value = try {
work()
} catch (error: Throwable) {
if (error is CancellationException) throw error
null
}
coroutineContext.ensureActive()
return value
}
}
12 changes: 6 additions & 6 deletions android/app/src/main/java/com/noop/ui/StressScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,9 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) {
var stored by remember { mutableStateOf<Map<String, Double>>(emptyMap()) }
var storedLoaded by remember { mutableStateOf(false) }
androidx.compose.runtime.LaunchedEffect(Unit) {
val rows = runCatching {
val rows = StressLoadCancellation.read {
vm.repo.metricSeries("my-whoop", "stress", "0000-01-01", "9999-12-31")
}.getOrDefault(emptyList())
}.orEmpty()
stored = rows.associate { it.day to it.value.coerceIn(0.0, 3.0) }
storedLoaded = true
}
Expand Down Expand Up @@ -151,9 +151,9 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) {
while (true) {
val nowSeconds = System.currentTimeMillis() / 1000L
val window = stressLocalDayWindowContaining(nowSeconds, ZoneId.systemDefault())
val fingerprint = runCatching {
val fingerprint = StressLoadCancellation.read {
vm.repo.hrFingerprintWindow(vm.activeStrapId, window.fromEpochSecond, nowSeconds)
}.getOrNull()
}
// A failed fingerprint reads as "cannot tell", which loads rather than skips: being
// wrong about this costs one pass, being wrong the other way freezes the screen.
if (fingerprint == null || fingerprint != lastHrFingerprint) {
Expand All @@ -169,11 +169,11 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) {
daytime = core?.daytime ?: DaytimeStress.Result.EMPTY
daytimeUsesPersonalBaseline = core?.usesPersonalBaseline == true
val beats = core?.rr.orEmpty()
val lenses = if (beats.isEmpty()) null else runCatching {
val lenses = if (beats.isEmpty()) null else StressLoadCancellation.read {
withContext(Dispatchers.Default) {
StressIndex.components(beats) to HrvFreqDomain.freqDomain(beats)
}
}.getOrNull()
}
stressIndex = lenses?.first
freqHrv = lenses?.second
}
Expand Down
Loading
Loading