From 9640563091e71ac2eecda3e51a967122391465ac Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:37:10 +0000 Subject: [PATCH 1/2] Agent pane flicker log: one instance from the composition root, not two statics Fixes the arch-ratchet tip red after #36133: [stateful_singletons] +2 (CmuxNextAgentPane: AgentPageFlickerLog.shared and its static occlusion watch). The agent tab store's wiring (AgentTabStore.wired, built once per AppServices) makes the app run's log with AgentPageFlickerLog.forAppRun() (nil outside an app bundle, as `shared` was) and hands it to every pane through AgentPaneShared.flickerLog. A pane keeps it (AgentPaneView.flickerLog) and its bridge, navigation delegate and AgentPageTerminations record through it. The window occlusion watch is the log's own instance state: each pane asks for it, the first starts it. A pane with no log (tests) writes nothing. Threading is unchanged: a caller's turn only builds the JSON line; the file create, append and roll-over run on the log's utility queue, never the main thread. Test: AgentPageFlickerLogTests.everyPaneAsksForTheWindowWatchAndAFlipIsOneLine. Red on base b1b7a3ff9d02: arch-ratchet.py --base bacb88f2349f --head b1b7a3ff9d02 refuses stateful_singletons +2; --audit at b1b7a3ff9d02 counts 201 over 199. Green on this commit. --- .../AgentPageFlickerLog.swift | 66 ++++++++++--------- .../AgentPageTerminations.swift | 2 +- .../CmuxNextAgentPane/AgentPaneBridge.swift | 4 +- .../AgentPaneNavigation.swift | 4 +- .../CmuxNextAgentPane/AgentPaneShared.swift | 9 ++- .../CmuxNextAgentPane/AgentPaneView.swift | 8 ++- .../CmuxNextApp/AgentTabs+Wiring.swift | 2 +- .../AgentPageFlickerLogTests.swift | 18 +++++ 8 files changed, 71 insertions(+), 42 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageFlickerLog.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageFlickerLog.swift index 96807d5e4d28..b3da23be75e7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageFlickerLog.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageFlickerLog.swift @@ -20,55 +20,61 @@ nonisolated private let flickerLogger = Logger(subsystem: "com.cmuxterm.app.next /// `//diagnostics/agent-page-flicker.ndjson`, which becomes /// `agent-page-flicker.1.ndjson` when a write would take it past ``maximumBytes``. Nothing in a line is the /// person's text: a page name (`chat:` or `new-tab`), a pid, numbers and short words. -/// Writes run on a utility queue, never the main thread. -nonisolated final class AgentPageFlickerLog: Sendable { - static let fileName = "agent-page-flicker.ndjson" - static let olderName = "agent-page-flicker.1.ndjson" - static let maximumBytes = 256 * 1024 +/// Writes run on a utility queue, never the main thread: a caller's turn only builds the line. +/// +/// The composition root makes the app's one (``forAppRun()``) and hands it to every pane through +/// ``AgentPaneShared/flickerLog``; a pane without one (tests, tools) writes nothing. +@MainActor public final class AgentPageFlickerLog { + nonisolated static let fileName = "agent-page-flicker.ndjson" + nonisolated static let olderName = "agent-page-flicker.1.ndjson" + nonisolated static let maximumBytes = 256 * 1024 /// The page's method; answered in ``AgentPaneBridge`` before ``AgentPaneRequest``, never logged. - static let method = "diag.flicker" + nonisolated static let method = "diag.flicker" /// The kinds a page may write, and the most events one call carries. - static let pageKinds: Set = ["page-load", "visibility", "transcript-mount", "long-frame"] - static let maximumPageEvents = 32 + nonisolated static let pageKinds: Set = ["page-load", "visibility", "transcript-mount", "long-frame"] + nonisolated static let maximumPageEvents = 32 - /// The app's ring; nil outside an app bundle (package tests, tools), so they never write it. - static let shared: AgentPageFlickerLog? = { + /// The app run's ring, for the composition root to make once; nil outside an app bundle + /// (package tests, tools), so they never write it. + public nonisolated static func forAppRun() -> AgentPageFlickerLog? { guard Bundle.main.bundleURL.pathExtension == "app", let bundle = Bundle.main.bundleIdentifier, !bundle.isEmpty, let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } return AgentPageFlickerLog(directory: support.appending(path: bundle).appending(path: "diagnostics", directoryHint: .isDirectory)) - }() + } - let directory: URL - private let queue = DispatchQueue(label: "com.cmuxterm.app.next.agent-page.flicker-log", qos: .utility) + nonisolated let directory: URL + private nonisolated let queue = DispatchQueue(label: "com.cmuxterm.app.next.agent-page.flicker-log", qos: .utility) + /// The window occlusion watch (``watchWindows()``), started by the first pane. + private var occlusion: MainActorObservation? - init(directory: URL) { + nonisolated init(directory: URL) { self.directory = directory } - var url: URL { directory.appending(path: Self.fileName) } + nonisolated var url: URL { directory.appending(path: Self.fileName) } /// Appends one line: `fields` with `at` (UTC, milliseconds) first. - func record(_ kind: String, at date: Date = Date(), _ fields: [String: Any] = [:]) { + nonisolated func record(_ kind: String, at date: Date = Date(), _ fields: [String: Any] = [:]) { guard let line = Self.line(kind: kind, at: date, fields: fields) else { return } append([line]) } /// The page's events (`{events: [{kind, at, ...}]}`) for the pane showing `page`: only known /// kinds and short scalar fields, at most ``maximumPageEvents``. - func recordPage(_ params: [String: Any]?, page: String, pid: Int32) { + nonisolated func recordPage(_ params: [String: Any]?, page: String, pid: Int32) { let lines = Self.pageLines(params, page: page, pid: pid) if !lines.isEmpty { append(lines) } } /// A pane's page name in a line: `chat:`, or `new-tab`. - @MainActor static func pageName(_ view: AgentPaneView) -> String { + static func pageName(_ view: AgentPaneView) -> String { view.model.sessionId.map { "chat:\($0)" } ?? "new-tab" } /// Waits for every queued write (tests). - func flush() { queue.sync {} } + nonisolated func flush() { queue.sync {} } - static func pageLines(_ params: [String: Any]?, page: String, pid: Int32) -> [Data] { + nonisolated static func pageLines(_ params: [String: Any]?, page: String, pid: Int32) -> [Data] { guard let events = params?["events"] as? [Any] else { return [] } return events.prefix(maximumPageEvents).compactMap { item -> Data? in guard let event = item as? [String: Any], let kind = event["kind"] as? String, pageKinds.contains(kind) else { return nil } @@ -89,7 +95,7 @@ nonisolated final class AgentPageFlickerLog: Sendable { } } - static func line(kind: String, at date: Date, fields: [String: Any]) -> Data? { + nonisolated static func line(kind: String, at date: Date, fields: [String: Any]) -> Data? { var object = fields object["kind"] = kind object["at"] = Date.ISO8601FormatStyle(includingFractionalSeconds: true).format(date) @@ -99,7 +105,7 @@ nonisolated final class AgentPageFlickerLog: Sendable { return data } - private func append(_ lines: [Data]) { + private nonisolated func append(_ lines: [Data]) { let directory = directory, url = url, older = directory.appending(path: Self.olderName) queue.async { do { @@ -127,19 +133,17 @@ nonisolated final class AgentPageFlickerLog: Sendable { } } - /// Records every window's occlusion flip, once per app run (the first agent pane calls it). - @MainActor static func watchWindows() { - guard occlusion == nil, let log = shared else { return } - occlusion = MainActorObservation(NSWindow.didChangeOcclusionStateNotification, read: { $0.object as? NSWindow }) { window in - guard let window else { return } - log.record("window-occlusion", [ + /// Records every window's occlusion flip from now on, once per log (each agent pane calls it; the first starts it). + /// - Parameter center: Where windows post their flips (tests pass their own). + func watchWindows(center: NotificationCenter = .default) { + guard occlusion == nil else { return } + occlusion = MainActorObservation(NSWindow.didChangeOcclusionStateNotification, center: center, read: { $0.object as? NSWindow }) { [weak self] window in + guard let self, let window else { return } + record("window-occlusion", [ "window": window.windowNumber, "visible": window.occlusionState.contains(.visible), "type": String(describing: type(of: window)), ]) } } - - /// App-wide for the app run. - @MainActor private static var occlusion: MainActorObservation? } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageTerminations.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageTerminations.swift index 8f8c9d85c208..0efb13ce619d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageTerminations.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageTerminations.swift @@ -88,7 +88,7 @@ public final class AgentPageTerminations { // An event none of whose pages still shows the notice is done (the pages closed or reloaded). events.removeAll { $0 !== event && $0.waiting.isEmpty } let page = event.pages.last - AgentPageFlickerLog.shared?.record("web-content-ended", [ + view.flickerLog?.record("web-content-ended", [ "reason": termination.reason.rawValue, "pid": Int(termination.pid), "page": page?.page ?? "-", "engine": page?.engine ?? "-", "ended_with_it": event.pages.count, "retries": retries, ]) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift index 9b96c1ee3b23..9fafd3fe3f43 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift @@ -38,8 +38,8 @@ final class AgentPaneBridge: NSObject, WKScriptMessageHandlerWithReply { // The page's flicker detector (``AgentPageFlickerLog``): short words and numbers for the // ring file, answered at once and not logged here. if let body = message.body as? [String: Any], body["method"] as? String == AgentPageFlickerLog.method, let view { - AgentPageFlickerLog.shared?.recordPage(body["params"] as? [String: Any], page: AgentPageFlickerLog.pageName(view), - pid: view.knownWebContentPid) + view.flickerLog?.recordPage(body["params"] as? [String: Any], page: AgentPageFlickerLog.pageName(view), + pid: view.knownWebContentPid) return replyHandler(AgentPaneReply.success(), nil) } // Where a chat rests belongs to the chat, so it is answered here from the one store both diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation.swift index 595d2a75fbf4..731f042276b9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation.swift @@ -39,8 +39,8 @@ final class AgentPaneNavigation: NSObject, WKNavigationDelegate { /// page with no `newTab.context` round trip (`injectedNewTabContext`, bd cx-z9kz0). The host /// gives what is true at this load: a reloaded page that became a chat gets none. func webView(_ webView: WKWebView, didCommit navigation: WKNavigation?) { - if let view { - AgentPageFlickerLog.shared?.record("navigation", ["page": AgentPageFlickerLog.pageName(view), "engine": view.engineName]) + if let view, let log = view.flickerLog { + log.record("navigation", ["page": AgentPageFlickerLog.pageName(view), "engine": view.engineName]) } guard let model = view?.model, model.sessionId == nil, var page = model.newTab else { return } // Its start folder too, so the first frame draws the project or no project (``AgentPaneNewTab/start``). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneShared.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneShared.swift index bd9d9894839a..35e8f30663f4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneShared.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneShared.swift @@ -11,19 +11,22 @@ public import CmuxNextDesign /// - `apps`: the app supervisor's client; each page gets its own ``AppsPanelsPageProvider`` over it (Session Info /// app sections). Nil (tests): the page has no `cmux.apps.panels.*` ops. /// - `actAsMe`: the app's one "Computer use can act as me" grant (``AgentPaneActAsMe``); nil (tests) grants nothing. +/// - `flickerLog`: the app run's flicker ring file (``AgentPageFlickerLog/forAppRun()``); nil (tests) writes none. public struct AgentPaneShared { public let openPanel: MadeAheadOpenPanel? public let eventSources: EventSourceProcesses public let apps: AppsClient? public let actAsMe: AgentPaneActAsMe? + public let flickerLog: AgentPageFlickerLog? - /// The defaults (tests): no open panel, a posting-process table of the pane's own, no apps and no grant. + /// The defaults (tests): no open panel, a posting-process table of the pane's own, no apps, no grant and no flicker log. public init(openPanel: MadeAheadOpenPanel? = nil, eventSources: EventSourceProcesses = EventSourceProcesses(), apps: AppsClient? = nil, - actAsMe: AgentPaneActAsMe? = nil) { + actAsMe: AgentPaneActAsMe? = nil, flickerLog: AgentPageFlickerLog? = nil) { self.openPanel = openPanel self.eventSources = eventSources self.apps = apps self.actAsMe = actAsMe + self.flickerLog = flickerLog } } @@ -32,7 +35,7 @@ extension AgentPaneView { public convenience init?(model: AgentPaneModel, source: AgentPaneSource? = nil, renderRate: AgentPaneRenderRate = .capped, pageHost: Bool = false, shared: AgentPaneShared) { self.init(model: model, source: source, renderRate: renderRate, pageHost: pageHost, panelProvider: shared.apps.map(AppsPanelsPageProvider.init(client:)), - openPanel: shared.openPanel, eventSources: shared.eventSources) + openPanel: shared.openPanel, eventSources: shared.eventSources, flickerLog: shared.flickerLog) model.transport.gestures.actAsMe = shared.actAsMe showActAsMe(shared.actAsMe) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift index 3912e360ffae..8b925a364846 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift @@ -61,6 +61,8 @@ public final class AgentPaneView: NSView { let crashRetry = AgentPageCrashRetry() /// The page ends of the app's agent panes, grouped by the termination that ended them. let terminations: AgentPageTerminations + /// The app's flicker ring file (``AgentPageFlickerLog``, made by the composition root); nil writes nothing (tests). + let flickerLog: AgentPageFlickerLog? /// The line that says the chat runs on another cmux install's acpmux (cx-fcaq). var otherInstallNotice: AgentPaneNotice? /// The pane's daemon is another cmux install's (cx-fcaq); the line shows only while a request waits. @@ -183,12 +185,14 @@ public final class AgentPaneView: NSView { /// - openPanel: The app's made-ahead open panel (`AppServices.openPanel`) for Add Files, Attach and a page's /// file input, warmed when the pane reaches a window; nil (tests) shows no file picker and warms none. /// - eventSources: The app's table of processes that post input (`AppServices.personInput.sources`); tests get their own. + /// - flickerLog: The app's flicker ring file (``AgentPaneShared/flickerLog``); nil (tests) writes none. public init?(model: AgentPaneModel, source: AgentPaneSource? = nil, renderRate: AgentPaneRenderRate = .capped, pageHost: Bool = false, testRoot: Bool = false, chromium: (any PageChromiumHosting)? = PageWebView.chromiumHosting, panelProvider: (any PageProvider)? = nil, openPanel: MadeAheadOpenPanel? = nil, terminations: AgentPageTerminations? = nil, - eventSources: EventSourceProcesses = EventSourceProcesses()) { + eventSources: EventSourceProcesses = EventSourceProcesses(), flickerLog: AgentPageFlickerLog? = nil) { self.terminations = terminations ?? Self.processGroups.terminations + self.flickerLog = flickerLog guard let source = source ?? Self.bundledPage.map({ AgentPaneSource.bundled($0) }) else { return nil } self.openPanel = openPanel self.eventSources = eventSources @@ -268,7 +272,7 @@ public final class AgentPaneView: NSView { if let self { LinkRouter.shared.open(url, modifiers: modifiers, from: self) } else { Self.routedOpenURL(url, modifiers) } } inputReadiness.attach(webView) - AgentPageFlickerLog.watchWindows() + flickerLog?.watchWindows() if let page { attachPage(page) } else { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift index d2b62b30e9b6..5fb247dd8fc3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift @@ -111,7 +111,7 @@ extension AgentTabStore { /// The agent tabs' view store of `services`, wired to every machine's tree and daemon. static func wired(to services: AppServices) -> AgentTabStore { let tabs = AgentTabStore(build: services.environment.build, registry: services.registry, environment: services.agentPaneEnvironment ?? ProcessInfo.processInfo.environment, showcase: services.environment.showcase, - linkScheme: services.linkScheme, settings: services.settings, paneShared: AgentPaneShared(openPanel: services.openPanel, eventSources: services.personInput.sources, apps: services.apps.client, actAsMe: services.personInput.actAsMe), + linkScheme: services.linkScheme, settings: services.settings, paneShared: AgentPaneShared(openPanel: services.openPanel, eventSources: services.personInput.sources, apps: services.apps.client, actAsMe: services.personInput.actAsMe, flickerLog: AgentPageFlickerLog.forAppRun()), attentionTabs: services.attentionTabs, daemons: services.acpmuxDaemons, updates: services.daemonUpdates, peerApprover: services.peerApprover) tabs.onRemoteChatStarted = { [weak services] host, session, cwd, harness in diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPageFlickerLogTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPageFlickerLogTests.swift index 14f18414b3a4..5762bcc5661a 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPageFlickerLogTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPageFlickerLogTests.swift @@ -1,4 +1,5 @@ #if DEBUG +import AppKit import Foundation import Testing @testable import CmuxNextAgentPane @@ -63,5 +64,22 @@ struct AgentPageFlickerLogTests { let olderSize = try #require(try FileManager.default.attributesOfItem(atPath: older.path)[.size] as? Int) #expect(olderSize <= AgentPageFlickerLog.maximumBytes) } + + /// The window watch is the log's own (the composition root makes the one log every pane shares), not + /// app-wide static state: each pane asks for it and a window's occlusion flip is one line, not one per pane. + @MainActor @Test func everyPaneAsksForTheWindowWatchAndAFlipIsOneLine() throws { + let log = AgentPageFlickerLog(directory: directory()) + defer { try? FileManager.default.removeItem(at: log.directory) } + let center = NotificationCenter() + log.watchWindows(center: center) + log.watchWindows(center: center) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 10, height: 10), styleMask: [.borderless], backing: .buffered, defer: false) + defer { window.close() } + center.post(name: NSWindow.didChangeOcclusionStateNotification, object: window) + log.flush() + let flips = try lines(log.url) + #expect(flips.map { $0["kind"] as? String } == ["window-occlusion"]) + #expect(flips.first?["window"] as? Int == window.windowNumber) + } } #endif From c425e04a4f841067068c5e166a7d0b06f87924b8 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:53:25 +0000 Subject: [PATCH 2/2] flicker log test: close its window with closeAfterTest (the test still holds it; review F1) --- .../CmuxNextAgentPaneTests/AgentPageFlickerLogTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPageFlickerLogTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPageFlickerLogTests.swift index 5762bcc5661a..8381717fb9b2 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPageFlickerLogTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPageFlickerLogTests.swift @@ -1,5 +1,6 @@ #if DEBUG import AppKit +import CmuxNextTestSupport import Foundation import Testing @testable import CmuxNextAgentPane @@ -74,7 +75,7 @@ struct AgentPageFlickerLogTests { log.watchWindows(center: center) log.watchWindows(center: center) let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 10, height: 10), styleMask: [.borderless], backing: .buffered, defer: false) - defer { window.close() } + defer { window.closeAfterTest() } center.post(name: NSWindow.didChangeOcclusionStateNotification, object: window) log.flush() let flips = try lines(log.url)