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
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -251,6 +252,7 @@
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; };
F9200001A1B2C3D4E5F60718 /* RapidSpawnKillFixtureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RapidSpawnKillFixtureTests.swift; sourceTree = "<group>"; };
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = "<group>"; };
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
187 changes: 187 additions & 0 deletions cmuxTests/RapidSpawnKillFixtureTests.swift
Original file line number Diff line number Diff line change
@@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused force-window env var has no effect

Medium Severity

The CMUX_RAPID_SPAWN_KILL_FORCE_WINDOW environment variable is set by the test to force IOSurface allocation, but the rapid_spawn_kill.sh fixture and the cmux app don't consume it. This makes the variable inert, and the test's assertion for IOSurface allocation relies on default cmux behavior, which could be misleading or flaky.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 78a38b0. Configure here.

"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 = <N> 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..<output.endIndex, in: output)
guard
let match = regex.firstMatch(in: output, range: range),
let valueRange = Range(match.range(at: 1), in: output)
else {
return nil
}
return Double(output[valueRange])
}

private func runProcess(
executablePath: String,
arguments: [String],
environment: [String: String],
timeout: TimeInterval
) -> 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
)
}
}
25 changes: 25 additions & 0 deletions tests/fixtures/README.md
Original file line number Diff line number Diff line change
@@ -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 = <N> MB`; `RapidSpawnKillFixtureTests` runs it under `leaks --atExit` and asserts that value stays under the configured threshold.
Loading
Loading