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
21 changes: 21 additions & 0 deletions Strand/BLE/LiveState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,27 @@ public final class LiveState: ObservableObject {
/// a streaming strap fills it in about 50 minutes, so exports read the whole log from disk (`archive`);
/// this buffer drives the Live log card and the Test Centre readouts. Each line is a short redacted string
/// (~100 bytes), so the worst-case buffer is well under ~1 MB — bounded, never unbounded.
/// The tail of `log` that the Live screen's card RENDERS: its last `tailLines` lines.
///
/// Returns a SLICE, not a range, and that is the load-bearing part. The card draws it in a `LazyVStack`,
/// whose row closures can run in a later main-actor turn than the body that produced them (on scroll,
/// with no re-evaluation). A range plus `log[idx]` would then read a buffer that `append(log:)` may have
/// trimmed in between and crash out of bounds. A slice is a copy-on-write value snapshot, so the rows it
/// hands out stay valid however the live buffer moves.
///
/// Its `indices` are ABSOLUTE positions in `log`, which is what makes them usable as identity: between
/// trims an append shifts only the window edges, so every shared row keeps its id, and
/// `scrollTo(log.indices.last)` always addresses a row that is actually rendered, which a LEADING window
/// would not. Empty log or a non-positive tail yields an empty slice. (#2521)
///
/// `nonisolated` because it is pure over its arguments and touches nothing on the actor, the same way
/// `redactPii` and `logSafeDeviceName` below are. Without it the method inherits `LiveState`'s
/// `@MainActor` and a plain `XCTestCase` cannot call it at all.
nonisolated static func renderedTail(_ log: [String], tailLines: Int) -> ArraySlice<String> {
guard tailLines > 0 else { return log[log.endIndex..<log.endIndex] }
return log.suffix(tailLines)
}

static let maxLogLines = 5_000

/// Amortize the ring trim: let the buffer overrun by this slack, then trim back to the cap in one batch
Expand Down
90 changes: 70 additions & 20 deletions Strand/Screens/LiveView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1245,11 +1245,37 @@ private struct ActiveWorkoutLive: View {
}
}

/// The strap log + export controls + Test Centre link. Owns LiveState so the streaming log lines
/// re-render only this card. Wrapped in the liquid frosted card style.
/// The strap log + export controls + Test Centre link. Wrapped in the liquid frosted card style.
///
/// This observes `LiveState` rather than scoping anything: a published change there invalidates every
/// observer, so what this card controls is the COST of its own re-evaluation, not whether it happens.
private struct LiveLogCard: View {
/// How many trailing lines the card RENDERS. The buffer stays `LiveState.maxLogLines` (5,000) and Copy /
/// Save / the export still read all of it, so nothing is lost by drawing less.
///
/// #2521: this card used to render the whole buffer in a non-lazy `VStack`, so every appended line built
/// and diffed up to 5,000 rows while the visible viewport is 200pt, about fifteen lines. A history drain
/// plus a re-score burst emits hundreds of lines a minute, and the 80%-of-a-core CPU limit is per
/// process, so that cost landed on the main thread and the app was killed with `cpu_resource_fatal`
/// while it was not even frontmost.
private static let renderedTailLines = 200

@EnvironmentObject private var live: LiveState
@EnvironmentObject private var model: AppModel
/// Backgrounded, nothing is on screen to keep current, and a `LiveState` publish still re-evaluates this
/// body because an `ObservableObject` invalidates every observer on ANY published change, not only the
/// property a view reads. So what is cut is the COST of that re-evaluation, which is what the CPU limit
/// measures: roughly fifteen built rows instead of five thousand, and none at all here.
///
/// Not free, to be exact about what remains: the header, the `Divider` and the `NavigationLink` are still
/// built per line, and `NavigationLink(destination:)` constructs `TestCentreView()` eagerly, which
/// evaluates its `@State` defaults (three `UserDefaults` reads). Microseconds against the five thousand
/// `Text` views this removes, but the place to look first if a background cost survives this.
///
/// Gated on `.background` rather than `!= .active` deliberately: `.inactive` is also when iOS takes the
/// app-switcher snapshot, and blanking the log there would be visible for no benefit. The kill needs
/// sustained non-frontmost CPU, which is `.background`.
@Environment(\.scenePhase) private var scenePhase
@AppStorage(CardAppearancePrefs.opacityKey) private var cardOpacityPercent = CardAppearancePrefs.defaultPercent
private var cardOpacity: Double { max(0, min(1, Double(cardOpacityPercent) / 100)) }

Expand All @@ -1266,24 +1292,11 @@ private struct LiveLogCard: View {
Button("Save…") { saveStrapLog() }
.buttonStyle(.plain).font(StrandFont.mono).foregroundStyle(StrandPalette.accent)
}
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(live.log.enumerated()), id: \.offset) { idx, line in
Text(line).font(StrandFont.mono)
.foregroundStyle(StrandPalette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.id(idx)
}
}
}
#if os(iOS)
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
#endif
.frame(height: 200)
.onChangeCompat(of: live.log.count) { _ in
if let last = live.log.indices.last { proxy.scrollTo(last, anchor: .bottom) }
}
if scenePhase == .background {
// Same height so returning to the app does not shift the card's layout.
Color.clear.frame(height: 200)
} else {
logScroller
}

// Users look on Live first when something's wrong (#507/#509), so link straight into the
Expand All @@ -1307,6 +1320,43 @@ private struct LiveLogCard: View {
.background(NoopPanelSurface(cornerRadius: 22, surfaceOpacity: cardOpacity))
}

/// The tail of the log, lazily.
///
/// The slice comes from `LiveState.renderedTail`, which snapshots the buffer so a lazily-realized row
/// cannot index a trimmed array. Its indices are ABSOLUTE positions in `live.log`, which is what makes
/// them usable as identity: BETWEEN trims, appending a line leaves every other row's id alone, so SwiftUI adds one row
/// and drops one instead of re-identifying the list. A trim still renumbers, because `Array.removeFirst`
/// shifts every element down, but that is once per `LiveState.trimSlack` lines and now touches 200 ids
/// rather than rebuilding 5,000 rows.
///
/// The old `id: \.offset` over `Array(live.log.enumerated())` paid on every line instead: a fresh
/// 5,000-element array allocated per body evaluation, and a non-lazy `VStack` that built every row even
/// though the 200pt viewport shows about fifteen.
///
/// `scrollTo` still addresses the true last index, which is always inside the tail.
private var logScroller: some View {
let tail = LiveState.renderedTail(live.log, tailLines: Self.renderedTailLines)
return ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(tail.indices, id: \.self) { idx in
Text(tail[idx]).font(StrandFont.mono)
.foregroundStyle(StrandPalette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.id(idx)
}
}
}
#if os(iOS)
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
#endif
.frame(height: 200)
.onChangeCompat(of: live.log.count) { _ in
if let last = live.log.indices.last { proxy.scrollTo(last, anchor: .bottom) }
}
}
}

// MARK: - Strap-log export (issue #17 — let macOS users share the log for bug reports)

// The strap-log text builder lives on LiveState (`exportableLogText()`) so the macOS Settings
Expand Down
82 changes: 82 additions & 0 deletions StrandTests/LiveLogTailTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import XCTest
@testable import Strand

/// What the Live card renders, and why it is a SLICE of absolute indices.
///
/// #2521: the card rendered the whole 5,000-line buffer in a non-lazy `VStack`, so every appended line built
/// and diffed every row while the viewport shows about fifteen. A history drain plus a re-score burst emits
/// hundreds of lines a minute, and iOS killed the app with `cpu_resource_fatal` for 85% of a core over 57s
/// while it was not frontmost. Reported by @pipiche38 with a symbolicated microstackshot whose heaviest
/// stack was the main run loop inside SwiftUI.
final class LiveLogTailTests: XCTestCase {

private let tail = 200
private func log(_ n: Int) -> [String] { (0..<n).map { "line \($0)" } }

func testAnEmptyLogRendersNothing() {
XCTAssertTrue(LiveState.renderedTail(log(0), tailLines: tail).isEmpty)
}

func testALogShorterThanTheTailRendersAllOfIt() {
let t = LiveState.renderedTail(log(7), tailLines: tail)
XCTAssertEqual(t.indices, 0..<7)
XCTAssertEqual(Array(t), log(7))
}

func testALogLongerThanTheTailRendersExactlyTheLastLines() {
let t = LiveState.renderedTail(log(5_000), tailLines: tail)
XCTAssertEqual(t.indices, 4_800..<5_000)
XCTAssertEqual(t.count, tail)
XCTAssertEqual(t.first, "line 4800")
XCTAssertEqual(t.last, "line 4999")
}

/// The load-bearing invariant: `onChangeCompat` scrolls to `log.indices.last`, so that index MUST be one
/// the card actually rendered. A LEADING window would satisfy every count assertion above and still
/// scroll to a row that was never built.
func testTheScrollTargetIsAlwaysInsideTheRenderedTail() {
for count in [1, 2, 199, 200, 201, 5_000, 5_256] {
let t = LiveState.renderedTail(log(count), tailLines: tail)
XCTAssertTrue(t.indices.contains(count - 1),
"scrollTo(\(count - 1)) must be rendered, indices were \(t.indices) for \(count)")
}
}

/// Absolute indices mean an append shifts only the window edges, so every shared row keeps its identity.
/// That is what turns a full re-identification into one row in and one out.
func testAnAppendKeepsEverySharedRowsIdentity() {
let before = LiveState.renderedTail(log(1_000), tailLines: tail).indices
let after = LiveState.renderedTail(log(1_001), tailLines: tail).indices
XCTAssertEqual(before, 800..<1_000)
XCTAssertEqual(after, 801..<1_001)
XCTAssertEqual(Set(before).intersection(Set(after)).count, tail - 1,
"only one row should leave and one arrive")
}

/// The reason this returns a slice rather than a range.
///
/// The card draws the tail in a `LazyVStack`, whose row closures can run after the body that produced
/// them. If the tail were a range indexed back into the live buffer, a trim landing in between would make
/// a realized row read out of bounds. A slice is a value snapshot, so it survives the buffer moving.
func testTheTailIsASnapshotAndSurvivesTheBufferBeingTrimmed() {
var buffer = log(5_256)
let t = LiveState.renderedTail(buffer, tailLines: tail)
let expectedFirst = t.first
let expectedLast = t.last

buffer.removeFirst(256) // exactly what append(log:) does at the trim slack
buffer = log(3) // and something far more violent

XCTAssertEqual(t.count, tail, "the snapshot must not shrink with the buffer")
XCTAssertEqual(t.first, expectedFirst)
XCTAssertEqual(t.last, expectedLast)
// Every index the card would hand to SwiftUI is still readable.
for idx in t.indices { XCTAssertFalse(t[idx].isEmpty) }
}

/// Defensive: a non-positive tail renders nothing rather than trapping on a negative count.
func testANonPositiveTailRendersNothing() {
XCTAssertTrue(LiveState.renderedTail(log(500), tailLines: 0).isEmpty)
XCTAssertTrue(LiveState.renderedTail(log(500), tailLines: -5).isEmpty)
}
}
Loading