From de8d1b91b4cc073df2b62df06df2edce375bf1b2 Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Tue, 13 Oct 2026 17:05:42 +0530 Subject: [PATCH 1/3] feat(perf): Add signposts and isolated measurement harness OSSignposter intervals wrap launch, synchronize, player build, pause and resume. scripts/perf/measure.sh packages a release app, launches it with a throwaway HOME, and records top plus footprint JSON. Playing and paused modes assign a generated fixture through existing ConfigStore assign. No config schema or spaceKey change. Finder duplicate copies are gitignored. --- .gitignore | 11 + Sources/Lively/Core/WallpaperController.swift | 46 ++-- Sources/Lively/Logic/LivelyLogger.swift | 1 + Sources/Lively/Logic/PerfHarness.swift | 90 +++++++ Sources/Lively/Logic/PerfSignposts.swift | 27 +++ Sources/LivelyApp/main.swift | 35 ++- Tests/LivelyTests/PerfHarnessTests.swift | 43 ++++ Tests/LivelyTests/PerfSignpostsTests.swift | 34 +++ perf/fixtures/.gitkeep | 1 + scripts/check_hygiene.sh | 5 +- scripts/perf/README.md | 61 +++++ scripts/perf/generate-fixtures.sh | 45 ++++ scripts/perf/measure.sh | 222 ++++++++++++++++++ 13 files changed, 591 insertions(+), 30 deletions(-) create mode 100644 Sources/Lively/Logic/PerfHarness.swift create mode 100644 Sources/Lively/Logic/PerfSignposts.swift create mode 100644 Tests/LivelyTests/PerfHarnessTests.swift create mode 100644 Tests/LivelyTests/PerfSignpostsTests.swift create mode 100644 perf/fixtures/.gitkeep create mode 100644 scripts/perf/README.md create mode 100755 scripts/perf/generate-fixtures.sh create mode 100755 scripts/perf/measure.sh diff --git a/.gitignore b/.gitignore index 21ae049..688c666 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ +# Finder/iCloud duplicate copies ("name 2.ext") +* 2.md +* 2.png + +# Generated performance fixtures and measurement output +perf/results/ +perf/fixtures/*.mp4 +perf/fixtures/*.mov +perf/fixtures/*.m4v +perf/fixtures/*.hevc + .DS_Store .claude/worktrees/ /.build diff --git a/Sources/Lively/Core/WallpaperController.swift b/Sources/Lively/Core/WallpaperController.swift index 197a7a7..da3e0c0 100644 --- a/Sources/Lively/Core/WallpaperController.swift +++ b/Sources/Lively/Core/WallpaperController.swift @@ -83,7 +83,9 @@ private final class WallpaperSession { currentURL = url failedURL = nil - loadVideo(url: url, muted: wallpaper.isMuted, volume: wallpaper.volume, onError: onError) + PerfSignposts.interval(PerfSignposts.playerBuild) { + loadVideo(url: url, muted: wallpaper.isMuted, volume: wallpaper.volume, onError: onError) + } } private func loadVideo(url: URL, muted: Bool, volume: Float, onError: @escaping @Sendable (Error) -> Void) { @@ -215,13 +217,17 @@ private final class WallpaperSession { } func pause() { - wantsPlayback = false - updatePlaybackRate() + PerfSignposts.interval(PerfSignposts.pause) { + wantsPlayback = false + updatePlaybackRate() + } } func resume() { - wantsPlayback = true - updatePlaybackRate() + PerfSignposts.interval(PerfSignposts.resume) { + wantsPlayback = true + updatePlaybackRate() + } } private func occlusionDidChange() { @@ -494,7 +500,9 @@ public final class WallpaperController: ObservableObject { } else { // Re-sync rather than just resuming, so any config or space changes // that arrived while paused are picked up immediately. - synchronize(to: spaceMonitor.screenSpaces) + PerfSignposts.interval(PerfSignposts.resume) { + synchronize(to: spaceMonitor.screenSpaces) + } } } @@ -785,19 +793,21 @@ public final class WallpaperController: ObservableObject { private func synchronize(to spaces: [ScreenSpace]) { // NSApp is nil in unit test contexts — can't create windows without a running application guard let app = NSApp else { return } - let appearance = app.effectiveAppearance - sessionManager.synchronize( - to: spaces, - appearance: appearance, - configStore: configStore, - bookmarkManager: bookmarkManager, - isPaused: shouldHaltPlayback, - onPlaybackError: { [weak self] spaceKey, message in - Task { @MainActor in - self?.playbackErrors.send((spaceKey, message)) + PerfSignposts.interval(PerfSignposts.synchronize) { + let appearance = app.effectiveAppearance + sessionManager.synchronize( + to: spaces, + appearance: appearance, + configStore: configStore, + bookmarkManager: bookmarkManager, + isPaused: shouldHaltPlayback, + onPlaybackError: { [weak self] spaceKey, message in + Task { @MainActor in + self?.playbackErrors.send((spaceKey, message)) + } } - } - ) + ) + } } } diff --git a/Sources/Lively/Logic/LivelyLogger.swift b/Sources/Lively/Logic/LivelyLogger.swift index 0840a1c..9eac82e 100644 --- a/Sources/Lively/Logic/LivelyLogger.swift +++ b/Sources/Lively/Logic/LivelyLogger.swift @@ -116,4 +116,5 @@ public enum LivelyLogger { public static let videoPreview = LivelyCategoryLogger(category: "VideoPlayerView") public static let spaceMonitor = LivelyCategoryLogger(category: "SpaceMonitor") public static let updater = LivelyCategoryLogger(category: "Updater") + public static let perf = LivelyCategoryLogger(category: "Performance") } diff --git a/Sources/Lively/Logic/PerfHarness.swift b/Sources/Lively/Logic/PerfHarness.swift new file mode 100644 index 0000000..07b93ed --- /dev/null +++ b/Sources/Lively/Logic/PerfHarness.swift @@ -0,0 +1,90 @@ +import Foundation + +/// Isolated launch path for `scripts/perf/measure.sh`. +/// +/// Reads `LIVELY_PERF_MODE` and `LIVELY_PERF_FIXTURE`. Does not change the +/// config schema or spaceKey format. Measure with a throwaway `HOME` so the +/// operator's real Application Support is never touched. +public enum PerfHarness { + public enum Mode: String, Equatable, Sendable { + case off + case idle + case playing + case paused + } + + public struct Request: Equatable, Sendable { + public var mode: Mode + public var fixtureURL: URL? + + public static func parse( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Request { + let raw = environment["LIVELY_PERF_MODE"]?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let raw, let mode = Mode(rawValue: raw), mode != .off else { + return Request(mode: .off, fixtureURL: nil) + } + let fixture = environment["LIVELY_PERF_FIXTURE"] + .flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0) } + return Request(mode: mode, fixtureURL: fixture) + } + } + + public static func isValidRequest(_ request: Request) -> Bool { + switch request.mode { + case .off, .idle: + return true + case .playing, .paused: + guard let url = request.fixtureURL else { return false } + return isValidLivelyVideoFile(url) + } + } + + /// Assigns the fixture to every currently visible Space via the existing + /// `ConfigStore.assign` API. Paused mode waits one run-loop turn plus a + /// short grace so the player is built before `togglePause`. + @MainActor + public static func apply( + _ request: Request, + spaceMonitor: SpaceMonitor, + configStore: ConfigStore, + wallpaperController: WallpaperController + ) { + guard request.mode != .off else { return } + guard isValidRequest(request) else { + LivelyLogger.perf.error("Perf harness: playing/paused requires a supported LIVELY_PERF_FIXTURE") + return + } + + spaceMonitor.refresh() + + if request.mode == .idle { + LivelyLogger.perf.info("Perf harness: idle (no wallpaper assigned)") + return + } + + guard let url = request.fixtureURL else { return } + + var wallpaper = DynamicWallpaper() + wallpaper.mode = .staticVideo + wallpaper.staticURL = url + wallpaper.isMuted = true + wallpaper.volume = 0 + + let spaces = spaceMonitor.screenSpaces + for space in spaces { + configStore.assign(dynamicWallpaper: wallpaper, toSpaceKey: space.spaceKey) + } + LivelyLogger.perf.info("Perf harness: assigned fixture to \(spaces.count) space(s)") + + if request.mode == .paused { + // Config sink refreshes asynchronously; pause after the player is up + // so the paused footprint includes a resident AVPlayer (hypothesis B). + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if !wallpaperController.isPaused { + wallpaperController.togglePause() + } + } + } + } +} diff --git a/Sources/Lively/Logic/PerfSignposts.swift b/Sources/Lively/Logic/PerfSignposts.swift new file mode 100644 index 0000000..e1cc822 --- /dev/null +++ b/Sources/Lively/Logic/PerfSignposts.swift @@ -0,0 +1,27 @@ +import os + +/// `os_signpost` intervals for Instruments. Cheap when a recording is not +/// attached: begin/end become no-ops at the tracing layer. +/// +/// Names match `docs/PERFORMANCE_MASTER_PLAN.md` P0: launch, synchronize, +/// playerBuild, pause, resume. +public enum PerfSignposts { + public static let subsystem = "com.lively.app" + public static let category = "Performance" + + public static let launch: StaticString = "launch" + public static let synchronize: StaticString = "synchronize" + public static let playerBuild: StaticString = "playerBuild" + public static let pause: StaticString = "pause" + public static let resume: StaticString = "resume" + + public static let signposter = OSSignposter(subsystem: subsystem, category: category) + + /// Runs `body` inside a matching begin/end interval. + @discardableResult + public static func interval(_ name: StaticString, _ body: () throws -> T) rethrows -> T { + let state = signposter.beginInterval(name) + defer { signposter.endInterval(name, state) } + return try body() + } +} diff --git a/Sources/LivelyApp/main.swift b/Sources/LivelyApp/main.swift index 5943c57..25f90b4 100644 --- a/Sources/LivelyApp/main.swift +++ b/Sources/LivelyApp/main.swift @@ -27,17 +27,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private let runMarker = RunMarker.default func applicationDidFinishLaunching(_ notification: Notification) { - if runMarker.begin() { - LogStore.shared.previousRunEndedUncleanly = true - LivelyLogger.config.error("Previous run ended unexpectedly (crash, force quit or power loss)") + PerfSignposts.interval(PerfSignposts.launch) { + if runMarker.begin() { + LogStore.shared.previousRunEndedUncleanly = true + LivelyLogger.config.error("Previous run ended unexpectedly (crash, force quit or power loss)") + } + // Background-only process — no Dock icon + NSApp.setActivationPolicy(.accessory) + + // Warm up the controller (triggers initial wallpaper load for all screens) + _ = wallpaperController + + setupMenuBar() + + let request = PerfHarness.Request.parse() + if request.mode != .off { + PerfHarness.apply( + request, + spaceMonitor: spaceMonitor, + configStore: configStore, + wallpaperController: wallpaperController + ) + } } - // Background-only process — no Dock icon - NSApp.setActivationPolicy(.accessory) - - // Warm up the controller (triggers initial wallpaper load for all screens) - _ = wallpaperController - - setupMenuBar() } func applicationWillTerminate(_ notification: Notification) { @@ -84,7 +96,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // Apply saved appearance (Light / Dark / System) before first paint. AppPreferences.applyAppAppearance(AppPreferences.shared.appearance) - if AppPreferences.shared.checkForUpdates { + let perfMode = PerfHarness.Request.parse().mode + if AppPreferences.shared.checkForUpdates && perfMode == .off { Task { await UpdateChecker.shared.checkIfEnabled() } } } diff --git a/Tests/LivelyTests/PerfHarnessTests.swift b/Tests/LivelyTests/PerfHarnessTests.swift new file mode 100644 index 0000000..97c47fb --- /dev/null +++ b/Tests/LivelyTests/PerfHarnessTests.swift @@ -0,0 +1,43 @@ +import Testing +import Foundation +@testable import LivelyCore + +struct PerfHarnessTests { + @Test func parseMissingEnvIsOff() { + let request = PerfHarness.Request.parse(environment: [:]) + #expect(request.mode == .off) + #expect(request.fixtureURL == nil) + #expect(PerfHarness.isValidRequest(request)) + } + + @Test func parseUnknownModeIsOff() { + let request = PerfHarness.Request.parse(environment: ["LIVELY_PERF_MODE": "turbo"]) + #expect(request.mode == .off) + } + + @Test func parseIdle() { + let request = PerfHarness.Request.parse(environment: ["LIVELY_PERF_MODE": "idle"]) + #expect(request.mode == .idle) + #expect(PerfHarness.isValidRequest(request)) + } + + @Test func playingRequiresSupportedFixture() { + let missing = PerfHarness.Request.parse(environment: ["LIVELY_PERF_MODE": "playing"]) + #expect(missing.mode == .playing) + #expect(!PerfHarness.isValidRequest(missing)) + + let badExt = PerfHarness.Request.parse(environment: [ + "LIVELY_PERF_MODE": "playing", + "LIVELY_PERF_FIXTURE": "/tmp/clip.avi" + ]) + #expect(!PerfHarness.isValidRequest(badExt)) + + let ok = PerfHarness.Request.parse(environment: [ + "LIVELY_PERF_MODE": "PAUSED", + "LIVELY_PERF_FIXTURE": "/tmp/clip.mp4" + ]) + #expect(ok.mode == .paused) + #expect(ok.fixtureURL?.path == "/tmp/clip.mp4") + #expect(PerfHarness.isValidRequest(ok)) + } +} diff --git a/Tests/LivelyTests/PerfSignpostsTests.swift b/Tests/LivelyTests/PerfSignpostsTests.swift new file mode 100644 index 0000000..2ad5527 --- /dev/null +++ b/Tests/LivelyTests/PerfSignpostsTests.swift @@ -0,0 +1,34 @@ +import Testing +@testable import LivelyCore + +struct PerfSignpostsTests { + @Test func intervalRunsBodyAndReturnsValue() { + var ran = false + let value = PerfSignposts.interval("test") { + ran = true + return 42 + } + #expect(ran) + #expect(value == 42) + } + + @Test func intervalRethrows() { + enum Probe: Error { case boom } + var caught = false + do { + try PerfSignposts.interval("test") { + throw Probe.boom + } + } catch is Probe { + caught = true + } catch { + Issue.record("unexpected error \(error)") + } + #expect(caught) + } + + @Test func subsystemAndCategoryMatchBundle() { + #expect(PerfSignposts.subsystem == "com.lively.app") + #expect(PerfSignposts.category == "Performance") + } +} diff --git a/perf/fixtures/.gitkeep b/perf/fixtures/.gitkeep new file mode 100644 index 0000000..ba8ebb7 --- /dev/null +++ b/perf/fixtures/.gitkeep @@ -0,0 +1 @@ +# Generated by scripts/perf/generate-fixtures.sh. Clips are gitignored. diff --git a/scripts/check_hygiene.sh b/scripts/check_hygiene.sh index 814efff..33ce1da 100755 --- a/scripts/check_hygiene.sh +++ b/scripts/check_hygiene.sh @@ -35,7 +35,10 @@ fi # 4. Syntax checks. plutil -lint Info.plist entitlements.plist >/dev/null || note "plist lint failed" -for f in package.sh test.sh scripts/*.sh; do bash -n "$f" || note "bash syntax: $f"; done +for f in package.sh test.sh scripts/*.sh scripts/perf/*.sh; do + [[ -f "$f" ]] || continue + bash -n "$f" || note "bash syntax: $f" +done # 5. Version sanity: Info.plist is the single source for package.sh. v="$(plutil -extract CFBundleShortVersionString raw Info.plist)" diff --git a/scripts/perf/README.md b/scripts/perf/README.md new file mode 100644 index 0000000..4d5c251 --- /dev/null +++ b/scripts/perf/README.md @@ -0,0 +1,61 @@ +# Performance measurement + +P0 harness for `docs/PERFORMANCE_MASTER_PLAN.md`. Numbers come from a real +Apple silicon Mac. Do not paste invented values into `docs/PERFORMANCE.md`. + +## Fixtures + +```bash +scripts/perf/generate-fixtures.sh +``` + +Writes short clips under `perf/fixtures/` (gitignored): + +| File | Purpose | +|------|---------| +| `1080p30-hevc-silent.mp4` | Default playing/paused budget clip | +| `1080p30-hevc-audio.mp4` | Audio-track decode hypothesis (H) | +| `1080p30-h264-silent.mp4` | Software-vs-hardware comparison | +| `4k60-hevc-silent.mp4` | Transcode-to-native hypothesis (J) | + +## Measure + +Quit any running Lively first. The script refuses to start if `Lively` is +already in the process list, so it never kills your daily instance. + +```bash +scripts/perf/measure.sh idle 30 +scripts/perf/measure.sh playing 30 +scripts/perf/measure.sh paused 30 +``` + +Each run packages a release `.app` (skip with `LIVELY_PERF_SKIP_BUILD=1`), +launches it with an isolated `HOME`, samples `top` and `footprint`, and writes +`perf/results/--.json`. Playing and paused assign the 1080p30 +HEVC fixture to every visible Space via `LIVELY_PERF_*` env vars; they will +cover the desktop until the script stops the process. + +### powermetrics (sudo) + +```bash +sudo powermetrics --samplers tasks --show-process-energy \ + --show-process-wait-times -i 1000 -n 30 --process-name Lively +``` + +If `sudo -n` is not allowed, `measure.sh` records `powermetrics_error` and +leaves `idle_wakeups_per_s` and energy null. + +### Energy Impact + +Activity Monitor → Energy tab, or Instruments Energy Log: + +```bash +xctrace record --template 'Energy Log' --time-limit 30s \ + --launch -- /private/tmp/LivelyOutput/Lively.app/Contents/MacOS/Lively +``` + +### Signposts + +Release builds emit `os_signpost` intervals (`com.lively.app` / `Performance`): +`launch`, `synchronize`, `playerBuild`, `pause`, `resume`. Time Profiler or +the `os_signpost` Instruments template can attach to them. diff --git a/scripts/perf/generate-fixtures.sh b/scripts/perf/generate-fixtures.sh new file mode 100755 index 0000000..4b5dc74 --- /dev/null +++ b/scripts/perf/generate-fixtures.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Generate small looping clips for scripts/perf/measure.sh. +# Requires ffmpeg. Does not commit the binaries. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +OUT="${LIVELY_PERF_FIXTURES:-$ROOT/perf/fixtures}" +mkdir -p "$OUT" + +if ! command -v ffmpeg >/dev/null 2>&1; then + echo "generate-fixtures: ffmpeg not found. Install it (e.g. brew install ffmpeg)." >&2 + exit 1 +fi + +make_clip() { + local name="$1" + shift + local dest="$OUT/$name" + if [[ -f "$dest" ]]; then + echo "exists: $dest" + return + fi + echo "generating: $dest" + ffmpeg -hide_banner -loglevel error -y "$@" "$dest" +} + +# 2-second patterns, hardware-friendly tags (hvc1 / avc1). +make_clip "1080p30-hevc-silent.mp4" \ + -f lavfi -i "testsrc2=size=1920x1080:rate=30" -t 2 \ + -c:v libx265 -pix_fmt yuv420p -tag:v hvc1 -an -g 30 + +make_clip "1080p30-hevc-audio.mp4" \ + -f lavfi -i "testsrc2=size=1920x1080:rate=30" \ + -f lavfi -i "sine=frequency=440:sample_rate=48000" -t 2 \ + -c:v libx265 -pix_fmt yuv420p -tag:v hvc1 -c:a aac -ac 2 -shortest -g 30 + +make_clip "1080p30-h264-silent.mp4" \ + -f lavfi -i "testsrc2=size=1920x1080:rate=30" -t 2 \ + -c:v libx264 -pix_fmt yuv420p -tag:v avc1 -an -g 30 + +make_clip "4k60-hevc-silent.mp4" \ + -f lavfi -i "testsrc2=size=3840x2160:rate=60" -t 2 \ + -c:v libx265 -pix_fmt yuv420p -tag:v hvc1 -an -g 60 + +echo "fixtures in $OUT" +ls -lh "$OUT"/*.mp4 diff --git a/scripts/perf/measure.sh b/scripts/perf/measure.sh new file mode 100755 index 0000000..0734c22 --- /dev/null +++ b/scripts/perf/measure.sh @@ -0,0 +1,222 @@ +#!/bin/bash +# Measure Lively resource use. Writes perf/results/--.json +# +# Usage: +# scripts/perf/measure.sh [idle|playing|paused] [duration_seconds] +# +# Environment: +# LIVELY_PERF_SKIP_BUILD=1 reuse an existing packaged app +# LIVELY_OUTPUT_DIR package.sh output (default /private/tmp/LivelyOutput) +# LIVELY_PERF_FIXTURE override clip for playing/paused +# LIVELY_PERF_HOME isolated HOME (default a temp dir) +# +# Does not touch ~/Library/Application Support/Lively. Isolated HOME only. +# +# powermetrics needs sudo. If sudo is not available the JSON records +# powermetrics_error and the other samplers still run. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +MODE="${1:-idle}" +DURATION="${2:-30}" +case "$MODE" in + idle|playing|paused) ;; + *) + echo "usage: $0 [idle|playing|paused] [duration_seconds]" >&2 + exit 2 + ;; +esac +if ! [[ "$DURATION" =~ ^[0-9]+$ ]] || [[ "$DURATION" -lt 5 ]]; then + echo "duration must be an integer >= 5" >&2 + exit 2 +fi + +COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +RESULTS="$ROOT/perf/results" +mkdir -p "$RESULTS" +RAW_DIR="$RESULTS/${STAMP}-${COMMIT}-${MODE}" +mkdir -p "$RAW_DIR" + +if pgrep -x Lively >/dev/null 2>&1; then + echo "measure: a Lively process is already running. Quit it first so this" >&2 + echo "script does not attach to (or kill) your daily instance." >&2 + exit 1 +fi + +if [[ "${LIVELY_PERF_SKIP_BUILD:-}" != "1" ]]; then + echo "measure: packaging release app" + ./package.sh +fi + +APP="${LIVELY_OUTPUT_DIR:-/private/tmp/LivelyOutput}/Lively.app" +BIN="$APP/Contents/MacOS/Lively" +if [[ ! -x "$BIN" ]]; then + echo "measure: missing $BIN — run ./package.sh" >&2 + exit 1 +fi + +FIXTURE="${LIVELY_PERF_FIXTURE:-}" +if [[ "$MODE" == "playing" || "$MODE" == "paused" ]]; then + if [[ -z "$FIXTURE" ]]; then + "$ROOT/scripts/perf/generate-fixtures.sh" + FIXTURE="$ROOT/perf/fixtures/1080p30-hevc-silent.mp4" + fi + if [[ ! -f "$FIXTURE" ]]; then + echo "measure: fixture not found: $FIXTURE" >&2 + exit 1 + fi +fi + +PERF_HOME="${LIVELY_PERF_HOME:-$(mktemp -d /tmp/lively-perf-home.XXXXXX)}" +mkdir -p "$PERF_HOME" + +echo "measure: mode=$MODE duration=${DURATION}s home=$PERF_HOME" +echo "measure: launching $BIN" + +# Launch the bundle executable so HOME and LIVELY_PERF_* inherit. +# `open` goes through LaunchServices and drops those variables. +export HOME="$PERF_HOME" +export LIVELY_PERF_MODE="$MODE" +if [[ -n "$FIXTURE" ]]; then + export LIVELY_PERF_FIXTURE="$FIXTURE" +fi + +"$BIN" & +PID=$! +sleep 0.4 +if ! kill -0 "$PID" 2>/dev/null; then + echo "measure: Lively did not stay running (pid $PID)" >&2 + exit 1 +fi +echo "measure: pid=$PID" + +# Settle: player build / pause grace (1.5s) plus a couple of seconds of decode. +SETTLE=5 +if [[ "$MODE" == "paused" ]]; then + SETTLE=8 +fi +sleep "$SETTLE" + +TOP_SAMPLES=$(( DURATION / 5 )) +if [[ "$TOP_SAMPLES" -lt 3 ]]; then + TOP_SAMPLES=3 +fi + +echo "measure: sampling top (${TOP_SAMPLES} x 5s)" +top -l "$TOP_SAMPLES" -s 5 -pid "$PID" -stats pid,cpu,mem,rsize,threads \ + > "$RAW_DIR/top.txt" 2>/dev/null || true + +echo "measure: footprint" +if footprint -p "$PID" > "$RAW_DIR/footprint.txt" 2>"$RAW_DIR/footprint.err"; then + : +else + echo "measure: footprint failed (see $RAW_DIR/footprint.err)" +fi + +POWERMETRICS_ERROR="" +if command -v powermetrics >/dev/null 2>&1; then + if sudo -n true 2>/dev/null; then + echo "measure: powermetrics (passwordless sudo)" + sudo powermetrics --samplers tasks --show-process-energy \ + --show-process-wait-times -i 1000 -n 10 \ + --process-name Lively \ + > "$RAW_DIR/powermetrics.txt" 2>"$RAW_DIR/powermetrics.err" || \ + POWERMETRICS_ERROR="powermetrics failed; see powermetrics.err" + else + POWERMETRICS_ERROR="sudo required: sudo powermetrics --samplers tasks --show-process-energy --show-process-wait-times -i 1000 -n 30 --process-name Lively" + echo "measure: skipping powermetrics ($POWERMETRICS_ERROR)" + fi +else + POWERMETRICS_ERROR="powermetrics not found" +fi + +BINARY_BYTES="$(stat -f%z "$BIN" 2>/dev/null || wc -c < "$BIN" | tr -d ' ')" +CHIP="$(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo unknown)" +OS_VER="$(sw_vers -productVersion 2>/dev/null || echo unknown)" + +JSON="$RESULTS/${STAMP}-${COMMIT}-${MODE}.json" +MEASURE_STAMP="$STAMP" MEASURE_COMMIT="$COMMIT" MEASURE_MODE="$MODE" \ +MEASURE_DURATION="$DURATION" MEASURE_PID="$PID" MEASURE_CHIP="$CHIP" \ +MEASURE_OS="$OS_VER" MEASURE_BIN="$BINARY_BYTES" \ +MEASURE_POWER_ERR="$POWERMETRICS_ERROR" MEASURE_RAW="$RAW_DIR" MEASURE_JSON="$JSON" \ +python3 <<'PY' +import json, os, re +from pathlib import Path + +pid = os.environ["MEASURE_PID"] +raw_dir = Path(os.environ["MEASURE_RAW"]) + +def cpu_avg(text: str): + vals = [] + for line in text.splitlines(): + parts = line.split() + if parts and parts[0] == pid and len(parts) >= 2: + try: + vals.append(float(parts[1])) + except ValueError: + pass + if not vals: + return None + return round(sum(vals) / len(vals), 3) + +def size_to_bytes(num: str, unit: str) -> int: + n = float(num) + u = unit.upper().rstrip("s") + mul = {"": 1, "B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3} + return int(n * mul.get(u, 1)) + +def parse_footprint(text: str): + phys = peak = None + m = re.search(r"phys_footprint:\s+([0-9.]+)\s*([A-Za-z]+)", text) + if m: + phys = size_to_bytes(m.group(1), m.group(2)) + m = re.search(r"phys_footprint_peak:\s+([0-9.]+)\s*([A-Za-z]+)", text) + if m: + peak = size_to_bytes(m.group(1), m.group(2)) + return phys, peak + +top_text = (raw_dir / "top.txt").read_text(errors="replace") if (raw_dir / "top.txt").exists() else "" +fp_text = (raw_dir / "footprint.txt").read_text(errors="replace") if (raw_dir / "footprint.txt").exists() else "" +phys, peak = parse_footprint(fp_text) +err = os.environ.get("MEASURE_POWER_ERR") or None + +payload = { + "date": os.environ["MEASURE_STAMP"], + "commit": os.environ["MEASURE_COMMIT"], + "mode": os.environ["MEASURE_MODE"], + "duration_seconds": int(os.environ["MEASURE_DURATION"]), + "pid": int(pid), + "chip": os.environ["MEASURE_CHIP"], + "os": os.environ["MEASURE_OS"], + "binary_bytes": int(os.environ["MEASURE_BIN"]), + "cpu_pct_avg": cpu_avg(top_text), + "footprint_bytes": phys, + "footprint_peak_bytes": peak, + "powermetrics_error": err, + "energy_impact": None, + "idle_wakeups_per_s": None, + "raw_dir": str(raw_dir), + "notes": [ + "Energy Impact is Activity Monitor / Instruments Energy Log; not sampled here.", + "Idle wake-ups need powermetrics or Activity Monitor.", + "Never invent numbers; fields are null when the sampler did not run.", + ], +} +out = os.environ["MEASURE_JSON"] +with open(out, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + fh.write("\n") +print("wrote", out) +PY + +echo "measure: stopping pid $PID" +kill "$PID" 2>/dev/null || true +sleep 0.5 +kill -9 "$PID" 2>/dev/null || true +wait "$PID" 2>/dev/null || true + +echo "measure: done" +cat "$JSON" From 0b93d3b1537a01ec507977fa2346de8f452ce439 Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Tue, 13 Oct 2026 17:05:53 +0530 Subject: [PATCH 2/3] docs(perf): Record P0 baseline and measurement plan Apple M1, macOS 27.0.1, 20 s samples: idle 13 MB / 0.0% CPU, playing 1080p30 HEVC 18 MB / ~3% CPU, paused 17 MB / 0.0% CPU. Wake-ups and Energy Impact are blank because powermetrics needs sudo. Playing CPU is already above the 1.5% budget. --- CHANGELOG.md | 1 + CONTRIBUTING.md | 1 + docs/PERFORMANCE.md | 106 +++++++++++++++++--- docs/PERFORMANCE_MASTER_PLAN.md | 167 ++++++++++++++++++++++++++++++++ docs/perf-findings.md | 25 +++++ 5 files changed, 285 insertions(+), 15 deletions(-) create mode 100644 docs/PERFORMANCE_MASTER_PLAN.md create mode 100644 docs/perf-findings.md diff --git a/CHANGELOG.md b/CHANGELOG.md index acaef4f..f070343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ All notable changes to Lively are documented here. The format follows [Keep a Ch - New suites for update checking (stubbed network), video validation with generated H.264 and Motion-JPEG clips, config persistence/migration, preferences, Reset Data, and system suspension. - New CI workflow runs build, tests, and packaging on every push and pull request. +- Performance harness: `os_signpost` intervals, `scripts/perf/measure.sh`, generated fixtures. No runtime behaviour change unless `LIVELY_PERF_MODE` is set. ## [1.2.0] - 2026-07-14 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc79007..986d2d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,7 @@ swift build # build swift test # run tests (full Xcode) ./scripts/check_hygiene.sh # paths, secrets, duplicates, plist and script syntax ./package.sh # build an ad-hoc signed Lively.app (output in $LIVELY_OUTPUT_DIR) +scripts/perf/measure.sh idle 30 # isolated-HOME resource sample; see scripts/perf/README.md ``` `swiftlint` (config in `.swiftlint.yml`) runs in CI; run it locally if installed. diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 8feae37..b9b0dff 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -1,17 +1,93 @@ # CPU, GPU and battery budget -Targets (to be measured with Activity Monitor / Instruments on real hardware; they -were not measured in the audit environment): - -- Idle with no wallpaper assigned: no decoding, negligible CPU. -- Playing 1080p HEVC on Apple silicon: hardware decode, low single-digit CPU percent per display. -- Nothing visible, nothing decoded: playback stops when displays or the Mac sleep, the - screen is locked or the screen saver runs, the user session is inactive, or the - wallpaper window is fully occluded (for example by a full-screen app). -- Battery: playback pauses at or below 25% on battery (forced) and at the user threshold - (default 35%) when "Pause on Battery" is on; it resumes on AC. -- Thermal: playback is throttled/paused under serious thermal pressure. -- User controls: Playback Quality (peak bit rate cap), Max Resolution, hardware decoding. - -Known gap: the wallpaper video itself does not yet react to the system Reduce Motion -setting. Tracked as a follow-up (design decision: pause on first frame vs. keep playing). +Policy lives in `docs/PERFORMANCE_MASTER_PLAN.md`. How to reproduce: +`scripts/perf/README.md`. Later phases compare to the P0 baseline below. +Do not invent numbers; a cell is `—` when that sampler did not run. + +## Targets (budgets) + +Measure on Apple silicon, release `.app`, 1080p HEVC 30 fps looping wallpaper +on one display, AC power, idle desktop, popover closed. + +| Metric | Budget | Tool | +|---|---|---| +| Resident memory, wallpaper playing, popover closed | <= 60 MB (stretch 40 MB) | `footprint -p`, Instruments Allocations | +| Resident memory, paused or suspended | <= 25 MB | `footprint` | +| Resident memory, no wallpaper assigned | <= 20 MB | `footprint` | +| App CPU, playing 1080p30 HEVC | <= 1.5% of one core (stretch 1%) | `top -pid`, Activity Monitor | +| App CPU, paused or idle, 10 min average | 0.0% | `top`, `sample` | +| Wake-ups per second, idle | <= 1 (target 0) | `powermetrics --samplers tasks`, Activity Monitor Idle Wake Ups | +| Energy Impact, playing | <= 3 (stretch 2) | Activity Monitor, Instruments Energy Log | +| Energy Impact, paused | ~0 | Activity Monitor | +| Time from pause trigger to decoder stopped | <= 100 ms | os_signpost `pause` | +| Time from resume to first frame | <= 300 ms | os_signpost `resume` / `playerBuild` | +| Cold-launch to menu-bar ready | <= 250 ms | os_signpost `launch` | +| Release binary size (unstripped `.app` executable) | track; stretch <= 2 MB | `ls -l` | + +If a budget is physically impossible, record the measured floor and why. + +## Safety pauses (do not remove) + +Playback stops when displays or the Mac sleep, the screen is locked, the +screen saver runs, the user session is inactive, the wallpaper window is +fully occluded, battery is at or below 25% (forced) or the user threshold +(default 35% when "Pause on Battery" is on), or thermal state is serious +or critical. + +User controls: Playback Quality (peak bit rate cap), Max Resolution, +hardware decoding. + +Known gap: the wallpaper video itself does not yet react to Reduce Motion +(P4 / hypothesis K). + +## P0 baseline + +Recorded 2026-10-13 on this machine. Host: Apple M1, macOS 27.0.1. +Release `.app` from `./package.sh` (Command Line Tools). Isolated `HOME`. +Popover closed (`startMinimized` default). Fixture: +`perf/fixtures/1080p30-hevc-silent.mp4` (2 s loop). Sample window 20 s +after a 5–8 s settle. Parent commit at measure time: `85e332a` plus this +P0 tree. + +`footprint` values are `phys_footprint` (mebibytes = bytes / 1024²). +`top` %CPU is macOS one-core percent (can exceed 100% on many cores). + +| Metric | Idle (no wallpaper) | Playing 1080p30 HEVC | Paused | Budget | +|---|---|---|---|---| +| `footprint` resident | 13 MB (peak 14) | 18 MB (peak 18) | 17 MB (peak 18) | 20 / 60 / 25 MB | +| `top` CPU % | 0.0 (all 4 samples) | 2.325 avg; last three samples 3.0, 3.1, 3.2 | 0.0 | 0.0 / 1.5 / 0.0 | +| Threads (`top` #TH) | 3–8 | 18–19 | (see raw) | — | +| Idle wake-ups / s | — | — | — | <= 1 idle | +| Energy Impact | — | — | — | 0 / 3 / 0 | +| Release executable | 4,364,304 bytes (4.16 MiB) | same | same | track; stretch 2 MB | + +RAM is already inside the idle/playing/paused budgets. Playing CPU is +**above** the 1.5% budget on this 20 s window (~3% once decode is up). +Paused RAM stays ~4 MB above idle and still shows a CoreMedia memory pool, +which is consistent with hypothesis B (player left resident) — P1 must +confirm with Allocations. + +### Not measured here (need the operator) + +`sudo -n` was not available, so `powermetrics` and Energy Impact are +blank on purpose. + +```bash +# Idle wake-ups and process energy, 30 s, while Lively is in the mode you care about: +sudo powermetrics --samplers tasks --show-process-energy \ + --show-process-wait-times -i 1000 -n 30 --process-name Lively + +# Energy Log (Instruments) +xctrace record --template 'Energy Log' --time-limit 30s \ + --output /tmp/lively-energy.trace \ + --launch -- /private/tmp/LivelyOutput/Lively.app/Contents/MacOS/Lively + +# Signpost intervals (launch / synchronize / playerBuild / pause / resume) +xctrace record --template 'Time Profiler' --time-limit 30s \ + --output /tmp/lively-time.trace \ + --launch -- /private/tmp/LivelyOutput/Lively.app/Contents/MacOS/Lively +``` + +Or Activity Monitor: select Lively → Energy tab (Energy Impact, Idle +Wake Ups) while the harness is running (`LIVELY_PERF_SKIP_BUILD=1 +scripts/perf/measure.sh playing 60`). diff --git a/docs/PERFORMANCE_MASTER_PLAN.md b/docs/PERFORMANCE_MASTER_PLAN.md new file mode 100644 index 0000000..f732f30 --- /dev/null +++ b/docs/PERFORMANCE_MASTER_PLAN.md @@ -0,0 +1,167 @@ +# Lively Performance Master Plan + +Goal: the lightest possible live-wallpaper app on macOS. Minimal RAM, near-zero idle CPU, minimal battery impact. +Audience: an autonomous coding agent. Execute phases in order. Every phase ends with a measurement and a commit. + +## 0. Decision: do NOT rewrite in Rust + +Lively is already native Swift/AppKit: ~7.8k LOC, zero third-party dependencies, SPM, macOS 14+. +The real cost centres are not the language: + +1. Video decode, which is done by the Apple Silicon media engine through AVFoundation. Rust cannot beat it. +2. WindowServer compositing of the wallpaper window, which is OS-owned. +3. SwiftUI/Combine/popover memory that stays resident while the app sits in the menu bar. +4. Wake-ups (timers, polling, notification fan-out) that keep the CPU out of deep idle. + +A Rust rewrite would add FFI to AVFoundation/AppKit, cost months, and reduce nothing in 1 and 2. +Rust or C is only justified for a hot path that a profile proves is ours. None is expected. +Strategy: measure, remove wake-ups, free memory when invisible, shrink the video workload, shrink the binary. + +## 1. Current state (audited 2026-10-13) + +- Local `master` is in sync with `origin/master` (harshabala/lively) at `2d83093` "Production readiness pass (#7)". +- Open PR: Dependabot `actions/checkout 4 -> 7`. Review CI, then merge. Not performance-related. +- Untracked Finder/iCloud duplicates ("... 2.md", "... 2.png", including `.impeccable/critique/...2.md`). Delete or gitignore them. Check `diff` against the originals first. +- Stack: `LivelyCore` library (Sources/Lively), `LivelyApp` executable (main.swift, AppDelegate, NSStatusItem, NSPopover with NSHostingController(SettingsContainerView)). +- Big files: `ScreenCardView.swift` 942 lines, `WallpaperController.swift` 850, `PreferencesView.swift` 563, `ConfigStore.swift` 425, `LibraryView.swift` 422. +- `docs/PERFORMANCE.md` says its targets were never measured on hardware. This plan fixes that first. + +### What is already good (keep) +- Pauses on display sleep, system sleep, lock, screen saver, fast-user-switch (`SystemSuspension`). +- Pauses when the wallpaper window is fully occluded (`didChangeOcclusionState`). +- Battery floor 25%, user threshold, thermal throttling, quality and resolution caps, `preferredPeakBitRate`. +- ConfigStore persistence is debounced (300 ms). +- `automaticallyWaitsToMinimizeStalling = false`, `preventsDisplaySleepDuringVideoPlayback = false`. + +### Inefficiencies found in code (verify each with a profile before and after) +| # | Where | Issue | Fix (phase) | +|---|-------|-------|-------------| +| A | `WallpaperController.setupBatteryMonitor` | 20 s repeating `Timer` plus a `Task` hop forever, even while idle or paused. Constant wake-ups. | Event-driven `IOPSNotificationCreateRunLoopSource`; no timer (P2) | +| B | `WallpaperSession.pause()` | Pausing only calls `player.pause()`. The AVPlayer, item, decoded frames and buffers stay resident for hours while suspended or battery-paused. | Tear down the player after a short grace period; rebuild on resume from a poster frame (P3) | +| C | `NSHostingController` in main.swift | The SwiftUI settings tree (LibraryView, ScreenCardView, thumbnails, Combine subscriptions) is built at launch and kept alive for the app's life. | Create the popover content lazily on first open; destroy on close (P3) | +| D | `LogStore` | Up to 1000 `LogEntry` objects held in a `@Published` array on the main actor, mirroring `os.Logger`. Every log line fires objectWillChange. | Drop the in-memory mirror; read from `OSLogStore` on demand, or use a ring buffer that is not `@Published` while the log view is closed (P3) | +| E | `configStore.$configs` sink | Every config change triggers `retryFailedPlayback()` and `spaceMonitor.refresh()` (rebuilds all `ScreenSpace` values, re-queries `desktopImageURL`). | Diff first; refresh only when the affected spaceKey changed (P2) | +| F | `SpaceMonitor.handleSpaceChange` | Polls up to 10 x 50 ms on each Space switch. | Fine functionally. Cap work, skip when no display has a wallpaper assigned (P2) | +| G | Loop via `seek(to: .zero)` | Seek on item end causes a decoder flush, a stutter and a CPU spike every loop. | `AVPlayerLooper`/`AVQueuePlayer`, or an `AVAssetReader` + `AVSampleBufferDisplayLayer` pipeline if profiling shows it wins (P4) | +| H | Muted wallpapers | A muted `AVPlayer` still decodes the audio track. | Disable audio tracks on the item (`AVPlayerItemTrack.isEnabled = false`) or strip audio at import (P4) | +| I | Same file on 2+ displays | One `AVPlayer` and one decode per display. | Share a single `AVPlayer` across several `AVPlayerLayer`s for identical URL and settings (P4) | +| J | Source video can be 4K/60 fps | Only a bit-rate and max-height hint is applied. The decoder still works on the full stream. | Import-time transcode to a "Lively-optimised" HEVC at display-native size, 30 fps cap (P4). Biggest battery lever. | +| K | Reduce Motion gap noted in PERFORMANCE.md | Wallpaper ignores the accessibility setting. | Show poster frame and stop decoding when Reduce Motion is on (P4) | +| L | `AppMetrics` | A `DateFormatter` allocated for a once-a-day string. | Use `Date.FormatStyle` or compare `Calendar.startOfDay`. Trivial (P5) | +| M | `UpdateChecker` | Network call on every launch. | Throttle to once per 24 h, `URLSession` ephemeral, no retained session (P5) | +| N | Binary | Default release flags. | `-Osize`, WMO, dead-strip, strip symbols, no unused frameworks (P6) | + +## 2. Budgets (the definition of done) + +Measure on Apple silicon, release build, 1080p HEVC 30 fps looping wallpaper on one external or built-in display, AC power, idle desktop. + +| Metric | Budget | Tool | +|---|---|---| +| Resident memory, wallpaper playing, popover closed | <= 60 MB (stretch 40 MB) | `footprint -p`, Instruments Allocations | +| Resident memory, paused or suspended | <= 25 MB | `footprint` | +| Resident memory, no wallpaper assigned | <= 20 MB | `footprint` | +| App CPU, playing 1080p30 HEVC | <= 1.5% of one core (stretch 1%) | `top -pid`, Activity Monitor | +| App CPU, paused or idle, 10 min average | 0.0% | `top`, `sample` | +| Wake-ups per second, idle | <= 1 (target 0) | `powermetrics --samplers tasks`, Activity Monitor "Idle Wake Ups" | +| Energy Impact, playing | <= 3 (stretch 2) | Activity Monitor, Instruments Energy Log | +| Energy Impact, paused | ~0 | Activity Monitor | +| Time from pause trigger to decoder stopped | <= 100 ms | os_signpost | +| Time from resume to first frame | <= 300 ms | os_signpost | +| Cold-launch to menu-bar ready | <= 250 ms | os_signpost | +| Release binary size (unstripped .app executable) | track and do not regress; stretch <= 2 MB | `ls -l`, `bloaty` | + +Budgets are targets. If a budget is physically impossible, record the measured floor and the reason in `docs/PERFORMANCE.md`. Do not fudge numbers. + +## 3. Phases + +### P0. Hygiene and baseline (do first, about half a day) +1. Delete or ignore the "... 2" duplicates after confirming they are byte-identical or stale. +2. Merge the Dependabot PR if CI is green. +3. Create `scripts/perf/` with: + - `measure.sh`: builds release, launches with a fixture wallpaper, samples `top` for N minutes, runs `footprint`, `powermetrics` (needs sudo, document it), writes `perf/results/-.json`. + - A fixture set in `perf/fixtures/` (small generated clips: 1080p30 HEVC, 4K60 HEVC, 1080p30 H.264, with and without audio). Generate with `ffmpeg` or `avconvert`; do not commit large binaries (use Git LFS or generate on demand). +4. Add `os_signpost` intervals (`OSSignposter`) around: launch, synchronize, player build, pause, resume. Keep them; they are free when not profiled. +5. Record the BASELINE in `docs/PERFORMANCE.md` as a table. Every later phase compares to it. No optimisation without a before and after number. + +Exit: baseline table committed. CI still green. + +### P1. Instruments pass (read-only, about half a day) +Run Time Profiler, Allocations, Energy Log, System Trace (wake-ups) for: idle, playing, paused, popover open and closed, Space switching, display hot-plug. Write findings to `docs/perf-findings.md`. Confirm or refute rows A to N above and add anything new. Re-rank by measured impact. Do not skip. The table above is a hypothesis from reading code. + +### P2. Kill wake-ups (target: idle wake-ups to ~0) +- A: replace the 20 s timer with `IOPSNotificationCreateRunLoopSource` (fires only on power-source change). Also observe `NSProcessInfoPowerStateDidChange` (already done) and `ProcessInfo.thermalStateDidChangeNotification` (already done). Initial read once at launch. Keep a pure function `batteryPauseDecision` and its tests. +- E: in the `configStore.$configs` sink, compute which spaceKeys changed; call `spaceMonitor.refresh()` only if a current space's config changed. Add `removeDuplicates`. +- F: skip the poll loop when no screen has an assigned wallpaper. +- Audit every `Timer`, `Task.sleep`, `asyncAfter`, `Combine.publish(every:)`, and `NotificationCenter` observer. Each must have a documented reason. Remove observers that are not needed while paused (for example the occlusion observer). +- Coalesce rapid-fire notifications (display reconfiguration storms) with a single trailing 100 ms debounce. +- Add a test that no repeating timer exists after `WallpaperController.init` (inject a scheduler or assert `powerSourceTimer == nil`). + +Exit: `powermetrics` shows idle wake-ups under budget; paused CPU 0.0%. + +### P3. Memory: free everything that is not visible +- B: "Cold pause". When suspension or pause lasts longer than a grace period (suggest 10 s for lock, sleep and screen saver; 0 s for battery and thermal pauses), call `tearDownPlayer()` and keep only the window with a static poster frame (`CALayer.contents` as a `CGImage` at display pixel size; store the JPEG or HEIC poster in Caches). On resume, rebuild the player. Must not flash black: keep the poster visible until the first decoded frame arrives (`AVPlayerLayer.isReadyForDisplay`). +- Optional: when occluded for more than N seconds, apply the same cold pause. +- C: make the popover content lazy. `NSPopover.contentViewController` is set on first show, and set to `nil` in `popoverDidClose` (after a short delay) so SwiftUI state, thumbnails and Combine sinks are released. Verify with Allocations that memory drops after closing. +- D: LogStore. Remove the `@Published` 1000-entry array. Options in order of preference: (1) read `OSLogStore(scope: .currentProcessIdentifier)` when the log view opens; (2) a plain non-observable ring buffer of 200 entries, published only while the log view is visible. +- Thumbnails: `VideoThumbnailView` and `LibraryView` must use `AVAssetImageGenerator` with `maximumSize` set to the displayed pixel size, cache to disk in Caches, keep an `NSCache` with a small count/cost limit, and evict on `popoverDidClose`. +- Make `ScreenCardView` (942 lines) cheaper: split into small views with equatable inputs, avoid `@EnvironmentObject` fan-out so one `@Published` change does not re-render every card. This is also a maintainability win. +- Replace `@Published` + `ObservableObject` on hot controllers with `@Observable` (macOS 14 supports it) to cut invalidation. Do this only where P1 shows view-update cost. + +Exit: footprint paused <= 25 MB, popover-closed <= budget, no growth after 50 open/close cycles of the popover (leak check with the memory-leak workflow). + +### P4. Decode workload: the biggest battery lever +- J: Import pipeline "Optimise for Lively": on import (and optionally on first play), transcode to a cached HEVC file: + - Resolution = display native pixel size, never larger (use the max across assigned displays, or one rung per display). + - 30 fps cap (24 fps offered as "Eco"), constant frame rate. + - Strip audio if the wallpaper is muted (the default). + - Bitrate target about 4 to 8 Mbps for 1080p; GOP and keyframe interval short enough for cheap looping. + - Use `AVAssetExportSession` with a preset or `AVAssetWriter` for control. Run it as a background `Process`-free task with `QualityOfService.utility`, cancellable, with progress. Keep the original untouched; cache under `~/Library/Caches` or the app container. + - Skip transcoding if the source already meets the spec (reuse `VideoValidator`). +- G: seamless loop without seek-flush: `AVPlayerLooper` with `AVQueuePlayer` as first step. Measure CPU spikes at loop boundaries. Only if still visible, evaluate `AVAssetReader` + `AVSampleBufferDisplayLayer` (more control, more code; adopt only with a measured win). +- H: if the item is muted, disable the audio `AVPlayerItemTrack`s so no audio decode or render graph exists. +- I: share one `AVPlayer` across displays that show the same URL and settings; lay out multiple `AVPlayerLayer`s on it. +- K: respect `NSWorkspace.shared.accessibilityDisplayShouldReduceMotion`: show the poster frame, no decode. Add an observer for `accessibilityDisplayOptionsDidChangeNotification`. Update docs and tests. +- Low Power Mode: when `ProcessInfo.isLowPowerModeEnabled`, default to 24 fps or poster-only (user-configurable, default on). +- Smart "static after idle": optional setting to freeze on the poster frame after N minutes without user input on battery. Use `CGEventSource.secondsSinceLastEventType`, polled only when a decision is pending (no always-on timer; use a single one-shot timer re-armed on state changes). +- Never use software decode paths. Remove the "hardware decoding off" option unless there is a hard reason to keep it; if kept, document the energy cost. + +Exit: playing CPU and Energy Impact meet budget on the 1080p30 fixture; the 4K60 fixture after transcode behaves like the 1080p30 one. + +### P5. Small leaks of cycles +- L: replace `DateFormatter` in `AppMetrics`. +- M: throttle `UpdateChecker` to once per 24 h, using an ephemeral session, off the main actor, and never at launch. Defer 30 s after launch at `.background` QoS. +- Use `OSAllocatedUnfairLock` or actors consistently; remove stray `DispatchQueue.main.asyncAfter` where a state change is the correct trigger (`main.swift` initial popover open). +- Launch path: construct `UpdateChecker`, `LogStore`, `AppMetrics`, `Diagnostics` lazily. Do not touch the UI layer until the user clicks the status item (unless "Start Minimized" is off). +- Set QoS everywhere: UI `.userInteractive`, persistence `.utility`, transcoding `.utility`, update check `.background`. +- Use `NSStatusItem` image as a template `NSImage` created once. +- Ensure `Info.plist` has `LSUIElement` = YES (already accessory policy), no unused entitlements. + +### P6. Build and binary size +- Release settings: `-Osize` (benchmark against `-O`; keep the faster one if the size gap is under 5%), `-whole-module-optimization`, `-Xlinker -dead_strip`, strip symbols, `-Xfrontend -enable-lto` or `-lto=llvm-thin` if supported by the toolchain. Evaluate Swift 6 embedded-style flags only if they build; do not chase them. +- Dependency audit: confirm no SwiftUI or Combine usage outside the settings UI. Consider replacing Combine in `LivelyCore` logic with plain `NotificationCenter` observers and closures; this shrinks the runtime footprint and removes allocation churn. +- Consider an AppKit-only settings window for the hottest screens only if P3 shows SwiftUI is still the dominant resident cost after lazy-load. Otherwise leave SwiftUI. +- Prelink and launch time: check `DYLD_PRINT_STATISTICS`-style output or `xctrace` App Launch template; remove any `+load` or heavy static initialisers. +- Keep `scripts/make-dmg.sh` and notarisation flow intact. + +### P7. Regression guard (so it stays fast) +- `scripts/perf/measure.sh` runs in a manually triggered GitHub Action on a macOS runner (hardware numbers on hosted runners are noisy; use as trend, not gate). Local runs on real Apple silicon are the gate. +- Add unit tests for: cold-pause state machine (suspension reasons x grace timers), no-repeating-timers invariant, battery decision table, Reduce Motion path, lazy popover lifecycle (no hosting controller retained when closed). +- Add `docs/PERFORMANCE.md` as the single source of truth: budgets, latest measured numbers, how to reproduce. +- Add a PR checklist item: "Does this add a timer, observer, or retained view? Justify." + +## 4. Rules for the executing agent +- One phase per branch and PR (`perf/p2-wakeups`, etc.). Small commits. All existing tests must pass (`swift test`). Add tests for any new logic. +- Every PR description must include BEFORE and AFTER numbers from `measure.sh`. No numbers means no merge. +- Do not change user-visible behaviour or config formats (security-scoped bookmarks, spaceKey format, `ConfigStore` schema). Migrations need tests. +- Do not add third-party dependencies. +- Do not remove the safety pauses (sleep, lock, thermal, battery floor). +- No black flashes: a wallpaper window may only be shown with content (poster or video frame). +- If a hypothesis in section 1 is disproved by profiling, say so in `docs/perf-findings.md` and skip the item. +- Stop and ask the owner before: changing minimum macOS version, removing a user-facing setting, or any rewrite of a module larger than ~300 lines. + +## 5. Expected payoff (ranked, to be confirmed by P1) +1. P4 transcode to native-size 30 fps HEVC plus audio strip: largest CPU, GPU and Energy gain while playing. +2. P3 cold pause and lazy popover: largest RAM gain while idle or paused. +3. P2 removal of the 20 s timer and other wake-ups: removes the constant idle drain. +4. P4 shared player across displays and seamless loop: removes loop spikes and halves multi-display decode. +5. P6 build flags: smaller binary and faster launch, modest runtime effect. diff --git a/docs/perf-findings.md b/docs/perf-findings.md new file mode 100644 index 0000000..da852d4 --- /dev/null +++ b/docs/perf-findings.md @@ -0,0 +1,25 @@ +# Perf findings + +Hypothesis table (A–N) lives in `docs/PERFORMANCE_MASTER_PLAN.md` §1. P1 +must confirm or refute each item with Instruments. Until then, treat the +table as unread code, not measured fact. + +## P0 (this branch) + +- Finder/iCloud `* 2.*` duplicates removed (byte-identical except + `Lively-task-list-plan 2.md`, which was an older copy of the tracked file). +- Dependabot `actions/checkout` 4 → 7 merged on `master` as #8 (CI green). +- Signposts: `launch`, `synchronize`, `playerBuild`, `pause`, `resume` + (`com.lively.app` / `Performance`). +- `scripts/perf/measure.sh` writes JSON under `perf/results/`. Null fields + mean the sampler did not run. Do not fill them in by hand. +- Baseline numbers are in `docs/PERFORMANCE.md`. Playing CPU (~3% once + decode is up) misses the 1.5% budget. Paused footprint 17 MB vs idle 13 MB + with a CoreMedia pool still mapped — treat as a **hint** for hypothesis B, + not a P1 confirmation. Wake-ups and Energy Impact were not sampled + (powermetrics needs sudo). + +## P1 (not started) + +See the P1 plan in the P0 pull request. Do not skip. Do not start P2 until +this file records a ranked, measured list. From 28ebfe7e99a7c7319f281e8227c3c7faee6a87d7 Mon Sep 17 00:00:00 2001 From: harsha balakrishnan Date: Tue, 13 Oct 2026 19:51:08 +0530 Subject: [PATCH 3/3] fix(perf): Isolate Application Support from the real user Library HOME does not redirect FileManager.userDomainMask on current macOS, so the harness wrote fixtures into the live config. Route config, run marker, and library I/O through LIVELY_APPLICATION_SUPPORT and LIVELY_CONFIG_FILE, use a throwaway UserDefaults suite in perf mode, and record the isolated 30s baseline. --- CHANGELOG.md | 1 + CONTRIBUTING.md | 2 +- Sources/Lively/Logic/AppPreferences.swift | 4 +- Sources/Lively/Logic/AppSupportPaths.swift | 41 +++++++++++++++++ Sources/Lively/Logic/ConfigStore.swift | 21 +++++---- Sources/Lively/Logic/Diagnostics.swift | 5 +-- .../Logic/WallpaperLibraryManager.swift | 3 ++ Tests/LivelyTests/AppSupportPathsTests.swift | 29 ++++++++++++ docs/PERFORMANCE.md | 45 +++++++++++++------ docs/perf-findings.md | 17 ++++--- scripts/perf/README.md | 19 ++++++-- scripts/perf/measure.sh | 23 ++++++++-- 12 files changed, 167 insertions(+), 43 deletions(-) create mode 100644 Sources/Lively/Logic/AppSupportPaths.swift create mode 100644 Tests/LivelyTests/AppSupportPathsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f070343..b4c1048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to Lively are documented here. The format follows [Keep a Ch - New suites for update checking (stubbed network), video validation with generated H.264 and Motion-JPEG clips, config persistence/migration, preferences, Reset Data, and system suspension. - New CI workflow runs build, tests, and packaging on every push and pull request. - Performance harness: `os_signpost` intervals, `scripts/perf/measure.sh`, generated fixtures. No runtime behaviour change unless `LIVELY_PERF_MODE` is set. +- Measure isolation: `LIVELY_APPLICATION_SUPPORT` and `LIVELY_CONFIG_FILE` redirect Application Support. Setting `HOME` does not move `userDomainMask` on current macOS. ## [1.2.0] - 2026-07-14 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 986d2d6..475bf41 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ swift build # build swift test # run tests (full Xcode) ./scripts/check_hygiene.sh # paths, secrets, duplicates, plist and script syntax ./package.sh # build an ad-hoc signed Lively.app (output in $LIVELY_OUTPUT_DIR) -scripts/perf/measure.sh idle 30 # isolated-HOME resource sample; see scripts/perf/README.md +scripts/perf/measure.sh idle 30 # isolated Application Support sample; see scripts/perf/README.md ``` `swiftlint` (config in `.swiftlint.yml`) runs in CI; run it locally if installed. diff --git a/Sources/Lively/Logic/AppPreferences.swift b/Sources/Lively/Logic/AppPreferences.swift index 07bbaf2..ae915ae 100644 --- a/Sources/Lively/Logic/AppPreferences.swift +++ b/Sources/Lively/Logic/AppPreferences.swift @@ -10,7 +10,7 @@ import AppKit /// assignments) so preference toggles stay lightweight and independent. @MainActor public final class AppPreferences: ObservableObject { - public static let shared = AppPreferences() + public static let shared = AppPreferences(defaults: AppSupportPaths.perfUserDefaults()) /// Hard floor: wallpapers always pause on battery at or below this level. nonisolated public static let forcedBatteryPausePercent: Double = 25 @@ -208,7 +208,7 @@ public final class AppPreferences: ObservableObject { didSet { defaults.set(hasSeenSpacesCoach, forKey: Keys.hasSeenSpacesCoach) } } - public init(defaults: UserDefaults = .standard) { + public init(defaults: UserDefaults = AppSupportPaths.perfUserDefaults()) { self.defaults = defaults func seedDefault(_ key: String, _ value: Any) { diff --git a/Sources/Lively/Logic/AppSupportPaths.swift b/Sources/Lively/Logic/AppSupportPaths.swift new file mode 100644 index 0000000..8ee42a9 --- /dev/null +++ b/Sources/Lively/Logic/AppSupportPaths.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Resolves Application Support. `LIVELY_APPLICATION_SUPPORT` wins so +/// `scripts/perf/measure.sh` can isolate I/O. Setting `HOME` is not enough: +/// `FileManager.SearchPathDomainMask.userDomainMask` still points at the +/// real user Library on current macOS. +public enum AppSupportPaths { + public static func applicationSupportDirectory( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default + ) -> URL { + if let override = environment["LIVELY_APPLICATION_SUPPORT"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty { + return URL(fileURLWithPath: override, isDirectory: true) + } + return fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory.appendingPathComponent("Application Support", isDirectory: true) + } + + public static func livelyDirectory( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default + ) -> URL { + applicationSupportDirectory(environment: environment, fileManager: fileManager) + .appendingPathComponent("Lively", isDirectory: true) + } + + public static func perfUserDefaults( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> UserDefaults { + let mode = environment["LIVELY_PERF_MODE"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !mode.isEmpty, mode != "off" else { return .standard } + let suite = "com.lively.app.perf" + if let defaults = UserDefaults(suiteName: suite) { + defaults.removePersistentDomain(forName: suite) + return defaults + } + return .standard + } +} diff --git a/Sources/Lively/Logic/ConfigStore.swift b/Sources/Lively/Logic/ConfigStore.swift index 1d9b3d9..c78531b 100644 --- a/Sources/Lively/Logic/ConfigStore.swift +++ b/Sources/Lively/Logic/ConfigStore.swift @@ -51,19 +51,18 @@ public class ConfigStore: ObservableObject { public init(configFileURL: URL? = nil) { if let provided = configFileURL { self.configFileURL = provided + } else if let envPath = ProcessInfo.processInfo.environment["LIVELY_CONFIG_FILE"], + !envPath.isEmpty { + self.configFileURL = URL(fileURLWithPath: envPath) } else { - if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { - let dir = appSupport.appendingPathComponent("Lively") - do { - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - } catch { - LivelyLogger.config.error("Failed to create config directory: \(error.localizedDescription)") - errors.send(.directoryCreationFailed(error)) - } - self.configFileURL = dir.appendingPathComponent("config_v2.json") - } else { - self.configFileURL = URL(fileURLWithPath: "/tmp/lively_config.json") + let dir = AppSupportPaths.livelyDirectory() + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } catch { + LivelyLogger.config.error("Failed to create config directory: \(error.localizedDescription)") + errors.send(.directoryCreationFailed(error)) } + self.configFileURL = dir.appendingPathComponent("config_v2.json") } load() } diff --git a/Sources/Lively/Logic/Diagnostics.swift b/Sources/Lively/Logic/Diagnostics.swift index 3fefb77..6abe50b 100644 --- a/Sources/Lively/Logic/Diagnostics.swift +++ b/Sources/Lively/Logic/Diagnostics.swift @@ -97,9 +97,8 @@ public struct RunMarker: Sendable { } public static var `default`: RunMarker { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? FileManager.default.temporaryDirectory - return RunMarker(url: base.appendingPathComponent("Lively", isDirectory: true).appendingPathComponent("running.marker")) + let dir = AppSupportPaths.livelyDirectory() + return RunMarker(url: dir.appendingPathComponent("running.marker")) } /// Call once at launch. Returns true when the previous run left its marker behind. diff --git a/Sources/Lively/Logic/WallpaperLibraryManager.swift b/Sources/Lively/Logic/WallpaperLibraryManager.swift index 542ab43..ec7cb94 100644 --- a/Sources/Lively/Logic/WallpaperLibraryManager.swift +++ b/Sources/Lively/Logic/WallpaperLibraryManager.swift @@ -22,6 +22,9 @@ public final class WallpaperLibraryManager: ObservableObject { if let libraryDir { self.libraryDir = libraryDir + } else if ProcessInfo.processInfo.environment["LIVELY_APPLICATION_SUPPORT"] != nil { + self.libraryDir = AppSupportPaths.livelyDirectory() + .appendingPathComponent("Library", isDirectory: true) } else if let support = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { self.libraryDir = support.appendingPathComponent("Lively/Library", isDirectory: true) } else { diff --git a/Tests/LivelyTests/AppSupportPathsTests.swift b/Tests/LivelyTests/AppSupportPathsTests.swift new file mode 100644 index 0000000..2053873 --- /dev/null +++ b/Tests/LivelyTests/AppSupportPathsTests.swift @@ -0,0 +1,29 @@ +import Testing +import Foundation +@testable import LivelyCore + +struct AppSupportPathsTests { + @Test func overrideBeatsUserDomain() { + let dir = AppSupportPaths.applicationSupportDirectory( + environment: ["LIVELY_APPLICATION_SUPPORT": "/tmp/lively-perf-support"] + ) + #expect(dir.path == "/tmp/lively-perf-support") + let lively = AppSupportPaths.livelyDirectory( + environment: ["LIVELY_APPLICATION_SUPPORT": "/tmp/lively-perf-support"] + ) + #expect(lively.path == "/tmp/lively-perf-support/Lively") + } + + @Test func emptyOverrideFallsBack() { + let dir = AppSupportPaths.applicationSupportDirectory(environment: ["LIVELY_APPLICATION_SUPPORT": " "]) + #expect(!dir.path.contains("lively-perf-support")) + #expect(dir.path.contains("Application Support")) + } + + @Test func perfDefaultsUseIsolatedSuite() { + let defaults = AppSupportPaths.perfUserDefaults(environment: ["LIVELY_PERF_MODE": "idle"]) + #expect(defaults !== UserDefaults.standard) + let off = AppSupportPaths.perfUserDefaults(environment: [:]) + #expect(off === UserDefaults.standard) + } +} diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index b9b0dff..8e16074 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -43,29 +43,46 @@ Known gap: the wallpaper video itself does not yet react to Reduce Motion ## P0 baseline Recorded 2026-10-13 on this machine. Host: Apple M1, macOS 27.0.1. -Release `.app` from `./package.sh` (Command Line Tools). Isolated `HOME`. -Popover closed (`startMinimized` default). Fixture: -`perf/fixtures/1080p30-hevc-silent.mp4` (2 s loop). Sample window 20 s -after a 5–8 s settle. Parent commit at measure time: `85e332a` plus this -P0 tree. +Release `.app` from `./package.sh` (Command Line Tools) after the +Application Support isolation fix. Popover closed (`startMinimized` +default). Fixture: `perf/fixtures/1080p30-hevc-silent.mp4` (2 s loop). +Sample window 30 s after a 5–8 s settle. Git parent at measure time: +`0b93d3b`; the packaged binary also included Application Support +isolation (`LIVELY_APPLICATION_SUPPORT`, `LIVELY_CONFIG_FILE`, perf +UserDefaults suite). JSON: +`perf/results/20261013T141319Z-0b93d3b-idle.json` and the matching +playing / paused files (gitignored). + +Isolation: setting `HOME` does **not** redirect +`FileManager.SearchPathDomainMask.userDomainMask`. The harness must set +`LIVELY_APPLICATION_SUPPORT` and `LIVELY_CONFIG_FILE`. Proof on this +run: user `config_v2.json` checksum `2558921003 1531` was unchanged. + +Discard earlier tables from this day. The 20 s `85e332a` run and the +60 s `0b93d3b` run used `HOME` only; the 60 s idle sample was actually +playing a user wallpaper (~3% CPU, AVPlayer in `sample(1)`). `footprint` values are `phys_footprint` (mebibytes = bytes / 1024²). `top` %CPU is macOS one-core percent (can exceed 100% on many cores). | Metric | Idle (no wallpaper) | Playing 1080p30 HEVC | Paused | Budget | |---|---|---|---|---| -| `footprint` resident | 13 MB (peak 14) | 18 MB (peak 18) | 17 MB (peak 18) | 20 / 60 / 25 MB | -| `top` CPU % | 0.0 (all 4 samples) | 2.325 avg; last three samples 3.0, 3.1, 3.2 | 0.0 | 0.0 / 1.5 / 0.0 | -| Threads (`top` #TH) | 3–8 | 18–19 | (see raw) | — | +| `footprint` resident | 14 MB (peak 15) | 21 MB (peak 22) | 20 MB (peak 21) | 20 / 60 / 25 MB | +| `top` CPU % | 0.0 (all 6 samples) | 4.783 avg; last five 5.5, 5.7, 6.1, 5.5, 5.9 | 0.133 avg; samples 0.0, 0.3, 0.0, 0.5, 0.0, 0.0 | 0.0 / 1.5 / 0.0 | +| Threads (`top` #TH) | 9 → 3 | 25–27 | 8–10 | — | | Idle wake-ups / s | — | — | — | <= 1 idle | | Energy Impact | — | — | — | 0 / 3 / 0 | -| Release executable | 4,364,304 bytes (4.16 MiB) | same | same | track; stretch 2 MB | +| Release executable | 4,366,496 bytes (4.16 MiB) | same | same | track; stretch 2 MB | -RAM is already inside the idle/playing/paused budgets. Playing CPU is -**above** the 1.5% budget on this 20 s window (~3% once decode is up). -Paused RAM stays ~4 MB above idle and still shows a CoreMedia memory pool, -which is consistent with hypothesis B (player left resident) — P1 must -confirm with Allocations. +`sample(1)` on the same PIDs: idle is CFRunLoop waiting (CoreMedia +linked, not on-CPU); playing is AVPlayer / CoreMedia decode; paused +still has `figThreadMain` waiting on Fig semaphores. + +RAM is inside the idle/playing/paused budgets. Playing CPU is **above** +the 1.5% budget once decode is up (~5.7% of one core on this 30 s +window). Paused RAM stays ~6 MB above idle with CoreMedia threads still +resident — a **hint** for hypothesis B, not a P1 confirmation. Paused +CPU is not a clean 0.0 on this window. ### Not measured here (need the operator) diff --git a/docs/perf-findings.md b/docs/perf-findings.md index da852d4..06966d7 100644 --- a/docs/perf-findings.md +++ b/docs/perf-findings.md @@ -13,11 +13,18 @@ table as unread code, not measured fact. (`com.lively.app` / `Performance`). - `scripts/perf/measure.sh` writes JSON under `perf/results/`. Null fields mean the sampler did not run. Do not fill them in by hand. -- Baseline numbers are in `docs/PERFORMANCE.md`. Playing CPU (~3% once - decode is up) misses the 1.5% budget. Paused footprint 17 MB vs idle 13 MB - with a CoreMedia pool still mapped — treat as a **hint** for hypothesis B, - not a P1 confirmation. Wake-ups and Energy Impact were not sampled - (powermetrics needs sudo). +- Isolation: `HOME` does not move `userDomainMask`. Harness sets + `LIVELY_APPLICATION_SUPPORT`, `LIVELY_CONFIG_FILE`, and a throwaway + `com.lively.app.perf` UserDefaults suite. `sample(1)` is captured when + present (CLT; no Xcode). +- Canonical baseline is the isolated 30 s run in `docs/PERFORMANCE.md`. + Playing CPU (~5.7% once decode is up) misses the 1.5% budget. Paused + footprint 20 MB vs idle 14 MB, with `figThreadMain` still waiting — + treat as a **hint** for hypothesis B, not a P1 confirmation. Wake-ups + and Energy Impact were not sampled (powermetrics needs sudo). +- Earlier same-day tables that used `HOME` only are invalid. One leaked + run wrote the fixture into the real `config_v2.json`; isolation is now + a P0 correctness requirement. ## P1 (not started) diff --git a/scripts/perf/README.md b/scripts/perf/README.md index 4d5c251..b051e1a 100644 --- a/scripts/perf/README.md +++ b/scripts/perf/README.md @@ -30,10 +30,21 @@ scripts/perf/measure.sh paused 30 ``` Each run packages a release `.app` (skip with `LIVELY_PERF_SKIP_BUILD=1`), -launches it with an isolated `HOME`, samples `top` and `footprint`, and writes -`perf/results/--.json`. Playing and paused assign the 1080p30 -HEVC fixture to every visible Space via `LIVELY_PERF_*` env vars; they will -cover the desktop until the script stops the process. +launches the bundle executable (not via `open`) with: + +- `HOME` — temp dir +- `LIVELY_APPLICATION_SUPPORT` — `$HOME/Library/Application Support` +- `LIVELY_CONFIG_FILE` — that tree’s `Lively/config_v2.json` +- `LIVELY_PERF_MODE` / `LIVELY_PERF_FIXTURE` + +`HOME` alone is not enough: `FileManager.userDomainMask` still points at +the real user Library on current macOS. Without the `LIVELY_*` overrides +the harness writes the live config. + +Samples `top`, `footprint`, and `sample(1)` (CLT). Writes +`perf/results/--.json`. Playing and paused assign the +1080p30 HEVC fixture to every visible Space; they cover the desktop until +the script stops the process. ### powermetrics (sudo) diff --git a/scripts/perf/measure.sh b/scripts/perf/measure.sh index 0734c22..0abfb02 100755 --- a/scripts/perf/measure.sh +++ b/scripts/perf/measure.sh @@ -10,7 +10,9 @@ # LIVELY_PERF_FIXTURE override clip for playing/paused # LIVELY_PERF_HOME isolated HOME (default a temp dir) # -# Does not touch ~/Library/Application Support/Lively. Isolated HOME only. +# Does not touch ~/Library/Application Support/Lively. HOME is not enough: +# userDomainMask still points at the real Library. The launch also sets +# LIVELY_APPLICATION_SUPPORT and LIVELY_CONFIG_FILE. # # powermetrics needs sudo. If sudo is not available the JSON records # powermetrics_error and the other samplers still run. @@ -71,14 +73,18 @@ if [[ "$MODE" == "playing" || "$MODE" == "paused" ]]; then fi PERF_HOME="${LIVELY_PERF_HOME:-$(mktemp -d /tmp/lively-perf-home.XXXXXX)}" -mkdir -p "$PERF_HOME" +SUPPORT="${PERF_HOME}/Library/Application Support" +mkdir -p "$SUPPORT/Lively" echo "measure: mode=$MODE duration=${DURATION}s home=$PERF_HOME" echo "measure: launching $BIN" -# Launch the bundle executable so HOME and LIVELY_PERF_* inherit. +# Launch the bundle executable so env vars inherit. # `open` goes through LaunchServices and drops those variables. +# HOME is not enough: userDomainMask still points at the real Library. export HOME="$PERF_HOME" +export LIVELY_APPLICATION_SUPPORT="$SUPPORT" +export LIVELY_CONFIG_FILE="$SUPPORT/Lively/config_v2.json" export LIVELY_PERF_MODE="$MODE" if [[ -n "$FIXTURE" ]]; then export LIVELY_PERF_FIXTURE="$FIXTURE" @@ -109,6 +115,17 @@ echo "measure: sampling top (${TOP_SAMPLES} x 5s)" top -l "$TOP_SAMPLES" -s 5 -pid "$PID" -stats pid,cpu,mem,rsize,threads \ > "$RAW_DIR/top.txt" 2>/dev/null || true +# sample(1) works with Command Line Tools. xctrace/Instruments need full Xcode. +SAMPLE_SECS=10 +if [[ "$DURATION" -lt 15 ]]; then + SAMPLE_SECS=5 +fi +if command -v sample >/dev/null 2>&1; then + echo "measure: sample ${SAMPLE_SECS}s of pid $PID" + sample "$PID" "$SAMPLE_SECS" 1 -mayDie -file "$RAW_DIR/sample.txt" \ + >"$RAW_DIR/sample.err" 2>&1 || true +fi + echo "measure: footprint" if footprint -p "$PID" > "$RAW_DIR/footprint.txt" 2>"$RAW_DIR/footprint.err"; then :