* test: cover kitty placement frame reuse * fix: cache kitty placement frames * test: cover pixel-accurate kitty clipping * fix: clip kitty placements in pixel space * test: cover pixel-accurate kitty replay clipping * fix: clip kitty replay in pixel space * test: cover number-only kitty image attach * test: cover both numbered kitty image aliases * fix: preserve kitty number aliases across attach * test: cover inflight kitty replay across resize * fix: preserve inflight kitty replay * test: cover anonymous kitty replay collisions * fix: preserve anonymous kitty placements in replay * test: cover kitty object count limits * fix: bound kitty graphics object counts * test: cover Kitty graphics in web render mode * test: cover host kitty scene invalidation * fix: restore kitty graphics after host resize * feat: render Kitty graphics in web terminal * test: cover graphics writer shutdown quiescence * fix: layer Kitty graphics above cell backgrounds * fix: draw web graphics from callback ref * fix: quiesce graphics before terminal restore * test: cover kitty replay allocation order * fix: preserve kitty replay allocation order * test: cover incremental render graphics deltas * test(tui): cover linear graphics state maintenance * fix: send incremental render graphics deltas * test: cover bounded kitty replay semantics * test(tui): cover late Kitty image ordering * test: cover render transport size boundaries * fix(tui): maintain Kitty graphic IDs linearly * test: cover atomic cell geometry updates * test(tui): cover Kitty PNG compatibility * test: cover full render metadata budget * test: preserve measured cell pixels across resize * test: bound kitty pixel cache lookups * fix: make cell geometry updates atomic * test: bound kitty placement grouping * fix: align render transport size budgets * test: bound render taps and resize replay * fix: preserve kitty images in bounded vt replay * fix: bound render taps and skip unused replay * docs(tui): document inline Kitty image support * style(tui): format merged changes * test: cover UTF-8 before kitty replay * test: cover large kitty resize replay * fix: distinguish UTF-8 from C1 kitty APC * fix: preserve kitty upload across resize * test(tui-sdk): cover retained render metadata overflow * fix(tui-sdk): bound retained render events * test(tui-web): expose placement canvas memory blowup * fix(tui-web): bound graphic canvas backing * test: preserve hosted Kitty image aliases * fix: preserve hosted Kitty image aliases * test(tui): cover stale Kitty write after resize clear * fix(tui): discard stale Kitty writes after resize clear * test(browser): expose terminal host alias protocol gap * fix(browser): support terminal host Kitty aliases * test(cmux-tui): expect resize alias sidecars * test(tui): preserve sparse viewport across replay * fix(tui): preserve sparse rows in terminal replay * fix(tui): align replayed scrollback rows * test(tui): await terminal host process exit * test(tui): cover Kitty alias history and sparse replay * fix(tui): preserve Kitty alias and sparse row history * fix(tui): address Kitty graphics review findings * fix(tui): harden Kitty graphics integration * fix(tui): resolve final Kitty autoreview findings * fix(tui): close Kitty autoreview findings * test(tui): cover final Kitty review regressions * fix(tui): close final Kitty autoreview findings * test(tui): reject overflowing PTY pixel geometry * fix(tui): reject invalid PTY pixel geometry * test(tui): cover remaining Kitty review regressions * fix(tui): close remaining Kitty review findings * fix(cmux-tui): close graphics review gaps * fix(cmux-tui): preserve attach and startup progress * fix(tui): reconcile image render geometry * fix(tui): align attach wire progress * fix(tui): bound inline image rendering resources * fix(tui): close inline image review gaps * fix(tui): bound inline graphics hot paths * test(tui): cover graphics attachment memory regressions * fix(tui): bound graphics attachment allocations * test(tui): budget retained render capacity * test(tui): cover graphics review regressions * fix(tui): close graphics autoreview gaps * test(tui): cover second graphics review regressions * fix(tui): close remaining graphics review gaps * test(tui): cover remaining graphics review regressions * fix(tui): close graphics review findings * test(tui): cover final graphics review regressions * fix(tui): close final graphics review findings * test(tui): cover graphics admission regressions * fix(tui): make graphics admission lazy and refillable * test(tui): cover final host lifecycle findings * fix(tui): bound host lifecycle work * test(tui): cover final protocol review findings * fix(tui): close final protocol review gaps * test(tui): cover bounded graphics writer failure * fix(tui): bound graphics output failure lifecycle * test(tui): cover final remote graphics review gaps * fix(tui): validate and localize remote attach data * test(tui): cover final graphics ownership findings * fix(tui): scope graphics output ownership * test(tui): cover graphics resource safety gaps * fix(tui): bound graphics resource lifecycles * test(tui): cover graphics budget scan fanout * fix(tui): make graphics admission single-pass * test(tui): cover review resource safety gaps * fix(tui): bound graphics attachment resources * test(tui): cover enhanced input adapter compatibility * fix(tui): reconcile shortcut merge with graphics input * test(tui): reconcile merged attach fixtures * test(tui): bound inline surface state * fix(tui): keep libghostty state out of line * test(tui): cover cell pixel fanout retry gap * fix(tui): reconcile skipped cell pixel fanout * test(tui): cover aggregate graphics ownership gaps * fix(tui): bound aggregate graphics ownership * test(tui): cover graphics teardown ownership * fix(tui): rebalance graphics ownership on teardown * test(tui): cover aggregate graphics recovery * fix(tui): recover aggregate graphics capacity * test(tui): isolate graphics counters per thread * test(tui): cover graphics resource ownership gaps * fix(tui): close graphics resource ownership gaps * test(tui): cover Kitty replay state divergence * fix(tui): preserve Kitty replay state across mirrors * test(tui): cover terminal resource lifecycle stalls * fix(tui): decouple terminal resource lifecycle work * test(tui): cover review lifecycle regressions * fix(tui): close review lifecycle gaps * test(tui): cover graphics review regressions * fix(tui): reconcile graphics lifecycle under load * test(tui): cover graphics baseline and quota exhaustion * fix(tui): reconcile graphics baselines and quota overflow * test(tui): make graphics backpressure deterministic * test(tui): cover exited quota and reset replay ordering * fix(tui): preserve graphics state across resets and exits * test(tui): cover scrolled Kitty placement alignment * fix(tui): align Kitty graphics with scrolled viewports * test(tui): bound stalled renderer output * fix(tui): preserve renderer output backpressure * test(tui): cover relabel and retry bounds * fix(tui): bound graphics recovery work * fix(ci): isolate fork-agent singleton default * test(tui): bound persistent graphics recovery * fix(tui): bound persistent graphics recovery * test(tui): drain stalled quota worker * test(tui): cover panic and fanout lifecycles * fix(tui): bound graphics worker lifecycles * test(web): cover exhausted graphics decode queue * fix(web): retire exhausted graphics decode jobs * test(tui): cover final Kitty review findings * fix(tui): close final Kitty replay gaps * test(tui): cover encoded Kitty quota * fix(tui): budget encoded Kitty uploads * test(browser): sync Kitty replay ceilings * fix(browser): match Kitty replay ceilings * test(tui): cover unsupported Kitty grayscale * fix(tui): bound Kitty snapshot formats * test(tui): cover Kitty quota recovery * fix(tui): reconcile Kitty quota recovery * test(tui): cover reconnect completion retry * fix(tui): retry failed host reconnect completion * test(tui): cover attach priority and replay cursor state * fix(tui): preserve attach priority and replay state * test(tui): cover superseded attach resize failure * fix(tui): settle the latest promoted resize * chore(tui): satisfy strict attach lifecycle lint * test(tui): cover numeric Kitty final chunks * fix(tui): parse Kitty chunk flags numerically * test(tui): cover Kitty images in web scrollback * fix(tui): render Kitty images in web scrollback * test(tui): admit terminals after graphics quota failure * fix(tui): degrade graphics after quota failure * test(tui): finish graphics probe at DA1 marker * fix(tui): end graphics probe at DA1 marker * test(tui): reject stale scrollback image epochs * fix(tui): version scrollback image anchors * test(tui): refresh active scrollback epochs * fix(tui): refresh active scrollback epochs * test(tui): ignore screen-only history epochs * fix(tui): scope history epochs to retained rows * test(tui): keep image frames out of history epochs * fix(tui): keep image frames out of history epochs * test(tui): assert stable screen-only history epochs * test(tui): bound deferred work and reserve attaches * fix(tui): bound deferred graphics coordination * test(tui): exhaust saturated Kitty quota retries * fix(tui): exhaust saturated Kitty quota retries * test(tui): retain overlapping Kitty replay placements * fix(tui): clip Kitty placements at replay boundaries * fix(tui): preserve merged attach invariants * test(ci): cover Ghostty path metadata * fix(ci): inspect executable Ghostty consumers * test(ios): replace wall-clock synchronization * chore(xcode): normalize project ordering * fix(ssh): simplify retry script assembly * test(app): update detached transfer fixture * test: update remote PTY lifecycle fake * test: require explicit app-host test mode * fix: declare app-host test launch mode * fix: clear Xcode 26.3 warning gate * test: detect embedded app-host test bundle * fix: detect app-host tests from embedded bundle * chore: drop unreliable app-host scheme marker * test: avoid async ARC lifetime assertion * test: require app-host build identity * fix: stamp app-host test builds before launch * test: require test-runner app-host marker * fix: forward app-host test identity through xcodebuild * fix: keep completed iroh dial single-flight through install * test: re-report lifecycle after status clear * Fix cmux-tui merge integration * test(web): cover render attach WebSocket budget * fix(web): admit full render attach frames --------- Co-authored-by: austinpower1258 <[email protected]>
193 lines
6.9 KiB
Swift
193 lines
6.9 KiB
Swift
import Darwin
|
|
import Foundation
|
|
import Testing
|
|
|
|
@testable import CmuxFoundation
|
|
|
|
@Suite("CommandRunner descriptor lifecycle", .serialized)
|
|
struct CommandRunnerDescriptorLifecycleTests {
|
|
private let tempDirectory = FileManager.default.temporaryDirectory
|
|
|
|
@Test("Capture pipes are close-on-exec and close before success returns")
|
|
func capturePipesAreCloseOnExecAndCloseAfterSuccess() async throws {
|
|
let execution = try makeExecution(executable: "/usr/bin/true")
|
|
let descriptors = try snapshotDescriptors(of: execution)
|
|
#expect(descriptors.count == 8)
|
|
for descriptor in descriptors {
|
|
let flags = fcntl(descriptor.fileDescriptor, F_GETFD)
|
|
#expect(flags != -1)
|
|
#expect(
|
|
flags & FD_CLOEXEC != 0,
|
|
"CommandRunner pipe descriptor \(descriptor.fileDescriptor) can leak into unrelated children"
|
|
)
|
|
}
|
|
|
|
let result = await execution.run(timeout: 5)
|
|
#expect(result.exitStatus == 0)
|
|
expectDescriptorsClosed(descriptors)
|
|
#expect(execution.process.terminationHandler == nil)
|
|
}
|
|
|
|
@Test("Launch failure closes capture pipes")
|
|
func launchFailureClosesPipes() async throws {
|
|
let execution = try makeExecution(
|
|
executable: "/usr/bin/true",
|
|
directory: URL(
|
|
fileURLWithPath: "/cmux-test-directory-that-does-not-exist-\(UUID().uuidString)"
|
|
)
|
|
)
|
|
let descriptors = try snapshotDescriptors(of: execution)
|
|
let result = await execution.run(timeout: 5)
|
|
|
|
#expect(result.executionError != nil)
|
|
expectDescriptorsClosed(descriptors)
|
|
}
|
|
|
|
@Test("Abandoning an execution before launch closes every pipe")
|
|
func abandonedExecutionClosesPipes() throws {
|
|
weak var abandonedExecution: CommandExecution?
|
|
var execution: CommandExecution? = try makeExecution(executable: "/usr/bin/true")
|
|
abandonedExecution = execution
|
|
let descriptors = try snapshotDescriptors(of: #require(execution))
|
|
execution = nil
|
|
|
|
#expect(abandonedExecution == nil)
|
|
expectDescriptorsClosed(descriptors)
|
|
}
|
|
|
|
@Test("Timeout terminates the child and closes capture pipes")
|
|
func timeoutTerminatesAndClosesPipes() async throws {
|
|
let execution = try makeExecution(
|
|
executable: "/bin/sh",
|
|
arguments: ["-c", "sleep 2 &"]
|
|
)
|
|
let descriptors = try snapshotDescriptors(of: execution)
|
|
let result = await execution.run(timeout: 0.1)
|
|
|
|
#expect(result.timedOut)
|
|
expectDescriptorsClosed(descriptors)
|
|
}
|
|
|
|
@Test("Cancellation before launch closes every pipe")
|
|
func cancellationBeforeLaunchClosesPipes() async throws {
|
|
let execution = try makeExecution(executable: "/usr/bin/true")
|
|
let descriptors = try snapshotDescriptors(of: execution)
|
|
|
|
let result = await Task {
|
|
withUnsafeCurrentTask { $0?.cancel() }
|
|
return await execution.run(timeout: 5)
|
|
}.value
|
|
|
|
#expect(result.timedOut == false)
|
|
#expect(result.executionError != nil)
|
|
expectDescriptorsClosed(descriptors)
|
|
}
|
|
|
|
@Test("Task cancellation terminates the child and closes capture pipes")
|
|
func cancellationTerminatesAndClosesPipes() async throws {
|
|
let pidFile = uniqueTemporaryFile(named: "pid")
|
|
defer { try? FileManager.default.removeItem(at: pidFile) }
|
|
|
|
let execution = try makeExecution(
|
|
executable: "/bin/sh",
|
|
arguments: ["-c", "printf %s $$ > \"$1\"; exec sleep 30", "cmux-test", pidFile.path]
|
|
)
|
|
let descriptors = try snapshotDescriptors(of: execution)
|
|
let command = Task {
|
|
await execution.run(timeout: 2)
|
|
}
|
|
|
|
try await waitForFile(at: pidFile)
|
|
let pidText = try String(contentsOf: pidFile, encoding: .utf8)
|
|
let pid = try #require(pid_t(pidText))
|
|
command.cancel()
|
|
|
|
let result = await command.value
|
|
#expect(result.timedOut == false)
|
|
#expect(result.executionError != nil)
|
|
#expect(kill(pid, 0) == -1 && errno == ESRCH)
|
|
|
|
expectDescriptorsClosed(descriptors)
|
|
}
|
|
|
|
private func makeExecution(
|
|
executable: String,
|
|
arguments: [String] = [],
|
|
directory: URL? = nil
|
|
) throws -> CommandExecution {
|
|
try CommandExecution(
|
|
executableURL: URL(fileURLWithPath: executable),
|
|
arguments: arguments,
|
|
currentDirectoryURL: directory ?? tempDirectory
|
|
)
|
|
}
|
|
|
|
private func snapshotDescriptors(
|
|
of execution: CommandExecution
|
|
) throws -> [DescriptorIdentity] {
|
|
let descriptors = [
|
|
execution.stdoutPipe.pipe.fileHandleForReading.fileDescriptor,
|
|
execution.stdoutPipe.pipe.fileHandleForWriting.fileDescriptor,
|
|
execution.stderrPipe.pipe.fileHandleForReading.fileDescriptor,
|
|
execution.stderrPipe.pipe.fileHandleForWriting.fileDescriptor,
|
|
execution.cancellationSignal.readDescriptor,
|
|
execution.cancellationSignal.writeDescriptor,
|
|
execution.stdoutReadDescriptor.rawValue,
|
|
execution.stderrReadDescriptor.rawValue,
|
|
]
|
|
#expect(Set(descriptors).count == descriptors.count)
|
|
return try descriptors.map { descriptor in
|
|
var metadata = stat()
|
|
guard fstat(descriptor, &metadata) == 0 else {
|
|
throw DescriptorLifecycleTestError.snapshotFailed(descriptor)
|
|
}
|
|
return DescriptorIdentity(
|
|
fileDescriptor: descriptor,
|
|
inode: UInt64(metadata.st_ino)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func expectDescriptorsClosed(_ descriptors: [DescriptorIdentity]) {
|
|
for descriptor in descriptors {
|
|
var metadata = stat()
|
|
if fstat(descriptor.fileDescriptor, &metadata) == 0 {
|
|
#expect(
|
|
UInt64(metadata.st_ino) != descriptor.inode,
|
|
"CommandRunner retained pipe descriptor \(descriptor.fileDescriptor)"
|
|
)
|
|
} else {
|
|
#expect(errno == EBADF)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func uniqueTemporaryFile(named suffix: String) -> URL {
|
|
tempDirectory.appendingPathComponent(
|
|
"cmux-command-runner-\(UUID().uuidString)-\(suffix)",
|
|
isDirectory: false
|
|
)
|
|
}
|
|
|
|
private func waitForFile(at url: URL) async throws {
|
|
let clock = ContinuousClock()
|
|
let deadline = clock.now.advanced(by: .seconds(5))
|
|
while !FileManager.default.fileExists(atPath: url.path) {
|
|
guard clock.now < deadline else {
|
|
throw DescriptorLifecycleTestError.markerTimedOut
|
|
}
|
|
try await clock.sleep(for: .milliseconds(10))
|
|
}
|
|
}
|
|
|
|
private struct DescriptorIdentity {
|
|
let fileDescriptor: Int32
|
|
let inode: UInt64
|
|
}
|
|
|
|
private enum DescriptorLifecycleTestError: Error {
|
|
case markerTimedOut
|
|
case snapshotFailed(Int32)
|
|
}
|
|
}
|