Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
607c459
cmux-next tests: batch B1 suites close the windows they open
teamleaderleo Oct 28, 2026
89c29db
cmux-next: a closed window makes no new overlay panel; popups close t…
teamleaderleo Oct 28, 2026
2a4ec40
cmux-next tests: Home's Chief Settings panel closes with its test; Ap…
teamleaderleo Oct 28, 2026
ca4a789
Merge current feat-cmux-next into window cleanup B1
teamleaderleo Oct 29, 2026
29fcf05
Close browser profile test pages and palette on early failure
teamleaderleo Oct 29, 2026
29f279a
Make window test cleanup cover failed setup and all cached pages
teamleaderleo Oct 29, 2026
71c6f3a
fix(tests): drain owned daemon startup and settle approval panels
teamleaderleo Oct 29, 2026
fa8138e
fix(lifecycle): drain discarded startups and retire page info windows
teamleaderleo Oct 29, 2026
19207a4
fix(tests): avoid throwing key path in browser retirement assertion
teamleaderleo Oct 29, 2026
927ff9f
fix(tests): preserve launcher endpoint reuse during teardown
teamleaderleo Oct 29, 2026
a11bd13
fix(tests): synchronize moved tab metadata before assertions
teamleaderleo Oct 29, 2026
a36713a
fix(tests): finish fresh launch teardown on cancellation
teamleaderleo Oct 29, 2026
c52dbdf
Merge feat-cmux-next into lane/window-leak-suites-B1-codex
teamleaderleo Oct 29, 2026
5aafa01
fix(tests): retain owned daemon cleanup on handshake failure
teamleaderleo Oct 29, 2026
c26c33d
Merge remote-tracking branch 'origin/lane/window-leak-suites-B1-codex…
teamleaderleo Oct 29, 2026
b14c3d1
fix(tests): avoid region checker error in barrier wait
teamleaderleo Oct 30, 2026
90c32dc
fix(tests): observe daemon exit by kernel identity
teamleaderleo Oct 30, 2026
4051d1c
test: preserve unverified daemon ownership after shutdown
teamleaderleo Oct 30, 2026
2a72ab7
fix(tests): retain cancelled daemon ensures through teardown
teamleaderleo Oct 30, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ extension PageInfoController {
/// Site settings of `origin` (any site of this tab's profile, such as
/// one whose automatic downloads were blocked), from outside the bubble.
public func showSiteSettings(origin: String) {
guard let store, let provider, let url = URL(string: origin) else { return }
guard !isRetired, let store, let provider, let url = URL(string: origin) else { return }
close()
let site = PageInfoSite(url: url, security: url.scheme == "https" ? .secure : .insecure)
windows.showSiteSettings(origin: origin, site: site, store: store, provider: provider, send: { [weak self] in self?.send($0) })
Expand All @@ -77,9 +77,12 @@ extension PageInfoController {
guard let provider else { return }
close()
let failure = provider.pageInfoActivity.failedCertificateReason
Task { [weak self] in
certificateTask?.cancel()
// task-owner: the certificate viewer request; retirement cancels it and prevents late presentation
certificateTask = Task { [weak self] in
let chain = await provider.pageInfoCertificateChain().compactMap { try? PageInfoCertificate(der: $0) }
self?.windows.showCertificate(chain, site: site, failure: failure)
guard !Task.isCancelled, let self, !self.isRetired else { return }
self.windows.showCertificate(chain, site: site, failure: failure)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public final class PageInfoController {
private let card = PageInfoCardView()
var observation: ObservationLoop?
var loadTask: Task<Void, Never>?
var certificateTask: Task<Void, Never>?
var isRetired = false
var livePermissions: [SitePermissionKind: SitePermissionSetting] = [:]
var openedURL: URL?
private var pageHadFocus = false
Expand Down Expand Up @@ -78,6 +80,7 @@ public final class PageInfoController {

/// Runs a command (the registry handlers call this).
public func perform(_ command: PageInfoCommand) {
guard !isRetired else { return }
switch command {
case .show(let page): show(page)
case .close: close()
Expand All @@ -91,6 +94,8 @@ public final class PageInfoController {
/// The pane shows another tab: a bubble for the old one closes.
public func tabDidChange() {
close()
certificateTask?.cancel()
certificateTask = nil
windows.closeAll()
}

Expand Down Expand Up @@ -123,8 +128,12 @@ public final class PageInfoController {

/// Closes the bubble's panel for good (its page is closing): ``close()`` only orders it out.
func closePanel() {
isRetired = true
certificateTask?.cancel()
certificateTask = nil
close()
popup.closePanel()
windows.closeAll()
}

/// The bubble closed (``close()`` or a host dismiss rule): live data stops, the page gets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ final class PageInfoWindows {
private var certificate: CertificateViewerWindow?
private var siteSettings: SiteSettingsWindow?
private var siteData: SiteDataWindow?
var ownedWindows: [NSWindow] { [certificate, siteSettings, siteData].compactMap { $0 } }
/// The shell window the bubble belongs to (`WindowPlacement`).
var parentWindow: () -> NSWindow? = { nil }

Expand Down Expand Up @@ -45,7 +46,10 @@ final class PageInfoWindows {
}

func closeAll() {
[certificate, siteSettings, siteData].forEach { $0?.close() }
ownedWindows.forEach { $0.close() }
certificate = nil
siteSettings = nil
siteData = nil
}

private func present(_ window: NSWindow) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ public actor DaemonConnection {
continuation.finish()
}

/// Also waits for an in-flight endpoint/reconnect attempt to finish before its owner is reaped.
public func closeAndWait() async {
let reconnect = reconnectTask
close()
await reconnect?.value
}

public var isReady: Bool {
if case .ready = phase { return true }
return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public final class DaemonPrestart: Sendable {
await connection.prefetchSnapshot()
return .success((connection, identity))
} catch {
await connection.close()
await connection.closeAndWait()
return .failure((error as? DaemonError) ?? .launchFailed(String(describing: error)))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ public struct DaemonStartup: Sendable {
identity = try await connection.start()
DaemonLaunchTimings.shared.mark("daemon.handshake_end")
} catch {
await connection.close()
await connection.closeAndWait()
if Task.isCancelled { return nil }
let failure = (error as? DaemonError) ?? .launchFailed(String(describing: error))
await onFailure(failure)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ public final class DaemonService {
if let (exiting, identity) = connected, let handOff, await handOff(exiting, identity) {
// The new connection's endpoint waits for the old daemon to
// exit and starts the bundled build, which adopts the terminals.
await exiting.close()
await exiting.closeAndWait()
// The first connect was in time; the deadline now covers
// the successor's start.
self?.armStartupDeadline()
Expand All @@ -240,7 +240,7 @@ public final class DaemonService {
}
guard let (connection, identity) = connected else { return }
guard let self, !Task.isCancelled else {
await connection.close()
await connection.closeAndWait()
return
}
self.didConnect(connection, identity: identity)
Expand Down Expand Up @@ -302,7 +302,7 @@ public final class DaemonService {
await weakSelf?.noteStartupFailure(error)
}
if Task.isCancelled {
await connected?.0.close()
await connected?.0.closeAndWait()
return
}
guard let (connection, identity) = connected else {
Expand All @@ -311,7 +311,7 @@ public final class DaemonService {
continue
}
guard let self, !Task.isCancelled else {
await connection.close()
await connection.closeAndWait()
return
}
do {
Expand All @@ -323,13 +323,13 @@ public final class DaemonService {
let show = !Task.isCancelled
self.shutdownConnection()
if show { store.markFailed(String(describing: error)) }
await connection.close()
await connection.closeAndWait()
return
}
self.didConnect(connection, identity: identity)
logger.info("\(machineID, privacy: .public): cmux-tui \(identity.version, privacy: .public) session \(identity.session, privacy: .public)")
await store.run(connection: connection, scheduler: drainScheduler)
await connection.close()
await connection.closeAndWait()
if Task.isCancelled { return }
guard await ends.waitAfterFailure(wake: wake, clock: clock) else { return }
}
Expand Down Expand Up @@ -467,7 +467,20 @@ public final class DaemonService {
/// control socket can await it (`ActionRegistry.track`).
@ObservationIgnored public var workTracker: ((ActionWork) -> Void)?

/// Teardown that must finish before the caller removes a test session's state or reaps its owner.
public func shutdownConnectionAndWait() async {
let startup = runTask
let connected = detachConnection()
await connected?.closeAndWait()
await startup?.value
}

public func shutdownConnection() {
// task-owner: teardown hop; close() is idempotent and finishes the store pump
if let connection = detachConnection() { Task { await connection.close() } }
}

private func detachConnection() -> DaemonConnection? {
// Nothing may start an owner again behind the shutdown (the unresponsive owner's restart).
recovery.detach()
startupDeadlineTimer?.cancel()
Expand All @@ -480,8 +493,8 @@ public final class DaemonService {
runTask = nil
keptLayoutRelaunch?.cancel()
keptLayoutRelaunch = nil
// task-owner: teardown hop; close() is idempotent and finishes the store pump
if let connection { Task { await connection.close() } }
let connected = connection
connection = nil
return connected
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,10 @@ public final class MacScreenApprovalPrompt {
}
}

private func settle(_ id: String, hub: DeviceHub) {
func settle(_ id: String, hub: DeviceHub) {
settled.insert(id)
current = nil
// Keep the old request until update observes the transition to no request. Clearing it
// here made update's nil == nil guard skip closing the last request's panel.
update(hub.pendingRequests)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
@testable import CmuxNextAgentPane
@testable import CmuxNextDaemon
import Darwin
import Foundation
import Testing

@Suite(.serialized)
struct AppTestDaemonOwnerTests {
private struct HandshakeFailed: Error {}

private func withChild(_ body: (DaemonEndpoint, AcpmuxUnresponsiveWatch.Daemon) async throws -> Void) async throws {
let child = Process()
child.executableURL = URL(fileURLWithPath: "/bin/sleep")
child.arguments = ["3600"]
child.standardInput = FileHandle.nullDevice
try child.run()
guard let identity = AcpmuxUnresponsiveWatch.kernelProcess(pid: child.processIdentifier) else {
child.terminate()
_ = await ProcessExit.exitEvent(pid: child.processIdentifier, within: .seconds(5), clock: ContinuousClock())
Issue.record("could not read this test child's kernel identity")
return
}
let endpoint = DaemonEndpoint(socketPath: "/unused", pid: identity.pid)
let result: Result<Void, any Error>
do { result = .success(try await body(endpoint, identity)) }
catch { result = .failure(error) }
// task-owner: joined child cleanup survives cancellation of the test task.
let exited = await Task {
await AcpmuxUnresponsiveWatch(clock: ContinuousClock()).stop(identity, grace: .seconds(1))
}.value
#expect(exited)
try result.get()
}

@Test func aFailedHandshakeStillStopsTheRecordedProcess() async throws {
try await withChild { endpoint, identity in
let exited = await AppTestDaemonOwner.reap(endpoint, process: identity, grace: .seconds(1)) {
throw HandshakeFailed()
}
#expect(exited)
}
}

@Test func anUnverifiedLiveProcessIsLeftAloneAndCleanupRemainsIncomplete() async throws {
try await withChild { endpoint, identity in
let exited = await AppTestDaemonOwner.reap(endpoint, process: nil) { true }
#expect(!exited)
#expect(AcpmuxUnresponsiveWatch.kernelProcess(pid: identity.pid) == identity)
}
}

@Test func aReusedPidDoesNotSignalTheReplacement() async throws {
try await withChild { endpoint, identity in
var previous = identity
previous.token.val.7 &+= 1
let exited = await AppTestDaemonOwner.reap(endpoint, process: previous) { throw HandshakeFailed() }
#expect(exited, "the recorded process is gone")
#expect(AcpmuxUnresponsiveWatch.kernelProcess(pid: identity.pid) == identity,
"the replacement process must stay alive")
}
}
}
Loading
Loading