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
145 changes: 145 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
name: Benchmark

# Window-state resource benchmark (scripts/benchmark.py). Pushes to main
# publish a `benchmark-results` artifact that becomes the baseline; PR runs
# download the latest main baseline and post a comparison table to the job
# summary and a (single, updated-in-place) PR comment.

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: benchmark-${{ github.ref }}
cancel-in-progress: true

jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
outputs:
swift: ${{ steps.filter.outputs.swift }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
swift:
- "**/*.swift"
- "project.yml"
- "mise.toml"
- "scripts/**"
- ".github/workflows/benchmark.yml"

benchmark:
name: Window states
needs: changes
if: needs.changes.outputs.swift == 'true'
runs-on: macos-26
timeout-minutes: 30
permissions:
contents: read
actions: read # download the baseline artifact from a main run
pull-requests: write # upsert the comparison comment
issues: write # create/apply the benchmark:regression / benchmark:improvement labels
steps:
- name: Checkout
uses: actions/checkout@v7

- uses: jdx/mise-action@v4

- name: Setup GhosttyKit
run: mise run setup --verbose
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Build and benchmark
run: mise run bench --verbose

- name: Upload results
uses: actions/upload-artifact@v4
with:
name: benchmark-results
path: build/benchmark/results.json

- name: Upload failure diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: benchmark-diagnostics
path: build/benchmark/diagnostics/
if-no-files-found: ignore

- name: Fetch main baseline
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Walk recent successful main runs: a run whose benchmark job was
# skipped by the paths filter has no artifact, so try until one
# downloads.
for run_id in $(gh run list --workflow benchmark.yml --branch main --status success \
--limit 20 --json databaseId --jq '.[].databaseId'); do
if gh run download "$run_id" --name benchmark-results --dir baseline 2>/dev/null; then
echo "baseline: run $run_id"
break
fi
done
[ -f baseline/results.json ] || echo "no main baseline found; reporting absolute values"

- name: Report
run: |
args=(build/benchmark/results.json --verdict build/benchmark/verdict.json)
[ -f baseline/results.json ] && args+=(--baseline baseline/results.json)
python3 scripts/benchmark.py report "${args[@]}" > report.md
cat report.md >> "$GITHUB_STEP_SUMMARY"

- name: Label PR on significant deltas
if: github.event_name == 'pull_request'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
verdict=build/benchmark/verdict.json
regression=$(jq -r '.regression' "$verdict" 2>/dev/null || echo false)
improvement=$(jq -r '.improvement' "$verdict" 2>/dev/null || echo false)
repo='${{ github.repository }}'
pr='${{ github.event.pull_request.number }}'
gh label create benchmark:regression --repo "$repo" --force \
--color D93F0B --description "CI benchmark: significant resource regression vs main"
gh label create benchmark:improvement --repo "$repo" --force \
--color 0E8A16 --description "CI benchmark: significant resource improvement vs main"
# Keep labels in sync with the latest run: apply when the verdict
# says so, remove a stale one when a newer push clears it.
if [ "$regression" = "true" ]; then
gh pr edit "$pr" --repo "$repo" --add-label benchmark:regression
else
gh pr edit "$pr" --repo "$repo" --remove-label benchmark:regression || true
fi
if [ "$improvement" = "true" ]; then
gh pr edit "$pr" --repo "$repo" --add-label benchmark:improvement
else
gh pr edit "$pr" --repo "$repo" --remove-label benchmark:improvement || true
fi

- name: Comment on PR
if: github.event_name == 'pull_request'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
marker='<!-- macterm-benchmark -->'
printf '%s\n\n' "$marker" > comment.md
cat report.md >> comment.md
repo='${{ github.repository }}'
pr='${{ github.event.pull_request.number }}'
comment_id=$(gh api "repos/$repo/issues/$pr/comments" --paginate \
--jq "[.[] | select(.body | startswith(\"$marker\"))][0].id // empty")
if [ -n "$comment_id" ]; then
gh api -X PATCH "repos/$repo/issues/comments/$comment_id" -F body=@comment.md --silent
else
gh api "repos/$repo/issues/$pr/comments" -F body=@comment.md --silent
fi
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mise run format # Auto-fix formatting with swiftformat
mise run lint # swiftlint
mise run test # Run the test suite
mise run build # Release build + DMG
mise run bench # Release build + window-state resource benchmark
```

`format`, `lint`, and `test` show a spinner and print output only on failure. **Always pass `--verbose`** (e.g. `mise run test --verbose`) to stream the raw output.
Expand All @@ -27,6 +28,12 @@ Auto-updates ship via [Sparkle](https://sparkle-project.org/) — daily backgrou

Tag-pushed builds release via `.github/workflows/release.yml`, which needs repo secrets: `GH_PAT` (contents:read on `thdxg/ghostty`, downloads GhosttyKit), `SPARKLE_ED_PUBLIC_KEY` (baked into `Info.plist`), and `SPARKLE_ED_PRIVATE_KEY` (signs each DMG — **back it up; losing it means users can't auto-update to any further release**). The workflow appends an `<item>` to `appcast.xml` on `gh-pages` (served at `https://thdxg.github.io/macterm/appcast.xml`, the feed URL in `Info.plist`) along with a per-version notes page rendered from the GitHub Release body (`publish-appcast.sh`).

## Benchmarks

`.github/workflows/benchmark.yml` measures resource usage (CPU-time delta, RSS, and — best-effort via `powermetrics` — wakeups/s) across three window states: focused, open-but-unfocused, and minimized. Pushes to `main` upload a `benchmark-results` artifact that serves as the baseline; PR runs download the latest main baseline and post a comparison table to the job summary and a single updated-in-place PR comment. Runs land on different shared runners, so cross-run deltas are noisy — a delta is flagged only when it clears both ±25% _and_ the metric's absolute noise floor (`METRICS` in `benchmark.py`). Significant deltas add the `benchmark:regression` / `benchmark:improvement` PR label (removed again when a later push clears them) and the comment explains which metrics triggered it.

The harness (`scripts/benchmark.py`, invoked by `mise run bench`) launches the Release app under a throwaway `$HOME` (hermetic: no user ghostty config, fresh App Support) with `MACTERM_BENCHMARK=1`, which arms `BenchmarkControl` — Darwin-notification remote control (`notifyutil -p com.thdxg.macterm.bench.<cmd>`; commands: `open-project`, `activate`, `minimize`, `restore`). Darwin notifications and `open`-based activation need no TCC grants, which is what lets a headless CI runner script window states at all (AppleScript/synthetic keys would need an Accessibility grant). Benchmark mode also skips the notification-permission prompt (it would steal key focus mid-measurement) and doesn't start the Sparkle updater — a bench-built Release app carries the placeholder EdDSA key, so Sparkle's failed-start alert would otherwise block the launch run loop until someone clicks OK.

## Architecture

```
Expand Down
146 changes: 146 additions & 0 deletions Macterm/App/BenchmarkControl.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import AppKit
import os

private let logger = Logger(subsystem: appBundleID, category: "BenchmarkControl")

/// CI-only remote control for the window-state benchmark
/// (`.github/workflows/benchmark.yml` → `scripts/benchmark.py`).
///
/// When the app is launched with `MACTERM_BENCHMARK=1` it listens for Darwin
/// notifications (posted from a shell with `notifyutil -p <name>`) and drives
/// itself through the benchmarked window states. Darwin notifications need no
/// TCC grant — unlike AppleScript/System Events or synthetic key events — so a
/// headless CI runner can script the app without touching the SIP-protected
/// permission database.
@MainActor
enum BenchmarkControl {
static let isEnabled = ProcessInfo.processInfo.environment["MACTERM_BENCHMARK"] == "1"

// Strong references are fine here: both objects live for the app's
// lifetime, and benchmark mode never releases them.
private static var appState: AppState?
private static var projectStore: ProjectStore?

/// An `openProject` command can arrive before `connect`: SwiftUI only
/// creates the window (whose `onAppear` wires the state objects) once
/// macOS grants activation, and the harness polls the command until
/// then. Park it instead of dropping it.
private static var pendingOpenProject = false

/// Called by `AppDelegate.installResponders` once the state objects exist.
static func connect(appState: AppState, projectStore: ProjectStore) {
self.appState = appState
self.projectStore = projectStore
if pendingOpenProject {
pendingOpenProject = false
openProject()
}
}

private static let notificationPrefix = "com.thdxg.macterm.bench."

/// Held for the app's lifetime in benchmark mode. Without it, App Nap
/// suspends the backgrounded/occluded app — queued Darwin notifications
/// go undelivered and, on a busy desktop, the window never even appears.
/// It also makes the numbers measure what we care about: the app's own
/// timer/render/wakeup behavior, not the OS's nap throttling masking a
/// regression.
private static var activity: NSObjectProtocol?

private enum Command: String, CaseIterable {
case openProject = "open-project"
case activate
case minimize
case restore
}

static func install() {
guard isEnabled else { return }
activity = ProcessInfo.processInfo.beginActivity(
options: .userInitiatedAllowingIdleSystemSleep,
reason: "window-state benchmark"
)
nudgeActivationUntilWindowExists()
let center = CFNotificationCenterGetDarwinNotifyCenter()
for command in Command.allCases {
CFNotificationCenterAddObserver(
center,
nil,
{ _, _, name, _, _ in
guard let name else { return }
let raw = name.rawValue as String
DispatchQueue.main.async {
MainActor.assumeIsolated { BenchmarkControl.handle(raw) }
}
},
(notificationPrefix + command.rawValue) as CFString,
nil,
.deliverImmediately
)
}
logger.info("benchmark control listening (MACTERM_BENCHMARK=1)")
}

/// SwiftUI doesn't create the WindowGroup window until the app becomes
/// active, and a single post-launch activation request can be denied
/// (cooperative activation) or arrive before the session is ready on a
/// fresh CI runner. Re-request every second until the window exists.
private static func nudgeActivationUntilWindowExists(attempt: Int = 0) {
if mainWindow != nil {
logger.info("bench window exists after \(attempt, privacy: .public) activation nudges")
return
}
guard attempt < 120 else {
logger.error("bench window never appeared; giving up activation nudges")
return
}
NSApp.activate()
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
nudgeActivationUntilWindowExists(attempt: attempt + 1)
}
}

private static func handle(_ rawName: String) {
guard let command = Command(rawValue: String(rawName.dropFirst(notificationPrefix.count))) else { return }
logger.info("bench command: \(command.rawValue, privacy: .public)")
switch command {
case .openProject:
openProject()
case .activate:
NSApp.activate()
mainWindow?.makeKeyAndOrderFront(nil)
case .minimize:
mainWindow?.miniaturize(nil)
case .restore:
mainWindow?.deminiaturize(nil)
NSApp.activate()
mainWindow?.makeKeyAndOrderFront(nil)
}
}

/// Select (creating if needed) a project at `MACTERM_BENCHMARK_DIR` (or
/// `$HOME`) so the window hosts a live terminal pane. A fresh CI runner
/// otherwise sits on the WelcomeView, and the benchmark would measure an
/// app with no surface, no shell, and no foreground-process poll.
private static func openProject() {
guard let appState, let projectStore else {
logger.info("bench open-project queued until state objects exist")
pendingOpenProject = true
return
}
let path = ProcessInfo.processInfo.environment["MACTERM_BENCHMARK_DIR"] ?? NSHomeDirectory()
if let existing = projectStore.projects.first(where: { $0.path == path }) {
appState.selectProject(existing)
return
}
let project = Project(name: "Benchmark", path: path, sortOrder: projectStore.projects.count)
projectStore.add(project)
appState.selectProject(project)
}

/// Same filter as `AppDelegate.reopenIfNeeded`: the single main window is
/// the only non-panel window (panels: quick terminal, settings).
private static var mainWindow: NSWindow? {
NSApp.windows.first { !($0 is NSPanel) }
}
}
11 changes: 10 additions & 1 deletion Macterm/App/MactermApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return
}
UNUserNotificationCenter.current().delegate = NotificationHandler.shared
NotificationHandler.shared.requestAuthorization()
if BenchmarkControl.isEnabled {
// Under the CI benchmark, the notification-permission alert would
// steal key focus mid-measurement and nobody is there to answer it.
BenchmarkControl.install()
} else {
NotificationHandler.shared.requestAuthorization()
}
NSApp.setActivationPolicy(.regular)
NSApp.activate()
_ = GhosttyApp.shared
Expand Down Expand Up @@ -254,6 +260,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func installResponders(appState: AppState, projectStore: ProjectStore) {
guard !hasInstalledResponders else { return }
hasInstalledResponders = true
if BenchmarkControl.isEnabled {
BenchmarkControl.connect(appState: appState, projectStore: projectStore)
}
KeyRouter.shared.register(PaletteResponder(appState: appState))
KeyRouter.shared.register(QuickTerminalResponder())
let mainResponder = MainAppResponder(appState: appState, projectStore: projectStore)
Expand Down
7 changes: 6 additions & 1 deletion Macterm/App/Updater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,16 @@ final class Updater: ObservableObject {
// the production EdDSA key, so it pops an "Unable to Check For
// Updates" dialog on every launch. Start the controller without
// kicking off update checks; release builds still auto-check.
//
// Benchmark mode is a Release build with the placeholder key
// (scripts/bench.sh builds without SPARKLE_ED_PUBLIC_KEY), so the
// updater fails to start and its app-modal alert blocks the run
// loop at launch — on CI nobody can click OK.
let startUpdater: Bool = {
#if DEBUG
return false
#else
return true
return !BenchmarkControl.isEnabled
#endif
}()
let updaterDelegate = updaterDelegate
Expand Down
16 changes: 16 additions & 0 deletions Macterm/Persistence/FileStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ enum FileStorage {
}

static func appSupportDirectory() -> URL {
// The benchmark harness (scripts/benchmark.py) points this at a
// throwaway directory so a benchmark run can't read or pollute the
// real app data. An env override is required because
// `.applicationSupportDirectory` resolves via the user record, not
// `$HOME` — a temp `$HOME` doesn't isolate it.
if let benchDir = ProcessInfo.processInfo.environment["MACTERM_BENCHMARK_DATA_DIR"],
!benchDir.isEmpty
{
let dir = URL(fileURLWithPath: benchDir, isDirectory: true)
try? FileManager.default.createDirectory(
at: dir,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
return dir
}
guard let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
Expand Down
12 changes: 12 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,18 @@ run = """
fi
"""

[tasks.bench]
description = "Build the release app and run the window-state benchmark"
usage = 'flag "-v --verbose" help="Stream raw output instead of the spinner"'
run = """
source scripts/_lib.sh
if [[ "$usage_verbose" == "true" ]]; then
./scripts/bench.sh
else
run_step "Benchmarking..." ./scripts/bench.sh
fi
"""

[tasks.clean]
description = "Clean build artifacts"
run = "rm -rf build Macterm.xcodeproj"
Expand Down
Loading
Loading