From 78a38b02f73be05153b93ddc63bade3ce5e6ab05 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 27 Apr 2026 20:14:13 +0300 Subject: [PATCH] test(cmux): add rapid spawn kill IOSurface fixture --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + cmuxTests/RapidSpawnKillFixtureTests.swift | 187 +++++++++++++++++++++ tests/fixtures/README.md | 25 +++ tests/fixtures/rapid_spawn_kill.sh | 169 +++++++++++++++++++ 4 files changed, 385 insertions(+) create mode 100644 cmuxTests/RapidSpawnKillFixtureTests.swift create mode 100644 tests/fixtures/README.md create mode 100755 tests/fixtures/rapid_spawn_kill.sh diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 256ef3f34f5c..f6f8b19d94cc 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -99,6 +99,7 @@ F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; }; + F9200000A1B2C3D4E5F60718 /* RapidSpawnKillFixtureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9200001A1B2C3D4E5F60718 /* RapidSpawnKillFixtureTests.swift */; }; FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */; }; A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; }; A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; @@ -251,6 +252,7 @@ F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = ""; }; + F9200001A1B2C3D4E5F60718 /* RapidSpawnKillFixtureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RapidSpawnKillFixtureTests.swift; sourceTree = ""; }; FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = ""; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; @@ -492,6 +494,7 @@ F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, A5009002 /* MCPServerTests.swift */, F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, + F9200001A1B2C3D4E5F60718 /* RapidSpawnKillFixtureTests.swift */, FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */, A5008380 /* BrowserFindJavaScriptTests.swift */, A5008382 /* CommandPaletteSearchEngineTests.swift */, @@ -738,6 +741,7 @@ F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, A5009003 /* MCPServerTests.swift in Sources */, F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */, + F9200000A1B2C3D4E5F60718 /* RapidSpawnKillFixtureTests.swift in Sources */, FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, diff --git a/cmuxTests/RapidSpawnKillFixtureTests.swift b/cmuxTests/RapidSpawnKillFixtureTests.swift new file mode 100644 index 000000000000..063f015019a7 --- /dev/null +++ b/cmuxTests/RapidSpawnKillFixtureTests.swift @@ -0,0 +1,187 @@ +import XCTest + +final class RapidSpawnKillFixtureTests: XCTestCase { + private struct ProcessResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool + } + + func testRapidSpawnKillFixtureKeepsIOSurfaceFootprintUnderBudget() throws { + let repositoryRoot = try Self.repositoryRoot() + let fixtureURL = repositoryRoot + .appendingPathComponent("tests/fixtures/rapid_spawn_kill.sh") + + XCTAssertTrue( + FileManager.default.isExecutableFile(atPath: fixtureURL.path), + "Expected executable fixture at \(fixtureURL.path)" + ) + + let appURL = try Self.cmuxAppURL() + let thresholdMB = ProcessInfo.processInfo.environment["CMUX_RAPID_SPAWN_KILL_IOSURFACE_LIMIT_MB"] + .flatMap(Double.init) ?? 50 + let result = runProcess( + executablePath: "/usr/bin/leaks", + arguments: [ + "--atExit", + "--", + "/bin/bash", + fixtureURL.path, + ], + environment: [ + "CMUX_RAPID_SPAWN_KILL_APP_PATH": appURL.path, + "CMUX_RAPID_SPAWN_KILL_ITERATIONS": "3", + "CMUX_RAPID_SPAWN_KILL_FORCE_WINDOW": "1", + "CMUX_RAPID_SPAWN_KILL_READY_TIMEOUT_MS": "8000", + ], + timeout: 90 + ) + + let combinedOutput = [result.stdout, result.stderr] + .filter { !$0.isEmpty } + .joined(separator: "\n") + let attachment = XCTAttachment(string: combinedOutput) + attachment.name = "rapid-spawn-kill-leaks-output" + attachment.lifetime = .keepAlways + add(attachment) + + XCTAssertFalse(result.timedOut, combinedOutput) + XCTAssertEqual(result.status, 0, combinedOutput) + + let measuredMB = try XCTUnwrap( + Self.parseIOSurfaceFootprintMB(from: combinedOutput), + "Expected fixture output to include 'VM: IOSurface = MB'. Output:\n\(combinedOutput)" + ) + XCTAssertGreaterThan( + measuredMB, + 0, + "Expected rapid_spawn_kill.sh to force an IOSurface allocation. Output:\n\(combinedOutput)" + ) + XCTAssertLessThanOrEqual( + measuredMB, + thresholdMB, + "VM: IOSurface exceeded \(thresholdMB) MB after rapid spawn/kill loop. Output:\n\(combinedOutput)" + ) + } + + private static func repositoryRoot(filePath: String = #filePath) throws -> URL { + var url = URL(fileURLWithPath: filePath) + while url.path != "/" { + let candidate = url + .deletingLastPathComponent() + .appendingPathComponent("GhosttyTabs.xcodeproj") + if FileManager.default.fileExists(atPath: candidate.path) { + return url.deletingLastPathComponent() + } + url.deleteLastPathComponent() + } + throw XCTSkip("Unable to locate repository root from \(filePath)") + } + + private static func cmuxAppURL() throws -> URL { + let environment = ProcessInfo.processInfo.environment + + if let override = environment["CMUX_RAPID_SPAWN_KILL_APP_PATH"], !override.isEmpty { + let url = URL(fileURLWithPath: override) + if FileManager.default.fileExists(atPath: url.path) { + return url + } + } + + if let testHost = environment["TEST_HOST"], !testHost.isEmpty, + let appURL = enclosingAppBundle(for: URL(fileURLWithPath: testHost)) { + return appURL + } + + if let builtProductsDir = environment["BUILT_PRODUCTS_DIR"], !builtProductsDir.isEmpty { + let appURL = URL(fileURLWithPath: builtProductsDir) + .appendingPathComponent("cmux DEV.app") + if FileManager.default.fileExists(atPath: appURL.path) { + return appURL + } + } + + if let appURL = enclosingAppBundle(for: Bundle.main.bundleURL) { + return appURL + } + + throw XCTSkip("Unable to locate built cmux app bundle for rapid_spawn_kill.sh") + } + + private static func enclosingAppBundle(for url: URL) -> URL? { + var current = url + while current.path != "/" { + if current.pathExtension == "app" && FileManager.default.fileExists(atPath: current.path) { + return current + } + current.deleteLastPathComponent() + } + return nil + } + + private static func parseIOSurfaceFootprintMB(from output: String) -> Double? { + let pattern = #"VM: IOSurface\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*MB"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(output.startIndex.. ProcessResult { + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + var mergedEnvironment = ProcessInfo.processInfo.environment + for (key, value) in environment { + mergedEnvironment[key] = value + } + process.environment = mergedEnvironment + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + } catch { + return ProcessResult( + status: -1, + stdout: "", + stderr: String(describing: error), + timedOut: false + ) + } + + let exitSignal = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exitSignal.signal() + } + + let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut + if timedOut { + process.terminate() + _ = exitSignal.wait(timeout: .now() + 2) + } + + return ProcessResult( + status: process.terminationStatus, + stdout: String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "", + stderr: String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "", + timedOut: timedOut + ) + } +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 000000000000..a80e9a03a381 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,25 @@ +# cmux Test Fixtures + +## rapid_spawn_kill.sh + +`rapid_spawn_kill.sh` is a stress fixture for the cmux IOSurface regression harness. It repeatedly launches a fresh cmux app process, waits only long enough for the child process to expose `vmmap -summary` data, samples the `IOSurface` resident + swapped footprint, terminates the child, and immediately starts the next iteration. + +The default loop count is 100 to force Mach-port and IOSurface churn without inter-iteration settle time. XCTest uses a lower count for targeted verification so the fixture remains practical in local and CI runs. + +Useful environment variables: + +- `CMUX_RAPID_SPAWN_KILL_APP_PATH`: path to the cmux `.app` bundle. Defaults to `/Applications/cmux.app`. +- `CMUX_RAPID_SPAWN_KILL_EXECUTABLE_PATH`: direct cmux executable override. +- `CMUX_RAPID_SPAWN_KILL_ITERATIONS`: loop count. Defaults to `100`. +- `CMUX_RAPID_SPAWN_KILL_READY_TIMEOUT_MS`: maximum per-child startup sampling wait. Defaults to `2500`. +- `CMUX_RAPID_SPAWN_KILL_TMPDIR`: scratch directory for per-iteration sockets and logs. + +Example: + +```bash +CMUX_RAPID_SPAWN_KILL_APP_PATH="/Applications/cmux.app" \ +CMUX_RAPID_SPAWN_KILL_ITERATIONS=100 \ +tests/fixtures/rapid_spawn_kill.sh +``` + +The fixture prints `VM: IOSurface = MB`; `RapidSpawnKillFixtureTests` runs it under `leaks --atExit` and asserts that value stays under the configured threshold. diff --git a/tests/fixtures/rapid_spawn_kill.sh b/tests/fixtures/rapid_spawn_kill.sh new file mode 100755 index 000000000000..f49609048152 --- /dev/null +++ b/tests/fixtures/rapid_spawn_kill.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +set -euo pipefail +set +m 2>/dev/null || true + +ITERATIONS="${CMUX_RAPID_SPAWN_KILL_ITERATIONS:-100}" +READY_TIMEOUT_MS="${CMUX_RAPID_SPAWN_KILL_READY_TIMEOUT_MS:-2500}" +APP_PATH="${CMUX_RAPID_SPAWN_KILL_APP_PATH:-}" +EXECUTABLE_PATH="${CMUX_RAPID_SPAWN_KILL_EXECUTABLE_PATH:-}" +TMP_ROOT="${CMUX_RAPID_SPAWN_KILL_TMPDIR:-${TMPDIR:-/tmp}/cmux-rapid-spawn-kill.$$}" + +log() { + printf '[rapid-spawn-kill] %s\n' "$*" >&2 +} + +die() { + log "ERROR: $*" + exit 1 +} + +bytes_from_human() { + local value="$1" + local trimmed number unit exponent + trimmed="$(printf '%s' "$value" | tr -d '[:space:],')" + if [[ -z "$trimmed" || "$trimmed" == "-" ]]; then + echo 0 + return + fi + if [[ "$trimmed" =~ ^([0-9]+([.][0-9]+)?)([KMGTP]?) ]]; then + number="${BASH_REMATCH[1]}" + unit="${BASH_REMATCH[3]}" + else + echo 0 + return + fi + + exponent=0 + case "$unit" in + K) exponent=1 ;; + M) exponent=2 ;; + G) exponent=3 ;; + T) exponent=4 ;; + P) exponent=5 ;; + esac + + awk -v number="$number" -v exponent="$exponent" ' + function power(base, exponent_value, out, i) { + out = 1 + for (i = 0; i < exponent_value; i++) out *= base + return out + } + BEGIN { printf "%.0f\n", number * power(1024, exponent) }' +} + +bytes_to_mb() { + local bytes="$1" + awk -v bytes="$bytes" 'BEGIN { printf "%.2f", bytes / (1024 * 1024) }' +} + +resolve_executable() { + if [[ -n "$EXECUTABLE_PATH" ]]; then + [[ -x "$EXECUTABLE_PATH" ]] || die "CMUX_RAPID_SPAWN_KILL_EXECUTABLE_PATH is not executable: $EXECUTABLE_PATH" + printf '%s\n' "$EXECUTABLE_PATH" + return + fi + + if [[ -z "$APP_PATH" ]]; then + APP_PATH="/Applications/cmux.app" + fi + [[ -d "$APP_PATH" ]] || die "cmux app not found: $APP_PATH" + + local candidate + for candidate in \ + "$APP_PATH/Contents/MacOS/cmux DEV" \ + "$APP_PATH/Contents/MacOS/cmux"; do + if [[ -x "$candidate" ]]; then + printf '%s\n' "$candidate" + return + fi + done + + die "no cmux executable found inside $APP_PATH" +} + +iosurface_bytes_for_pid() { + local pid="$1" + local vmmap_output line resident swapped + if ! vmmap_output="$(vmmap -summary "$pid" 2>/dev/null)"; then + echo 0 + return + fi + + line="$(printf '%s\n' "$vmmap_output" | awk '/^IOSurface[[:space:]]/ {print; exit}')" + if [[ -z "$line" ]]; then + echo 0 + return + fi + + resident="$(awk '{print $3}' <<<"$line")" + swapped="$(awk '{print $5}' <<<"$line")" + awk -v resident_bytes="$(bytes_from_human "$resident")" \ + -v swapped_bytes="$(bytes_from_human "$swapped")" \ + 'BEGIN { printf "%.0f\n", resident_bytes + swapped_bytes }' +} + +wait_for_child_ready() { + local pid="$1" + local deadline + deadline=$((SECONDS + (READY_TIMEOUT_MS / 1000) + 1)) + while kill -0 "$pid" 2>/dev/null; do + if [[ "$(iosurface_bytes_for_pid "$pid")" != "0" ]]; then + return 0 + fi + if [[ "$SECONDS" -ge "$deadline" ]]; then + return 0 + fi + done + return 1 +} + +terminate_child() { + local pid="$1" + kill -TERM "$pid" 2>/dev/null || return 0 + for _ in {1..20}; do + if ! kill -0 "$pid" 2>/dev/null; then + wait "$pid" 2>/dev/null || true + return 0 + fi + sleep 0.05 + done + kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true +} + +main() { + [[ "$ITERATIONS" =~ ^[0-9]+$ ]] || die "CMUX_RAPID_SPAWN_KILL_ITERATIONS must be an integer" + [[ "$ITERATIONS" -gt 0 ]] || die "CMUX_RAPID_SPAWN_KILL_ITERATIONS must be greater than zero" + + local executable + executable="$(resolve_executable)" + mkdir -p "$TMP_ROOT" + trap 'rm -rf "$TMP_ROOT"' EXIT + + local max_iosurface_bytes=0 + local spawned=0 + local iteration pid sample_bytes + for ((iteration = 1; iteration <= ITERATIONS; iteration++)); do + CMUX_TAG="rapid-spawn-kill-$iteration-$$" \ + CMUX_SOCKET_PATH="$TMP_ROOT/socket-$iteration.sock" \ + CMUX_RAPID_SPAWN_KILL_FIXTURE=1 \ + "$executable" >"$TMP_ROOT/cmux-$iteration.log" 2>&1 & + pid="$!" + spawned=$((spawned + 1)) + + wait_for_child_ready "$pid" || true + sample_bytes="$(iosurface_bytes_for_pid "$pid")" + printf 'rapid_spawn_kill_sample iteration=%s pid=%s iosurface_mb=%s\n' \ + "$iteration" "$pid" "$(bytes_to_mb "$sample_bytes")" + if [[ "$sample_bytes" -gt "$max_iosurface_bytes" ]]; then + max_iosurface_bytes="$sample_bytes" + fi + terminate_child "$pid" + done + + printf 'rapid_spawn_kill_iterations=%s\n' "$ITERATIONS" + printf 'rapid_spawn_kill_spawned=%s\n' "$spawned" + printf 'VM: IOSurface = %s MB\n' "$(bytes_to_mb "$max_iosurface_bytes")" +} + +main "$@"