Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0aff311
test: cover codex monitor owner exit
austinywang May 15, 2026
42adc84
fix: tear down browser and codex helpers
austinywang May 15, 2026
6d56d47
fix: keep popup teardown self contained
austinywang May 15, 2026
e9289cb
fix: validate codex monitor owner input
austinywang May 15, 2026
93db722
fix: make browser teardown idempotent
austinywang May 15, 2026
20dd4f3
fix: watch codex monitor owner exits
austinywang May 15, 2026
15aabd5
fix: skip unowned codex monitors
austinywang May 15, 2026
9f49317
fix: keep codex owner skip parseable
austinywang May 16, 2026
9a6c02d
Merge remote-tracking branch 'origin/main' into issue-4188-memory-hel…
austinywang May 16, 2026
9cf2df3
fix: merge codex lease preservation with owner guard
austinywang May 16, 2026
b5490fe
fix: clean codex monitor leases on invalid owner pid
austinywang May 16, 2026
3091f0d
fix: skip codex monitor without lease
austinywang May 16, 2026
2c4d113
fix: clear browser observers before close teardown
austinywang May 16, 2026
01403e5
test: cover relay codex monitor ownership
austinywang May 16, 2026
8bee6fc
fix: skip app pid watcher for relay codex monitors
austinywang May 16, 2026
63b0d0d
Merge remote-tracking branch 'origin/main' into issue-4188-memory-hel…
austinywang May 16, 2026
c22e8d6
chore: retrigger ci
austinywang May 16, 2026
521190c
fix: hide stale web inspector windows after re-dock
austinywang May 17, 2026
c52153f
Merge remote-tracking branch 'origin/main' into issue-4188-memory-hel…
austinywang May 17, 2026
6cb581a
fix: scope stale inspector cleanup to mounted panels
austinywang May 17, 2026
526fafc
fix: preserve live detached web inspectors
austinywang May 17, 2026
945bbc9
fix: narrow live inspector window guard
austinywang May 17, 2026
adf917b
fix: preserve popup web inspector closes
austinywang May 17, 2026
500e551
fix: share inspector view ownership helper
austinywang May 17, 2026
8b52aba
Merge remote-tracking branch 'origin/main' into issue-4188-memory-hel…
austinywang May 18, 2026
cef8b8c
Merge remote-tracking branch 'origin/main' into issue-4188-memory-hel…
austinywang May 19, 2026
625b672
fix: avoid duplicate reactgrab teardown
austinywang May 19, 2026
26c1649
merge: resolve conflicts with main
austinywang Jun 6, 2026
a30528c
fix: make codex monitor owner watcher explicit
austinywang Jun 6, 2026
6679257
fix: require inspector ownership for live checks
austinywang Jun 6, 2026
f3fa5c1
fix: keep popup inspector ownership during close
austinywang Jun 6, 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
223 changes: 191 additions & 32 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18442,7 +18442,7 @@ struct CMUXCLI {
private static let codexMonitorLeaseDirectoryName = "codex-monitor-leases"
private static let codexMonitorLeaseMaxAgeSeconds: TimeInterval = 4 * 60 * 60
private static let codexMonitorRetiredLeaseMaxAgeSeconds: TimeInterval = 2 * 60
private static let codexMonitorOwnerCheckIntervalSeconds: TimeInterval = 60
private static let codexMonitorOwnerCheckIntervalSeconds: TimeInterval = 10
private static let codexMonitorOwnerCheckTimeoutSeconds: TimeInterval = 1

private func codexMonitorLeaseDirectory(env: [String: String]) -> URL {
Expand Down Expand Up @@ -18604,13 +18604,117 @@ struct CMUXCLI {
return ownerFound ? .alive : .gone
}

private func codexMonitorOwnerPID(client: SocketClient) -> Int? {
guard let payload = try? client.sendV2(
method: "system.identify",
responseTimeout: Self.codexMonitorOwnerCheckTimeoutSeconds
) else {
return nil
}
if let pid = payload["app_pid"] as? Int, pid > 0 {
return pid
}
if let pid = (payload["app_pid"] as? NSNumber)?.intValue, pid > 0 {
return pid
}
return nil
}

private final class CodexMonitorOwnerExitWatcher {
private let lock = NSLock()
private var processSource: DispatchSourceProcess?
private var didExit = false
private var waiters: [DispatchSemaphore] = []

init?(ownerPID: Int?) {
guard let ownerPID, ownerPID > 0 else { return nil }
guard Self.processExists(ownerPID) else {
didExit = true
return
}

let source = DispatchSource.makeProcessSource(
identifier: pid_t(ownerPID),
eventMask: .exit,
queue: DispatchQueue.global(qos: .utility)
)
source.setEventHandler { [weak self] in
self?.markExited()
}
processSource = source
source.resume()
}

deinit {
let sourceToCancel: DispatchSourceProcess?
lock.lock()
sourceToCancel = processSource
processSource = nil
waiters.removeAll()
lock.unlock()
sourceToCancel?.cancel()
}

var hasExited: Bool {
lock.lock()
let value = didExit
lock.unlock()
return value
}

func registerWaiter(_ semaphore: DispatchSemaphore) -> Bool {
lock.lock()
if didExit {
lock.unlock()
semaphore.signal()
return false
}
waiters.append(semaphore)
lock.unlock()
return true
}
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved

func unregisterWaiter(_ semaphore: DispatchSemaphore) {
lock.lock()
waiters.removeAll { $0 === semaphore }
lock.unlock()
}

private func markExited() {
let waitersToSignal: [DispatchSemaphore]
let sourceToCancel: DispatchSourceProcess?
lock.lock()
guard !didExit else {
lock.unlock()
return
}
didExit = true
waitersToSignal = waiters
waiters.removeAll()
sourceToCancel = processSource
processSource = nil
lock.unlock()

waitersToSignal.forEach { $0.signal() }
sourceToCancel?.cancel()
}

private static func processExists(_ pid: Int) -> Bool {
if kill(pid_t(pid), 0) == 0 {
return true
}
return errno == EPERM
}
}

private func startCodexTranscriptMonitor(
sessionId: String,
turnId: String?,
transcriptPath: String?,
cwd: String?,
workspaceId: String,
surfaceId: String?,
ownerPID: Int?,
leasePath: String?,
env: [String: String],
telemetry: CLISocketSentryTelemetry
Expand Down Expand Up @@ -18651,6 +18755,9 @@ struct CMUXCLI {
if let cwd, !cwd.isEmpty {
monitorArgs += ["--cwd", cwd]
}
if let ownerPID, ownerPID > 0 {
monitorArgs += ["--owner-pid", String(ownerPID)]
}
if let leasePath, !leasePath.isEmpty {
monitorArgs += ["--lease", leasePath]
}
Expand Down Expand Up @@ -18679,17 +18786,34 @@ struct CMUXCLI {
let turnId = optionValue(commandArgs, name: "--turn")
var transcriptPath = optionValue(commandArgs, name: "--transcript")
let leasePath = optionValue(commandArgs, name: "--lease")
defer { removeCodexMonitorLease(path: leasePath) }
let ownerPIDRaw = optionValue(commandArgs, name: "--owner-pid")
let ownerPID: Int?
if let ownerPIDRaw {
guard let parsedOwnerPID = Int(ownerPIDRaw), parsedOwnerPID > 0 else {
throw CLIError(
message: "cmux hooks codex monitor: Invalid --owner-pid; provide a positive integer PID and retry",
exitCode: 2
)
}
ownerPID = parsedOwnerPID
} else {
ownerPID = nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

guard !workspaceId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!sessionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return
}

defer { removeCodexMonitorLease(path: leasePath) }
let deadline = Date().addingTimeInterval(4 * 60 * 60)
let ownerExitWatcher = CodexMonitorOwnerExitWatcher(ownerPID: ownerPID)
var nextOwnerCheck = Date.distantPast
var publishedUserInputCallIds = Set<String>()
while Date() < deadline {
if ownerExitWatcher?.hasExited == true {
return
}
if isCodexMonitorLeaseRetired(path: leasePath) {
return
}
Expand Down Expand Up @@ -18751,7 +18875,12 @@ struct CMUXCLI {

let remaining = deadline.timeIntervalSinceNow
guard remaining > 0 else { return }
waitForCodexTranscriptChange(path: transcriptPath, leasePath: leasePath, timeout: min(30, remaining))
waitForCodexTranscriptChange(
path: transcriptPath,
leasePath: leasePath,
ownerExitWatcher: ownerExitWatcher,
timeout: min(30, remaining)
)
}
}

Expand Down Expand Up @@ -18794,11 +18923,23 @@ struct CMUXCLI {
)
}

private func waitForCodexTranscriptChange(path: String?, leasePath: String?, timeout: TimeInterval) {
private func waitForCodexTranscriptChange(
path: String?,
leasePath: String?,
ownerExitWatcher: CodexMonitorOwnerExitWatcher?,
timeout: TimeInterval
) {
guard timeout > 0 else { return }

let semaphore = DispatchSemaphore(value: 0)
let registeredOwnerWaiter = ownerExitWatcher?.registerWaiter(semaphore) ?? false
var sources: [DispatchSourceFileSystemObject] = []
defer {
if registeredOwnerWaiter {
ownerExitWatcher?.unregisterWaiter(semaphore)
}
sources.forEach { $0.cancel() }
}

func addFileSource(path: String?, eventMask: DispatchSource.FileSystemEvent) {
guard let path, !path.isEmpty else { return }
Expand All @@ -18823,13 +18964,12 @@ struct CMUXCLI {
addFileSource(path: path, eventMask: [.write, .extend, .delete, .rename])
addFileSource(path: leasePath, eventMask: [.write, .delete, .rename])

guard !sources.isEmpty else {
_ = DispatchSemaphore(value: 0).wait(timeout: .now() + timeout)
guard !sources.isEmpty || ownerExitWatcher != nil else {
_ = semaphore.wait(timeout: .now() + timeout)
return
}

_ = semaphore.wait(timeout: .now() + timeout)
sources.forEach { $0.cancel() }
}

private func extractMessageText(from message: [String: Any]) -> String? {
Expand Down Expand Up @@ -20867,37 +21007,56 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
client: client
)
if def.name == "codex", !sessionId.isEmpty {
let leasePath = createCodexMonitorLease(
sessionId: sessionId,
turnId: input.turnId,
workspaceId: workspaceId,
surfaceId: surfaceId,
env: env
)
if leasePath == nil {
let ownerPID = codexMonitorOwnerPID(client: client)
if ownerPID == nil {
telemetry.breadcrumb(
"codex-hook.monitor.lease-unavailable",
data: ["has_turn_id": normalizedHookValue(input.turnId) != nil]
"codex-hook.monitor.owner-unavailable",
data: [
"has_turn_id": normalizedHookValue(input.turnId) != nil,
"has_transcript": normalizedHookValue(input.transcriptPath) != nil,
"has_surface_id": normalizedHookValue(surfaceId) != nil,
]
)
} else {
retireCodexMonitorLeases(
retireCodexMonitorLeases(sessionId: sessionId, turnId: nil, env: env)
}
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
if let ownerPID = ownerPID {
let leasePath = createCodexMonitorLease(
sessionId: sessionId,
turnId: nil,
preservingLeasePath: leasePath,
turnId: input.turnId,
workspaceId: workspaceId,
surfaceId: surfaceId,
env: env
)
if leasePath == nil {
telemetry.breadcrumb(
"codex-hook.monitor.lease-unavailable",
data: [
"has_turn_id": normalizedHookValue(input.turnId) != nil,
"has_owner_pid": true,
]
)
retireCodexMonitorLeases(sessionId: sessionId, turnId: nil, env: env)
} else {
retireCodexMonitorLeases(
sessionId: sessionId,
turnId: nil,
preservingLeasePath: leasePath,
env: env
)
}
startCodexTranscriptMonitor(
sessionId: sessionId,
turnId: input.turnId,
transcriptPath: normalizedHookValue(input.transcriptPath),
cwd: hookCwd ?? mapped?.cwd,
workspaceId: workspaceId,
surfaceId: surfaceId,
ownerPID: ownerPID,
leasePath: leasePath,
env: env,
telemetry: telemetry
)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
startCodexTranscriptMonitor(
sessionId: sessionId,
turnId: input.turnId,
transcriptPath: normalizedHookValue(input.transcriptPath),
cwd: hookCwd ?? mapped?.cwd,
workspaceId: workspaceId,
surfaceId: surfaceId,
leasePath: leasePath,
env: env,
telemetry: telemetry
)
}

case .stop:
Expand Down
10 changes: 10 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1554,11 +1554,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
WebViewInspectorTeardown.closeAllInspectors(in: NSApp.windows)
}

private func teardownAllWorkspacePanelsBeforeAppTeardown() {
var seenManagers = Set<ObjectIdentifier>()
let managers = mainWindowContexts.values.map(\.tabManager) + [tabManager].compactMap { $0 }
for manager in managers {
guard seenManagers.insert(ObjectIdentifier(manager)).inserted else { continue }
manager.tabs.forEach { $0.teardownAllPanels() }
}
}

func applicationWillTerminate(_ notification: Notification) {
isTerminatingApp = true
closeAllWebInspectorsBeforeAppTeardown()
_ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false)
stopSessionAutosaveTimer()
teardownAllWorkspacePanelsBeforeAppTeardown()
CloudVMActionLauncher.shared.terminateAll()
CmuxSSHURLProcessLauncher.shared.terminateAll()
TerminalController.shared.stop()
Expand Down
Loading
Loading