From 9d40107403c7184f1d7fc1cf2cf43ab8cc4d62c7 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:59:01 +0000 Subject: [PATCH 1/3] fix(cmux-next checks): clear the three base reds of the cmux-next checks job (package deps, crash ratchet) Package dependency lists sorted: CmuxNextAccounts' TeamInviteConsentWindow uses DemandTimer from CmuxNextWakeups (a0ee1353f6b); the target now declares it. Crash safety: six crash-ratchet rows rose on the tip. Each new hit is fixed in the code, not waived away: - cmux-tui-core unwrap 1149 -> 1150 (915bab1c6fa): the roster's agent_records read recovers a poisoned lock (PoisonError::into_inner), as its 340 siblings do. - CmuxNextSidebar dynamic_dispatch 3 -> 5 (f97775b558b): the disclosure chevron sets its turn as a typed CATransform3D, not by key-value coding. - MessagesLabHome index_subscript (banned) 0 -> 1 (6ac0d64eb5b): the row bitmap write takes its slot from out.checkedIndex on the same line again. - MessagesLabHome unchecked 14 -> 16 and unchecked.waived 1 -> 4 (6ac0d64eb5b): PaletteScope's main-thread slot, process palette and box table, and Fixture.baseGeneration, move from nonisolated(unsafe) statics into one OSAllocatedUnfairLock. Render workers read the process palette and the base generation while the main thread writes them, so this was a real data race. PaletteBox keeps @unchecked Sendable with a reviewed crash-allow (two lets set before publication): recorded with --record-waivers, waived 1 -> 2. - CmuxNextDesign unchecked.waived 3 -> 4 (17d64ea2fd6, #27385): the Dock policy observation keeps its object in an OSAllocatedUnfairLock (uncheckedState) instead of a hand-rolled @unchecked Sendable holder. Patches/ for Fixture, Transcript and WindowView regenerated with sync-messageslab.sh --write-patches (WindowView's patch was already stale on the tip); --check passes. The arch ratchet's global_mutable_statics falls by three; the one lock is a reviewed exception. Red on base b3587f50ac4, green here: python3 scripts/cmux-next/crash_ratchet.py (6 rows), python3 scripts/cmux-next/check-package-dependency-lists.py (CI run on 49d2105a0e4). Sidebar-Look: no visual change --- .../Patches/catalyst__Fixture.swift.patch | 23 +++--- .../Patches/catalyst__Transcript.swift.patch | 43 +++++----- .../Patches/catalyst__WindowView.swift.patch | 51 ++++++++---- .../MessagesLabHome/Cmux/PaletteScope.swift | 81 +++++++++++++------ .../Vendor/catalyst/Fixture.swift | 11 +-- .../Vendor/catalyst/Transcript.swift | 5 +- Packages/macOS/CmuxNext/Package.swift | 2 +- .../Sources/CmuxNextDesign/DockBadge.swift | 17 ++-- .../Views/DisclosureChevronView.swift | 8 +- .../src/mux/agent_roster_fold.rs | 8 +- plans/cmux-next/arch-ratchet.json | 6 ++ scripts/cmux-next/crash-safety-baseline.json | 4 +- 12 files changed, 164 insertions(+), 95 deletions(-) diff --git a/Packages/Shared/CmuxMessagesLab/Patches/catalyst__Fixture.swift.patch b/Packages/Shared/CmuxMessagesLab/Patches/catalyst__Fixture.swift.patch index 3a8f813f2aac..5b8df329a736 100644 --- a/Packages/Shared/CmuxMessagesLab/Patches/catalyst__Fixture.swift.patch +++ b/Packages/Shared/CmuxMessagesLab/Patches/catalyst__Fixture.swift.patch @@ -9,33 +9,34 @@ set { fallbackRenderScale = newValue } } /// The process value for code with no view: set from the first window's screen at launch and by every -@@ -56,12 +56,22 @@ +@@ -56,12 +56,23 @@ /// Light system appearance (the host sets it from its effective appearance). /// Link cards follow it: Messages' light card is the light incoming grey /// (#E9E9EB) with dark text. A change bumps `paletteGeneration`. - static var lightAppearance = false { didSet { if lightAppearance != oldValue { paletteGeneration += 1 } } } -+ static var lightAppearance = false { didSet { if lightAppearance != oldValue { baseGeneration += 1 } } } ++ static var lightAppearance = false { didSet { if lightAppearance != oldValue { PaletteScope.bumpBaseGeneration() } } } /// Bumped by an appearance or a colour space change: cached bitmaps are stale (not by a window's key state). - static fileprivate(set) var paletteGeneration = 0 -+ // cmux: written on the main thread only; off-main renders compare a generation they were given. -+ nonisolated(unsafe) static fileprivate(set) var baseGeneration = 0 ++ // cmux: written on the main thread and read by off-main renders (their cache keys, `generationIsLive`), so it ++ // sits behind PaletteScope's lock. ++ static var baseGeneration: Int { PaletteScope.baseGeneration } + /// cmux: the palette generation of the drawing that runs now: the base generation and the palette's id (the + /// scope's, a window view's own Chat Colors, else the process palette, PaletteScope). Every palette-dependent + /// cache is keyed by it, so two chats with different colours keep separate bitmaps and tiles. + static var paletteGeneration: Int { generation(of: PaletteScope.current) } + /// cmux: the generation drawing in a scope of `box` has now (nil: the process palette's). -+ static func generation(of box: PaletteBox?) -> Int { baseGeneration &* 1_000_003 &+ ((box ?? PaletteScope.process)?.id ?? 0) } ++ static func generation(of box: PaletteBox?) -> Int { PaletteScope.generation(of: box) } + /// cmux: `generation` was made since the last appearance or colour space change (its palette may be another + /// view's): a result drawn for it is still good under its own key. + static func generationIsLive(_ generation: Int) -> Bool { generation / 1_000_003 == baseGeneration } /// The bitmaps' colour space changed (a window moved to a display of another colour space): every cache keyed /// on the palette generation draws again (MessagesWindowView.displayColorSpaceChanged). - static func colorSpaceChanged() { paletteGeneration += 1 } -+ static func colorSpaceChanged() { baseGeneration += 1 } ++ static func colorSpaceChanged() { PaletteScope.bumpBaseGeneration() } /// Palette, measured with `screencapture -l` on macOS 27 Messages /// (cmux-lawrence-2, 2026-10-05): key and non-key windows are identical -@@ -74,11 +84,24 @@ +@@ -74,11 +85,24 @@ static func p3(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat) -> UIColor { UIColor(displayP3Red: r / 255, green: g / 255, blue: b / 255, alpha: 1) } @@ -61,7 +62,7 @@ /// The compose field's placeholder and audio waveform are see-through in Messages: the same ink over the /// key field (59, light 249) and the inactive one (50, light 240) is a constant step from it (lossless /// deactivate-activate and appearance-light takes, macOS 27, cx-3cb compose-parity): placeholder +63 in dark -@@ -88,7 +111,7 @@ +@@ -88,7 +112,7 @@ /// 10 levels too light on the inactive dark field, 60 levels too dark in light. static var placeholderInk: UIColor { lightAppearance ? UIColor(white: 0, alpha: 0.27) : UIColor(white: 1, alpha: 0.316) } static var waveformInk: UIColor { lightAppearance ? UIColor(white: 0, alpha: 0.39) : UIColor(white: 212 / 255, alpha: 0.556) } @@ -70,7 +71,7 @@ /// The typing indicator (bubble, tail discs, dots) is not the incoming grey: in Messages' key window it is /// one level darker in red and green (bubble and discs 58, 58, 61; dots dim 91, 91, 94, lit 133, 133, 135: /// the 12 settled lossless stills typing-dots-02..13 and send-typed-take1 +5.5 s), in its inactive window it -@@ -98,16 +121,31 @@ +@@ -98,16 +122,31 @@ /// `inactive`: the drawing view's own state (MessagesWindowView.inactive, through RowCell.inactive). static func typingColors(inactive: Bool) -> (fill: UIColor, dotDim: UIColor, dotLit: UIColor) { let dim = inactive && !lightAppearance && !elevated @@ -105,7 +106,7 @@ /// The capture caret after a send (cx-3cb parity26; Messages' macOS 26 original recording 4.65-5.30 s and /// macOS 27 send-typed-take1, lossless): it stays on the field's first line while the field collapses (the /// compose view moves it with the placeholder), starts in the accent colour and turns grey 0.04-0.22 s after -@@ -126,8 +164,8 @@ +@@ -126,8 +165,8 @@ return (s < 0.02 || s >= offFrom ? 0 : 1, c) } } @@ -116,7 +117,7 @@ /// Outgoing bubbles shade with their position in the window (measured: /// lighter near the top, deeper blue near the compose field). /// (window y in 2x px, red, green), Display P3. -@@ -152,10 +190,68 @@ +@@ -152,10 +191,68 @@ // optional, no force unwrap ; a nil gradient draws nothing. private static let darkGradient = gradient { p3($0, $1, 247) } private static let lightGradient = gradient(lightGradientColor) diff --git a/Packages/Shared/CmuxMessagesLab/Patches/catalyst__Transcript.swift.patch b/Packages/Shared/CmuxMessagesLab/Patches/catalyst__Transcript.swift.patch index 0f5c7a06d8a5..bc2fe0628112 100644 --- a/Packages/Shared/CmuxMessagesLab/Patches/catalyst__Transcript.swift.patch +++ b/Packages/Shared/CmuxMessagesLab/Patches/catalyst__Transcript.swift.patch @@ -93,7 +93,7 @@ fillContainer.addSublayer(fillGradient) fillContainer.mask = fillMask connectorLine.backgroundColor = connector.strokeColor -@@ -694,10 +723,21 @@ +@@ -694,12 +723,23 @@ key = "" // A bitmap kept in a live resize is not the row's own: the next configure draws it, even for the same row. if liveKept { liveKept = false; spec = nil } @@ -102,8 +102,8 @@ markdownOverlay?.detach() CustomRows.host?.detach(self) dropWant() -+ } -+ + } + + /// cmux: draws the selection highlight (`selectionHighlight`), nil for none. The path is in this cell's + /// coordinates. + func setSelectionHighlight(_ path: CGPath?) { @@ -112,9 +112,11 @@ + if path != nil, selectionHighlight.superlayer !== contentView.layer { contentView.layer.addSublayer(selectionHighlight) } + selectionHighlight.path = path + CATransaction.commit() - } - ++ } ++ func clearAnimations() { + layer.removeAllAnimations() + contentView.layer.removeAllAnimations() @@ -719,10 +759,11 @@ guard let spec else { return nil } switch spec.kind { @@ -199,13 +201,14 @@ var images = [CGImage?](repeating: nil, count: specs.count) // Per row: its drawing time on its worker (ms), for the display change's evidence. var rowMs = [Double](repeating: 0, count: specs.count) -@@ -1004,13 +1060,17 @@ +@@ -1004,13 +1060,18 @@ DispatchQueue.concurrentPerform(iterations: specs.count) { i in // checked reads and writes (crash ratchet) let r0 = CACurrentMediaTime() - if let slot = out.checkedIndex(i), let spec = specs[checked: i], let sc = scales[checked: i] { out[slot] = RowBitmaps.render(spec, scale: sc) } -+ if let slot = out.checkedIndex(i), let spec = specs[checked: i], let sc = scales[checked: i] { -+ out[slot] = PaletteScope.with(palettes[checked: i] ?? nil) { RowBitmaps.render(spec, scale: sc) } ++ if let spec = specs[checked: i], let sc = scales[checked: i] { ++ let image = PaletteScope.with(palettes[checked: i] ?? nil) { RowBitmaps.render(spec, scale: sc) } ++ if let slot = out.checkedIndex(i) { out[slot] = image } + } if let slot = ms.checkedIndex(i) { ms[slot] = (CACurrentMediaTime() - r0) * 1000 } } @@ -219,7 +222,7 @@ let t2 = CACurrentMediaTime() if displayPass || DisplayTiming.flushOpen { DisplayTiming.note("flush.draw", (t1 - t0) * 1000) -@@ -1133,7 +1193,7 @@ +@@ -1133,7 +1194,7 @@ private func snapTyping() { #if !os(iOS) || targetEnvironment(macCatalyst) guard case .typing? = spec?.kind else { return } @@ -228,7 +231,7 @@ // In the row's content, which snapRow already moved by rowSnap. let top = windowY + rowSnap.y + RowDraw.typingBubble.minY, left = layer.frame.minX + rowSnap.x + RowDraw.typingBubble.minX guard top.isFinite, left.isFinite else { return } -@@ -1197,8 +1257,11 @@ +@@ -1197,8 +1258,11 @@ box.position = CGPoint(x: c.x - 20 + box.anchorPoint.x * size.width, y: c.y - 20 + box.anchorPoint.y * size.height) box.contentsScale = renderScale badgeTop = c.y - 20 @@ -242,7 +245,7 @@ RowCell.placeFill(fill, windowTop: contentWindowY + badgeTop, width: size.width, span: fillSpan, reachBelow: fillReachBelow) if let holder = fill.superlayer, let mask = holder.mask as? CAShapeLayer { holder.frame = CGRect(origin: .zero, size: size) -@@ -1357,6 +1420,7 @@ +@@ -1357,6 +1421,7 @@ /// layer tree (o: the window view's origin there, k: the window's length of one view point along the axis, +1 or /// -1 when not scaled), rounded at `scale`. A tie (exactly half a pixel off) rounds up in the window, the same in /// every pass. @@ -250,7 +253,7 @@ static func snap(_ v: CGFloat, _ o: CGFloat, _ k: CGFloat, scale s: CGFloat) -> CGFloat { guard abs(k) > 1e-6 else { return 0 } let r = o + k * v -@@ -1386,7 +1450,7 @@ +@@ -1386,7 +1451,7 @@ case let .label(_, outgoing, _), let .replies(_, _, outgoing): return outgoing case .receipt: return true case let .threadPreview(pv): return pv.outgoing @@ -259,7 +262,7 @@ } } /// The x (view pt) of the row's anchored side for a cell at `frame` (snapRow, the send morph's landing place): the -@@ -1402,7 +1466,7 @@ +@@ -1402,7 +1467,7 @@ /// Moves the content to the snapped place; true when the snap changed. @discardableResult private func snapRow() -> Bool { #if !os(iOS) || targetEnvironment(macCatalyst) @@ -268,7 +271,7 @@ let x = RowCell.anchorX(spec, layer.frame) guard windowY.isFinite, x.isFinite else { return false } let d = CGPoint(x: RowCell.snap(x, windowPlace.origin.x, windowPlace.axis.x, scale: s), -@@ -1451,6 +1515,16 @@ +@@ -1451,6 +1516,16 @@ clipTypingBody(Springs.typingBreathBody, about: Springs.typingBreathAnchor, begin: c.beginTime - Springs.typingBreathBody.start) } @@ -285,7 +288,7 @@ /// Window y of the cell's top: the outgoing fill shades with it. var windowY: CGFloat = 0 { didSet { -@@ -1471,10 +1545,12 @@ +@@ -1471,10 +1546,12 @@ /// down to this bubble: an arc that hangs from the root and a vertical /// stroke whose bottom stays 8.5 pt above this bubble. When the two rows /// move apart, the arc and the stroke's height animate (no path animation). @@ -301,7 +304,7 @@ connectorState = state CATransaction.begin(); CATransaction.setDisableActions(true) defer { CATransaction.commit() } -@@ -1487,7 +1563,7 @@ +@@ -1487,7 +1564,7 @@ contentView.layer.insertSublayer(connector, at: 0) contentView.layer.insertSublayer(connectorLine, at: 1) } @@ -310,7 +313,7 @@ let cx = x + r, cy = top + r let w = spec?.width ?? 628 let p = UIBezierPath() -@@ -1566,6 +1642,8 @@ +@@ -1566,6 +1643,8 @@ a.isRemovedOnCompletion = false a.calculationMode = .linear if let p = Springs.typingStillPhase { a.speed = 0; a.timeOffset = p } @@ -319,7 +322,7 @@ hi.add(a, forKey: "dots") } } -@@ -1576,6 +1654,8 @@ +@@ -1576,6 +1655,8 @@ func startTypingBreath(insert: CFTimeInterval) { #if !os(iOS) || targetEnvironment(macCatalyst) guard Springs.typingStillPhase == nil else { return } @@ -328,7 +331,7 @@ let a = RowDraw.typingAnchor, b = Springs.typingBreathAnchor // The container scales about RowDraw.typingAnchor; the opposite offset of a scale e about it moves the // fixed point to b: e x (a - b). -@@ -1610,8 +1690,10 @@ +@@ -1610,8 +1691,10 @@ // 0 after snapTyping), read from the window's layer tree. let o = typingContainer.convert(CGPoint(x: r.minX, y: r.minY), to: nil) let o1 = typingContainer.convert(CGPoint(x: r.minX + 1, y: r.minY + 1), to: nil) @@ -340,7 +343,7 @@ guard g.isFinite, k.isFinite, abs(k) > 1e-6 else { return 0 } let f = (g - (g + 1e-6).rounded()) * (k < 0 ? -1 : 1) return abs(f) > 1e-6 ? f : 0 -@@ -1674,7 +1756,8 @@ +@@ -1674,7 +1757,8 @@ /// The typing row should breathe but does not (macOS, not a still scene): its breathing was stopped or never started. var needsTypingBreath: Bool { #if !os(iOS) || targetEnvironment(macCatalyst) diff --git a/Packages/Shared/CmuxMessagesLab/Patches/catalyst__WindowView.swift.patch b/Packages/Shared/CmuxMessagesLab/Patches/catalyst__WindowView.swift.patch index 1fb363253ec3..e999bef09b29 100644 --- a/Packages/Shared/CmuxMessagesLab/Patches/catalyst__WindowView.swift.patch +++ b/Packages/Shared/CmuxMessagesLab/Patches/catalyst__WindowView.swift.patch @@ -217,11 +217,10 @@ CATransaction.commit() } -@@ -900,6 +995,18 @@ - // (RowCell.crispEdge). `.sending` and `.sent` (the send itself) keep the crisp edge. +@@ -901,6 +996,18 @@ if case let .status(id, st) = action, st != .sending, st != .sent, crispSends.values.contains(id) { releaseCrisp { $0 == id } -+ } + } + // cmux: also every crisp send whose message is past sent in the new state (delivered, read or failed), whatever + // the action: Home sends one status per update (HomeDiff.keepNewestStatus: two sends delivered together give + // only the newer one a status) and a rebuild's replaceWindow carries statuses with no status action. Sends are @@ -233,9 +232,10 @@ + switch m.status { case .delivered?, .read?, .failed?: past.insert(m.id); default: break } + } + if !past.isEmpty { releaseCrisp { past.contains($0) } } - } ++ } // this view's width (several Home tabs), not the process-wide `Metrics.current`. if case let .setDraft(text) = action { MorphBubble.prepare(draft: text, width: bounds.width, scale: renderScale) } + // Rows configured in this transaction draw now (RowCell.transitionDepth). @@ -1007,7 +1114,8 @@ // The same for an action whose rows came out unchanged. let draft: Bool = { if case .setDraft = action { return true }; return false }() @@ -298,21 +298,22 @@ case let .react(ref, _, _): guard let i = index(ref.messageId) else { return nil } idx.append(i) -@@ -1511,7 +1636,14 @@ +@@ -1511,8 +1636,15 @@ for case let cell as RowCell in collection.visibleCells { if let b = map[cell.key] { cell.renameRow(to: b) } } + onRowsRenamed(map) } -+ + + /// cmux: rows renamed in place (a stream's final message took its rows), old key -> new key; the + /// transcript selection follows (TranscriptSelection.renameRows). + var onRowsRenamed: ([String: String]) -> Void = { _ in } + /// cmux: a cell was configured for a row (decorate); the transcript selection draws its part in it. + var onCellDecorated: (RowCell) -> Void = { _ in } - ++ /// Live container motions: (displacement, spring, begin, end, rows it covered). A row that /// shows up while one runs (inserted later, or outside its band) gets the opposite motion, + /// so it moves as it did with per-row springs. @@ -1689,22 +1821,41 @@ } @@ -566,7 +567,7 @@ } var threadOpen: Bool { store.state.ui.openThread != nil } -+ + + /// cmux: the space an open thread view's rows may take, between the header and the field (as a sheet's). + var threadArea: CGRect { + let top = Fixture.headerHeight + 12, bottom = max(top + 40, fieldTop - 12) @@ -592,7 +593,7 @@ + CATransaction.commit() + return true + } - ++ + /// cmux: Reduce Motion (the host's pin or the system setting): the thread view and a reply sent in it change at once. + private var reducesMotion: Bool { MessagesLabHomeView.reducesMotion } + @@ -615,7 +616,7 @@ func softMaskOwner(_ h: Hit) -> CALayer? { guard !threadOpen else { return nil } for case let cell as RowCell in collection.visibleCells where cell.spec?.key == h.key { return cell.fillContainer } -@@ -2252,7 +2479,7 @@ +@@ -2252,12 +2479,29 @@ let r = cell.convert(cell.bounds, to: self) if pv.outgoing { if p.x >= r.minX + spec.metrics.rightEdge - max(pv.box.width, 90) { return pv.root } @@ -624,7 +625,29 @@ } } return nil -@@ -2317,6 +2544,9 @@ + } + ++ /// cmux: the key of the transcript row under `p` (a visible cell), or nil. ++ func rowKey(at p: CGPoint) -> String? { ++ for case let cell as RowCell in collection.visibleCells where cell.convert(cell.bounds, to: self).contains(p) { ++ return cell.spec?.key ++ } ++ return nil ++ } ++ ++ /// cmux: where the transcript row `key` is in this view (a visible cell, under an open thread view's ++ /// blur too), or nil when it is not on screen. ++ func rowFrame(key: String) -> CGRect? { ++ for case let cell as RowCell in collection.visibleCells where cell.spec?.key == key { ++ return cell.convert(cell.bounds, to: self) ++ } ++ return nil ++ } ++ + func threadContentContains(_ p: CGPoint) -> Bool { + threadViews.contains { $0.frame.insetBy(dx: 0, dy: RowDraw.margin).contains(p) } + || threadHeaderView?.frame.contains(p) == true +@@ -2317,6 +2561,9 @@ /// The host's thread header changed (title, status, owner): the open thread view asks /// `HostHooks.threadHeader` again. @@ -634,7 +657,7 @@ func threadHeaderDidChange() { guard store.state.ui.threadMode == .tree, store.state.ui.openThread != nil, threadClosing == 0 else { return } threadForceRebuild = true -@@ -2515,12 +2745,21 @@ +@@ -2515,12 +2762,21 @@ return l } @@ -657,7 +680,7 @@ let delay = open ? ThreadBackdrop.openDelay : 0 let t = CABasicAnimation(keyPath: "opacity"); t.fromValue = a0; t.toValue = a1; t.duration = d; t.timingFunction = fn // Without a wait, start at the commit as the row flights do (`fly`, `fade`): a begin -@@ -2529,7 +2768,6 @@ +@@ -2529,7 +2785,6 @@ if delay > 0 { t.beginTime = CACurrentMediaTime() + delay; t.fillMode = .backwards } tint.add(t, forKey: "thread") if let blur { @@ -665,7 +688,7 @@ let b = CABasicAnimation(keyPath: "filters.gaussianBlur.inputRadius"); b.fromValue = r0; b.toValue = r1; b.duration = d; b.timingFunction = fn if delay > 0 { b.beginTime = CACurrentMediaTime() + delay; b.fillMode = .backwards } blur.add(b, forKey: "thread") -@@ -2727,7 +2965,7 @@ +@@ -2727,7 +2982,7 @@ let textTop = Fixture.bubblePadY if case let .part(p) = spec0.kind, case let .text(t, _) = p.part, p.text == nil { let l = LongTextStore.shared.layout(t, width: spec0.width, message: p.ref.messageId, markdown: p.markdownFormat) diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaletteScope.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaletteScope.swift index 7ff1e3cf2a34..6c268fdd9688 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaletteScope.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaletteScope.swift @@ -1,9 +1,11 @@ import AppKit +import os /// cmux: one window view's palette (its Home chat's theme and Chat Colors…), with a stable id that keys every /// palette-dependent cache. Equal themes share one box (``PaletteScope/box(for:)``), so equal palettes share /// their row bitmaps and tiles, and two chats with different colours never read each other's. -// Immutable after init; FixtureTheme holds NSColor and CGGradient values, which are safe to read from any thread. +// FixtureTheme is a struct of `let` NSColor and CGGradient values that nothing mutates after it is made. +// crash-allow: immutable after init (two `let`s set before the box is published), so any thread may read a box. final class PaletteBox: @unchecked Sendable { let theme: FixtureTheme let id: Int @@ -23,48 +25,79 @@ final class PaletteBox: @unchecked Sendable { enum PaletteScope { /// The scope's palette for work inside a Swift task or off the main thread. @TaskLocal static var scoped: PaletteBox? = nil - /// The scope's palette on the main thread outside any task (a layer's draw, a layout pass); a task local is not - /// bound there for the reason `RenderScaleScope.mainThreadScoped` gives. - // crash-allow: read and written only on the main thread (`with` and `current` check it). - nonisolated(unsafe) private static var mainThreadScoped: PaletteBox? = nil - /// The process palette: `Fixture.theme` outside any scope. - // crash-allow: written on the main thread only (`Fixture.theme`'s setter, `claimProcess`); a box is immutable. - nonisolated(unsafe) private(set) static var process: PaletteBox? = nil - /// Every palette made, by theme: equal themes share a box and its id. Main thread only. - // crash-allow: read and written only on the main thread (`box(for:)` asserts it). - nonisolated(unsafe) private static var boxes: [FixtureTheme: PaletteBox] = [:] + /// The palettes kept outside a task local, and `Fixture.baseGeneration`. The main thread writes them; render + /// workers read the process palette and the base generation while the main thread sets them, so they sit behind + /// one lock (a box is immutable, so a reader keeps the one it got). + private struct Shared { + /// `Fixture.baseGeneration`: bumped by an appearance or a colour space change. + var baseGeneration = 0 + /// The scope's palette on the main thread outside any task (a layer's draw, a layout pass); a task local is + /// not bound there for the reason `RenderScaleScope.mainThreadScoped` gives. Only the main thread uses it. + var mainThreadScoped: PaletteBox? + /// The process palette: `Fixture.theme` outside any scope. + var process: PaletteBox? + /// Every palette made, by theme: equal themes share a box and its id. Main thread only. + var boxesByTheme: [FixtureTheme: PaletteBox] = [:] + } + // FixtureTheme keys are not Sendable: the lock is their only access path, so its unchecked state is safe. + private static let shared = OSAllocatedUnfairLock(uncheckedState: Shared()) + /// The process palette: `Fixture.theme` outside any scope. + static var process: PaletteBox? { shared.withLockUnchecked { $0.process } } + /// `Fixture.baseGeneration` (any thread). + static var baseGeneration: Int { shared.withLockUnchecked { $0.baseGeneration } } + /// Bumps `Fixture.baseGeneration` (main thread: an appearance or a colour space change). + static func bumpBaseGeneration() { shared.withLockUnchecked { $0.baseGeneration += 1 } } + /// `Fixture.generation(of:)`: the base generation and the id of `box`, else of the process palette, read together. + static func generation(of box: PaletteBox?) -> Int { + shared.withLockUnchecked { $0.baseGeneration &* 1_000_003 &+ ((box ?? $0.process)?.id ?? 0) } + } /// The palette of the scope that runs now, nil outside any scope. - static var current: PaletteBox? { scoped ?? (Thread.isMainThread ? mainThreadScoped : nil) } - /// The palette drawing reads now: the scope's, else the process palette. - static var effective: PaletteBox? { current ?? process } + static var current: PaletteBox? { + if let scoped { return scoped } + guard Thread.isMainThread else { return nil } + return shared.withLockUnchecked { $0.mainThreadScoped } + } + /// The palette drawing reads now: the scope's, else the process palette (one lock, not two). + static var effective: PaletteBox? { + if let scoped { return scoped } + let onMain = Thread.isMainThread + return shared.withLockUnchecked { (onMain ? $0.mainThreadScoped : nil) ?? $0.process } + } /// The one box for `theme` (main thread). static func box(for theme: FixtureTheme) -> PaletteBox { dispatchPrecondition(condition: .onQueue(.main)) - if let b = boxes[theme] { return b } - let b = PaletteBox(theme, id: boxes.count + 1) - boxes[theme] = b - return b + return shared.withLockUnchecked { state in + if let b = state.boxesByTheme[theme] { return b } + let b = PaletteBox(theme, id: state.boxesByTheme.count + 1) + state.boxesByTheme[theme] = b + return b + } } /// Sets the process palette (`Fixture.theme`'s setter): the palette outside any scope. - static func setProcess(_ theme: FixtureTheme?) { process = theme.map(box(for:)) } + static func setProcess(_ theme: FixtureTheme?) { + let box = theme.map(box(for:)) + shared.withLockUnchecked { $0.process = box } + } /// The chat the person typed or clicked in gives the process palette (its composer and send read it outside /// a scope). static func claimProcess(_ box: PaletteBox?) { - guard let box, process !== box else { return } - process = box + guard let box else { return } + shared.withLockUnchecked { if $0.process !== box { $0.process = box } } } /// Runs `body` with `box` as this thread's palette (nil: the current one stays). static func with(_ box: PaletteBox?, _ body: () throws -> T) rethrows -> T { guard let box, current !== box else { return try body() } if Thread.isMainThread, !withUnsafeCurrentTask(body: { $0 != nil }) { - let outer = mainThreadScoped - mainThreadScoped = box - defer { mainThreadScoped = outer } + let outer = shared.withLockUnchecked { state in + defer { state.mainThreadScoped = box } + return state.mainThreadScoped + } + defer { shared.withLockUnchecked { $0.mainThreadScoped = outer } } return try body() } // concurrency-allow: bound inside a task or off the main thread, never in fallback storage on main diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Vendor/catalyst/Fixture.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Vendor/catalyst/Fixture.swift index 201fa22b46f4..9acb43ddb9db 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Vendor/catalyst/Fixture.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Vendor/catalyst/Fixture.swift @@ -56,22 +56,23 @@ enum Fixture { /// Light system appearance (the host sets it from its effective appearance). /// Link cards follow it: Messages' light card is the light incoming grey /// (#E9E9EB) with dark text. A change bumps `paletteGeneration`. - static var lightAppearance = false { didSet { if lightAppearance != oldValue { baseGeneration += 1 } } } + static var lightAppearance = false { didSet { if lightAppearance != oldValue { PaletteScope.bumpBaseGeneration() } } } /// Bumped by an appearance or a colour space change: cached bitmaps are stale (not by a window's key state). - // cmux: written on the main thread only; off-main renders compare a generation they were given. - nonisolated(unsafe) static fileprivate(set) var baseGeneration = 0 + // cmux: written on the main thread and read by off-main renders (their cache keys, `generationIsLive`), so it + // sits behind PaletteScope's lock. + static var baseGeneration: Int { PaletteScope.baseGeneration } /// cmux: the palette generation of the drawing that runs now: the base generation and the palette's id (the /// scope's, a window view's own Chat Colors, else the process palette, PaletteScope). Every palette-dependent /// cache is keyed by it, so two chats with different colours keep separate bitmaps and tiles. static var paletteGeneration: Int { generation(of: PaletteScope.current) } /// cmux: the generation drawing in a scope of `box` has now (nil: the process palette's). - static func generation(of box: PaletteBox?) -> Int { baseGeneration &* 1_000_003 &+ ((box ?? PaletteScope.process)?.id ?? 0) } + static func generation(of box: PaletteBox?) -> Int { PaletteScope.generation(of: box) } /// cmux: `generation` was made since the last appearance or colour space change (its palette may be another /// view's): a result drawn for it is still good under its own key. static func generationIsLive(_ generation: Int) -> Bool { generation / 1_000_003 == baseGeneration } /// The bitmaps' colour space changed (a window moved to a display of another colour space): every cache keyed /// on the palette generation draws again (MessagesWindowView.displayColorSpaceChanged). - static func colorSpaceChanged() { baseGeneration += 1 } + static func colorSpaceChanged() { PaletteScope.bumpBaseGeneration() } /// Palette, measured with `screencapture -l` on macOS 27 Messages /// (cmux-lawrence-2, 2026-10-05): key and non-key windows are identical diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Vendor/catalyst/Transcript.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Vendor/catalyst/Transcript.swift index b393261ca95d..f6433d03098c 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Vendor/catalyst/Transcript.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Vendor/catalyst/Transcript.swift @@ -1060,8 +1060,9 @@ final class RowCell: UICollectionViewCell { DispatchQueue.concurrentPerform(iterations: specs.count) { i in // checked reads and writes (crash ratchet) let r0 = CACurrentMediaTime() - if let slot = out.checkedIndex(i), let spec = specs[checked: i], let sc = scales[checked: i] { - out[slot] = PaletteScope.with(palettes[checked: i] ?? nil) { RowBitmaps.render(spec, scale: sc) } + if let spec = specs[checked: i], let sc = scales[checked: i] { + let image = PaletteScope.with(palettes[checked: i] ?? nil) { RowBitmaps.render(spec, scale: sc) } + if let slot = out.checkedIndex(i) { out[slot] = image } } if let slot = ms.checkedIndex(i) { ms[slot] = (CACurrentMediaTime() - r0) * 1000 } } diff --git a/Packages/macOS/CmuxNext/Package.swift b/Packages/macOS/CmuxNext/Package.swift index 8c1c228c732a..6764115ba6a1 100644 --- a/Packages/macOS/CmuxNext/Package.swift +++ b/Packages/macOS/CmuxNext/Package.swift @@ -442,7 +442,7 @@ let packageTargets: [Target] = uiLayerPreviewTargets + [ // supplies `AccountsServices`. .target( name: "CmuxNextAccounts", - dependencies: ["CmuxNextCloud", "CmuxNextCodeRouter", "CmuxNextDesign", "CmuxNextIcons", .product(name: "CmuxAgentBrands", package: "CmuxAgentBrands"), + dependencies: ["CmuxNextCloud", "CmuxNextCodeRouter", "CmuxNextDesign", "CmuxNextIcons", "CmuxNextWakeups", .product(name: "CmuxAgentBrands", package: "CmuxAgentBrands"), .product(name: "CMUXAuthCore", package: "CMUXAuthCore")], resources: [ .process("Localizable.xcstrings"), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DockBadge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DockBadge.swift index db065ae353ce..2a319b409579 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DockBadge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DockBadge.swift @@ -1,4 +1,5 @@ public import AppKit +import os /// The Dock icon's unread badge (`notifications.dockBadge`): the label the app last showed, written /// to the tile only when it changes (writing an equal label would still ask the Dock). @@ -48,24 +49,16 @@ public final class DockBadge { // Only the newest policy matters: a burst of flips settles on the last. AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in // KVO does not retain its observed object. Keep it alive until the token is invalidated, - // then release it even if the stream itself remains retained by a cancelled task. - let lifetime = ActivationPolicyObservationLifetime(app) + // then release it even if the stream itself remains retained by a cancelled task. The lock + // owns the reference, so the termination callback may drop it from any thread. + let lifetime = OSAllocatedUnfairLock(uncheckedState: app) let token = app.observe(keyPath, options: [.new]) { source, _ in continuation.yield(source[keyPath: keyPath]) } continuation.onTermination = { _ in token.invalidate() - lifetime.release() + lifetime.withLockUnchecked { $0 = nil } } } } } - -// crash-allow: initialized before publication; only the once-only termination callback releases its reference, after KVO invalidation. -private nonisolated final class ActivationPolicyObservationLifetime: @unchecked Sendable { - private var object: Object? - - init(_ object: Object) { self.object = object } - - func release() { object = nil } -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/DisclosureChevronView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/DisclosureChevronView.swift index cf7dd78b92e9..02d03fb0c60e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/DisclosureChevronView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/DisclosureChevronView.swift @@ -58,7 +58,7 @@ final class DisclosureChevronView: NSView { let target = angle(open: open) guard changed, animated else { glyph.removeAnimation(forKey: Self.rotation) - Motion.sidebar.transaction(nil) { glyph.setValue(target, forKeyPath: Self.rotation) } + Motion.sidebar.transaction(nil) { setTurn(target) } return } Motion.sidebar.set(glyph, Self.rotation, to: target, spring: open ? .appear : .disappear) @@ -66,6 +66,10 @@ final class DisclosureChevronView: NSView { static let rotation = "transform.rotation.z" + /// Sets the glyph's model turn to `angle` about its center: the turn is the glyph's only transform, so this is + /// the value `transform.rotation.z` animates, set as a typed transform rather than by key-value coding. + private func setTurn(_ angle: CGFloat) { glyph.transform = CATransform3DMakeRotation(angle, 0, 0, 1) } + /// The chevron image (a template glyph pointing right). The view always shows the one chevron glyph, so an image /// of the size it already shows, at the same window scale, is not drawn again. func setImage(_ image: NSImage?) { @@ -99,7 +103,7 @@ final class DisclosureChevronView: NSView { glyph.position = CGPoint(x: bounds.midX, y: bounds.midY) shape.frame = glyph.bounds // A flip of the layer tree after the last turn (first layout in a window) keeps the glyph's direction. - if glyph.animation(forKey: Self.rotation) == nil { glyph.setValue(angle(open: isOpen), forKeyPath: Self.rotation) } + if glyph.animation(forKey: Self.rotation) == nil { setTurn(angle(open: isOpen)) } } } diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/agent_roster_fold.rs b/cmux-tui/crates/cmux-tui-core/src/mux/agent_roster_fold.rs index 7bb740a54acc..80a5894969fb 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/agent_roster_fold.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/agent_roster_fold.rs @@ -51,8 +51,12 @@ impl Mux { let terminal_id = TerminalPublicId::parse(&terminal_subject.id) .with_context(|| format!("invalid terminal subject {:?}", terminal_subject.id))?; // A completed tool call resumes only a blocked agent, so the roster's state decides it. - let current = - self.agent_records.lock().unwrap().get(&terminal_id).map(|record| record.state); + let current = self + .agent_records + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(&terminal_id) + .map(|record| record.state); let state = hook_roster_state(&ingress.kind, native_event, current); if state.is_none() && notification.is_none() { return Ok(()); diff --git a/plans/cmux-next/arch-ratchet.json b/plans/cmux-next/arch-ratchet.json index d751652dd191..788b4d47e167 100644 --- a/plans/cmux-next/arch-ratchet.json +++ b/plans/cmux-next/arch-ratchet.json @@ -603,6 +603,12 @@ "row": "global_mutable_statics", "allow": 4, "why": "MessagesLabHome PaletteScope (per-chat bubble colours, user 2026-10-28): the vendored MessagesLab renderer reads its palette from process-wide statics (Fixture.theme, Fixture.paletteGeneration) on any thread, so two Home chats on screen with different Chat Colors overwrote each other. The fix mirrors the renderer's existing RenderScaleScope: a main-thread scope slot (mainThreadScoped, as RenderScaleScope's, to avoid binding a task local on main outside a task), the process palette that replaces the stored Fixture.theme, a main-only intern table so equal palettes share cache ids, and Fixture.baseGeneration that replaces the stored paletteGeneration. Moving them into an injected instance would mean rewriting every static drawing helper of the vendored renderer, which reads Fixture.* by design." + }, + { + "date": "2026-10-30", + "row": "global_mutable_statics", + "allow": 1, + "why": "MessagesLabHome PaletteScope's one lock (crash ratchet base red, 2026-10-30): it replaces the four unsynchronized statics of the 2026-10-28 exception above (mainThreadScoped, process, the box table and Fixture.baseGeneration; three nonisolated(unsafe) waivers and one counted hit) with one OSAllocatedUnfairLock that guards them, because render workers read the process palette and the base generation while the main thread writes them: a data race the nonisolated(unsafe) marks only hid. The count falls by three. The state stays process-wide for the same reason as that exception: the vendored MessagesLab renderer reads Fixture.* statics from every static drawing helper by design." } ], "observer_wrappers": [ diff --git a/scripts/cmux-next/crash-safety-baseline.json b/scripts/cmux-next/crash-safety-baseline.json index 56fd5b172800..f6faebf5b06c 100644 --- a/scripts/cmux-next/crash-safety-baseline.json +++ b/scripts/cmux-next/crash-safety-baseline.json @@ -15,7 +15,7 @@ "cmux-app-host": 2, "cmux-browser-host": 1, "cmux-mux2": 42, - "cmux-remote": 44, + "cmux-remote": 39, "cmux-terminal-client": 1, "cmux-tui": 76, "cmux-tui-core": 190, @@ -179,7 +179,7 @@ "CmuxVNC": 3, "CmuxVNCDemoServer": 3, "CmuxVNCWebRTC": 19, - "MessagesLabHome": 1 + "MessagesLabHome": 2 } } } From 0681b9a313e01f03838552a602e207453858013d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:12:55 +0000 Subject: [PATCH 2/3] arch ratchet: the PaletteScope lock exception names its owner and expires 2026-11-13 --- plans/cmux-next/arch-ratchet.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plans/cmux-next/arch-ratchet.json b/plans/cmux-next/arch-ratchet.json index 788b4d47e167..e85df9f01039 100644 --- a/plans/cmux-next/arch-ratchet.json +++ b/plans/cmux-next/arch-ratchet.json @@ -608,6 +608,8 @@ "date": "2026-10-30", "row": "global_mutable_statics", "allow": 1, + "owner": "cmux-next Performance Lead (cmux-orchestrator-1), PR #28036", + "expires": "2026-11-13", "why": "MessagesLabHome PaletteScope's one lock (crash ratchet base red, 2026-10-30): it replaces the four unsynchronized statics of the 2026-10-28 exception above (mainThreadScoped, process, the box table and Fixture.baseGeneration; three nonisolated(unsafe) waivers and one counted hit) with one OSAllocatedUnfairLock that guards them, because render workers read the process palette and the base generation while the main thread writes them: a data race the nonisolated(unsafe) marks only hid. The count falls by three. The state stays process-wide for the same reason as that exception: the vendored MessagesLab renderer reads Fixture.* statics from every static drawing helper by design." } ], From 98a7c2c8e8b512684bb78f9c9cb92b4f1a16bfc5 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:17:51 +0000 Subject: [PATCH 3/3] fix(home): PaletteBox's crash-allow names why it is not checked Sendable, its owner and its expiry (2026-11-13) A checked Sendable PaletteBox needs a Sendable FixtureTheme, and FixtureTheme.swift:28 holds a CGGradient, which the SDK does not mark Sendable: the fleet probe e72b1f95374d27da0c9f8e94 (scratch 78d5dd91cf7) failed with "stored property 'gradient' ... contains non-Sendable type 'CGGradient'" (NSColor passed). The waiver stays, recorded as before. --- .../Sources/MessagesLabHome/Cmux/PaletteScope.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaletteScope.swift b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaletteScope.swift index 6c268fdd9688..9b7204f724f4 100644 --- a/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaletteScope.swift +++ b/Packages/Shared/CmuxMessagesLab/Sources/MessagesLabHome/Cmux/PaletteScope.swift @@ -4,8 +4,10 @@ import os /// cmux: one window view's palette (its Home chat's theme and Chat Colors…), with a stable id that keys every /// palette-dependent cache. Equal themes share one box (``PaletteScope/box(for:)``), so equal palettes share /// their row bitmaps and tiles, and two chats with different colours never read each other's. -// FixtureTheme is a struct of `let` NSColor and CGGradient values that nothing mutates after it is made. -// crash-allow: immutable after init (two `let`s set before the box is published), so any thread may read a box. +// Not checked Sendable: FixtureTheme.swift:28 (`outgoingGradient: CGGradient?`) holds a CGGradient, which the SDK +// does not mark Sendable (fleet probe e72b1f95374d27da0c9f8e94, 2026-10-30). FixtureTheme is a struct of `let`s that +// nothing mutates after init. Owner: cmux-next Performance Lead (PR #28036); this waiver expires 2026-11-13. +// crash-allow: immutable: two `let`s set in init, before the box is published to any other thread, so any thread may read it. final class PaletteBox: @unchecked Sendable { let theme: FixtureTheme let id: Int