Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5187332
test: preserve remote tmux pane identity across layout changes
austinywang Jul 10, 2026
0dc8aab
Fix remote tmux layout reconciliation identity
austinywang Jul 10, 2026
80a3273
Preserve initial multi-pane sizing ownership
austinywang Jul 10, 2026
d2f1210
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang Jul 10, 2026
d78c794
Keep remote tmux control identities render-independent
austinywang Jul 10, 2026
baf237e
test: keep remote session pane identities distinct
austinywang Jul 10, 2026
cce3649
test: publish initial remote windows atomically
austinywang Jul 10, 2026
9f4c728
test: make mirror lookup capture explicit
austinywang Jul 10, 2026
fae63cd
ci: route helper build to SDK 15 runner
austinywang Jul 10, 2026
24265ab
test: gate remote tmux identity regression directly
austinywang Jul 10, 2026
fb3553a
Fix remote tmux session pane identity ownership
austinywang Jul 10, 2026
1171508
Deduplicate remote tmux mirror test lookup
austinywang Jul 10, 2026
f686623
Keep workspace file within hard cap
austinywang Jul 10, 2026
19f418f
Preserve pane ownership through layout reorders
austinywang Jul 10, 2026
b530462
Import Bonsplit in remote tmux observability tests
austinywang Jul 10, 2026
0c53eab
Reconcile final-pane moves atomically
austinywang Jul 10, 2026
60fa525
Fix remote tmux identity test references
austinywang Jul 10, 2026
5de8edc
Retain pane identity through topology refresh
austinywang Jul 10, 2026
f45763f
Merge origin/main into issue-7833-mirror-incremental-split
austinywang Jul 10, 2026
09af1ab
Scope remote tmux topology refresh ownership
austinywang Jul 10, 2026
f7381cb
Install Node for CLI wrapper CI regressions
austinywang Jul 10, 2026
dc526f2
Merge origin/main into issue-7833-mirror-incremental-split
austinywang Jul 10, 2026
fbed5ec
Merge origin/main into issue-7833-mirror-incremental-split
austinywang Jul 11, 2026
a9998ef
Recover retained pane identities after topology errors
austinywang Jul 11, 2026
264dc7c
Integrate remote tmux resize ownership
austinywang Jul 11, 2026
6c4558a
Close remote tmux tabs before topology refresh
austinywang Jul 11, 2026
bdcc92f
Coalesce remote tmux topology refreshes
austinywang Jul 11, 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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,25 @@ jobs:
-only-testing:cmuxTests/RemoteTmuxWindowMirrorFocusSeedTests \
test

- name: Run remote tmux mirror layout identity regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# The tolerant full-suite step may accept expected failures or stop
# after an app-host crash. Keep #7833's identity suite non-tolerant
# so a missing incremental-reconcile regression cannot pass the shard.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/RemoteTmuxMirrorLayoutIdentityTests \
test

- name: Run unit tests
run: |
set -euo pipefail
Expand Down
5 changes: 3 additions & 2 deletions Sources/RemoteTmuxControlCommandKind.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import Foundation

enum RemoteTmuxControlCommandKind: Equatable {
/// A topology snapshot tagged with the accepted reorder generation at send time.
case listWindows(reorderGeneration: UInt64)
/// A topology snapshot tagged with the accepted reorder generation and the
/// exact close-gap pane identities it may release when the reply succeeds.
case listWindows(reorderGeneration: UInt64, retainedPaneIDs: Set<Int>)
/// An order-only snapshot used to verify a successful swap batch cheaply.
case listWindowOrder(reorderGeneration: UInt64)
case capturePane(Int)
Expand Down
25 changes: 21 additions & 4 deletions Sources/RemoteTmuxControlConnection+CommandResults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ extension RemoteTmuxControlConnection {
// misalign the positional correlation.
guard !pendingCommands.isEmpty else { return }
let kind = pendingCommands.removeFirst()
defer {
if case .listWindows = kind {
completeWindowListRequest()
}
}
guard !isError else {
// An errored activity query must still complete (with nil) — a close
// decision is waiting on it and falls back to the cached state.
Expand Down Expand Up @@ -41,9 +46,13 @@ extension RemoteTmuxControlConnection {
if case let .windowReorder(isLast) = kind {
completeWindowReorderCommand(isLast: isLast, failed: true)
}
if case let .listWindows(requestGeneration) = kind,
windowReorderRecoveryGeneration == requestGeneration {
restartAfterWindowReorderRecoveryFailure()
if case let .listWindows(requestGeneration, retainedPaneIDs) = kind {
if windowReorderRecoveryGeneration == requestGeneration {
restartAfterWindowReorderRecoveryFailure()
} else if !retainedPaneIDs.isEmpty {
record("window-list-retention-reconnect")
beginReconnecting()
}
}
if case .listWindowOrder = kind {
requestFullWindowOrderRecovery()
Expand All @@ -67,7 +76,7 @@ extension RemoteTmuxControlConnection {
completion(windowId)
case let .paneRects(windowId, generation):
handlePaneRectsReply(windowId: windowId, generation: generation, lines: lines)
case let .listWindows(requestGeneration):
case let .listWindows(requestGeneration, retainedPaneIDs):
// A pending order verification owns the window-order ledger: an
// incidental topology refetch (e.g. a %window-add landing mid-batch)
// shares the current generation tag, and letting it replace the
Expand Down Expand Up @@ -124,6 +133,7 @@ extension RemoteTmuxControlConnection {
// only by its rects reply. Verified entries for surviving
// windows stay as-is until then.
windowsByID = windowsByID.filter { liveIDs.contains($0.key) }
prunePublishedPaneOwnership(liveWindowIds: liveIDs)
pendingLayouts = pendingLayouts.filter { liveIDs.contains($0.key) }
// A population that starts from an empty table (first attach,
// reconnect reseed after every window closed) publishes
Expand All @@ -150,6 +160,10 @@ extension RemoteTmuxControlConnection {
zoomed: window.zoomed, name: window.name
)
}
// This complete snapshot decides only the close gaps already
// represented when its request was sent. A later overlapping
// close remains retained for its own snapshot.
paneIDsRetainedUntilWindowList.subtract(retainedPaneIDs)
Comment thread
cursor[bot] marked this conversation as resolved.
// Per-window sizing state must not outlive the topology: a
// stale pin would be replayed by the reconnect reseed, and a
// pending debounce could fire at a dead @id.
Expand Down Expand Up @@ -209,6 +223,9 @@ extension RemoteTmuxControlConnection {
// publishes only when the rects replies land.
} else if completesReorderRecovery {
restartAfterWindowReorderRecoveryFailure()
} else if !retainedPaneIDs.isEmpty {
record("window-list-retention-reconnect")
beginReconnecting()
}
case let .listWindowOrder(requestGeneration):
let order = lines.compactMap { line in
Expand Down
26 changes: 23 additions & 3 deletions Sources/RemoteTmuxControlConnection+Commands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,30 @@ extension RemoteTmuxControlConnection {
/// id and layout tokens never do — so the result parses as
/// `@id <layout> <name with spaces…>`.
func requestWindows() {
sendInternal(
guard !windowListRequestInFlight else {
windowListRequestDirty = true
return
}
guard sendInternal(
"list-windows -F \"#{window_id} #{window_layout} #{window_visible_layout} [#{window_flags}] #{window_name}\"",
kind: .listWindows(reorderGeneration: windowReorderGeneration)
)
kind: .listWindows(
reorderGeneration: windowReorderGeneration,
retainedPaneIDs: paneIDsRetainedUntilWindowList
)
) else { return }
windowListRequestInFlight = true
}

func completeWindowListRequest() {
windowListRequestInFlight = false
guard windowListRequestDirty else { return }
windowListRequestDirty = false
requestWindows()
}

func resetWindowListRequestCoalescing() {
windowListRequestInFlight = false
windowListRequestDirty = false
}

func restartAfterWindowReorderRecoveryFailure() {
Expand Down
31 changes: 31 additions & 0 deletions Sources/RemoteTmuxControlConnection+LayoutPublication.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ extension RemoteTmuxControlConnection {
func flushInitialBatchIfDrained() {
guard let awaiting = initialBatchAwaiting, awaiting.isEmpty else { return }
for (id, window) in initialBatchStaged { windowsByID[id] = window }
rebuildPublishedPaneOwnership()
initialBatchStaged = [:]
initialBatchAwaiting = nil
prunePaneState(keeping: Set(windowsByID.values.flatMap { $0.paneIDsInOrder }))
Expand Down Expand Up @@ -235,6 +236,10 @@ extension RemoteTmuxControlConnection {
return
}
windowsByID[windowId] = published
recordPublishedPaneOwnership(
windowId: windowId,
paneIds: published.paneIDsInOrder
)
if !windowOrder.contains(windowId) { windowOrder.append(windowId) }
prunePaneState(keeping: Set(windowsByID.values.flatMap { $0.paneIDsInOrder }))
observers.notifyTopologyChanged()
Expand All @@ -249,6 +254,32 @@ extension RemoteTmuxControlConnection {
scheduleAttachRedrawKickIfNeeded()
}

func recordPublishedPaneOwnership(windowId: Int, paneIds: [Int]) {
let livePaneIds = Set(paneIds)
publishedWindowIdByPane = publishedWindowIdByPane.filter {
$0.value != windowId || livePaneIds.contains($0.key)
}
for paneId in paneIds { publishedWindowIdByPane[paneId] = windowId }
}

func removePublishedPaneOwnership(windowId: Int) {
publishedWindowIdByPane = publishedWindowIdByPane.filter { $0.value != windowId }
}

func prunePublishedPaneOwnership(liveWindowIds: Set<Int>) {
publishedWindowIdByPane = publishedWindowIdByPane.filter {
liveWindowIds.contains($0.value)
}
}

func rebuildPublishedPaneOwnership() {
publishedWindowIdByPane.removeAll(keepingCapacity: true)
for windowId in windowOrder {
guard let window = windowsByID[windowId] else { continue }
for paneId in window.paneIDsInOrder { publishedWindowIdByPane[paneId] = windowId }
}
}


/// Retry-or-drop for a rects fetch that errored: the pending layout must
/// never be published raw, and must not dangle in-flight forever.
Expand Down
21 changes: 21 additions & 0 deletions Sources/RemoteTmuxControlConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ final class RemoteTmuxControlConnection {
private(set) var sessionId: Int?
var windowsByID: [Int: RemoteTmuxWindow] = [:]
var windowOrder: [Int] = []
var publishedWindowIdByPane: [Int: Int] = [:]
/// Pane identities whose ownership is temporarily undecidable after their
/// source window closes, retained until `list-windows` supplies a complete snapshot.
var paneIDsRetainedUntilWindowList: Set<Int> = []
var activePaneByWindow: [Int: Int] = [:]
var paneOutputByteCounts: [Int: Int] = [:]
var totalOutputBytes = 0
Expand Down Expand Up @@ -98,6 +102,8 @@ final class RemoteTmuxControlConnection {
private var ingestTask: Task<Void, Never>?
private var processGeneration: UInt64 = 0
var pendingCommands: [CommandKind] = []
var windowListRequestInFlight = false
var windowListRequestDirty = false
var windowReorderBatchFailed = false
var windowReorderGeneration: UInt64 = 0
var windowReorderRecoveryGeneration: UInt64?
Expand Down Expand Up @@ -307,6 +313,7 @@ final class RemoteTmuxControlConnection {
// A fresh control stream cannot retain the prior parser or command FIFO.
parser = RemoteTmuxControlStreamParser()
pendingCommands.removeAll()
resetWindowListRequestCoalescing()
windowReorderBatchFailed = false
windowReorderRecoveryGeneration = nil
pendingLayouts.removeAll()
Expand Down Expand Up @@ -449,6 +456,7 @@ final class RemoteTmuxControlConnection {
failPendingWindowReorderVerifications()
reconnectTask?.cancel()
reconnectTask = nil
resetWindowListRequestCoalescing()
cancelSizingFollowUps()
pendingPostAttachAction = nil
}
Expand Down Expand Up @@ -608,6 +616,7 @@ final class RemoteTmuxControlConnection {
failPendingActivityQueries()
failPendingNewWindowRequests()
failPendingWindowReorderVerifications()
resetWindowListRequestCoalescing()
cancelSizingFollowUps()
pendingPostAttachAction = nil
teardownProcessHandles()
Expand Down Expand Up @@ -717,6 +726,9 @@ final class RemoteTmuxControlConnection {
record("window-add @\(id)")
requestWindows()
case let .windowClose(id):
let closingPaneIDs = Set(windowsByID[id]?.paneIDsInOrder ?? [])
.union(pendingLayouts[id]?.node.paneIDsInOrder ?? [])
paneIDsRetainedUntilWindowList.formUnion(closingPaneIDs)
// Release the closed window's per-window sizing state: a stale
// entry would be replayed by the reconnect reseed, and a pending
// debounce could still fire at a dead @id target.
Expand All @@ -733,13 +745,22 @@ final class RemoteTmuxControlConnection {
}
}
activePaneByWindow[id] = nil
removePublishedPaneOwnership(windowId: id)
windowsByID[id] = nil
windowTitleRowsVisible[id] = nil
windowOrder.removeAll { $0 == id }
pendingLayouts[id] = nil
initialBatchStaged[id] = nil
finishInitialBatchMember(id)
record("window-close @\(id)")
// A move of the window's final pane reports the source close before
// the destination layout. Re-list atomically so observers reconcile
// against the destination's pending tree instead of pruning the
// surviving pane during that event gap.
requestWindows()
// Remove the closed window's tab immediately. The retained-pane
// ledger above keeps any moved pane's control identity alive until
// the authoritative window snapshot publishes its destination.
observers.notifyTopologyChanged()
case let .windowRenamed(id, name):
record("window-renamed @\(id)")
Expand Down
6 changes: 6 additions & 0 deletions Sources/RemoteTmuxControlKeySendResult.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// Outcome of translating and forwarding one control-plane key to tmux.
enum RemoteTmuxControlKeySendResult {
case sent
case rejected
case unknownKey
}
3 changes: 1 addition & 2 deletions Sources/RemoteTmuxControlPane.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import Bonsplit
import Foundation

/// A read-only control-plane projection of one pane rendered inside a
/// multi-pane remote-tmux window mirror.
/// A read-only control-plane projection of one pane in a mirrored tmux session.
@MainActor
struct RemoteTmuxControlPane {
let tmuxPaneID: Int
Expand Down
60 changes: 60 additions & 0 deletions Sources/RemoteTmuxControlPaneLocation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation

/// Resolves one projected tmux pane to its sole mutation owner. Production
/// session workspaces always use their session mirror; a standalone window
/// mirror can own mutations only when no session is bound to the workspace.
@MainActor
struct RemoteTmuxControlPaneLocation {
let containerPanelID: UUID
let owner: any RemoteTmuxControlPaneMutationOwner
let windowMirror: RemoteTmuxWindowMirror?
let pane: RemoteTmuxControlPane

func controlFocus() -> Bool {
owner.controlFocus(pane: pane.tmuxPaneID)
}

func sendInput(_ text: String) -> Bool {
owner.sendInput(toPane: pane.tmuxPaneID, text: text)
}

func sendKey(_ name: String) -> RemoteTmuxControlKeySendResult {
owner.sendKey(toPane: pane.tmuxPaneID, name: name)
}

func requestSplit(vertical: Bool) -> Bool {
owner.requestSplit(fromPane: pane.tmuxPaneID, vertical: vertical)
}

func requestResizePane(_ tmuxPaneID: Int, direction: String, amountCells: Int) -> Bool {
owner.requestResizePane(tmuxPaneID, direction: direction, amountCells: amountCells)
}

func requestResizePane(_ tmuxPaneID: Int, absoluteAxis: String, targetCells: Int) -> Bool {
owner.requestResizePane(tmuxPaneID, absoluteAxis: absoluteAxis, targetCells: targetCells)
}

func requestResizePane(
_ tmuxPaneID: Int,
absoluteAxis: String,
targetPercentage: Int
) -> Bool {
owner.requestResizePane(
tmuxPaneID,
absoluteAxis: absoluteAxis,
targetPercentage: targetPercentage
)
}

func requestRespawn(command: String, workingDirectory: String?) -> Bool {
owner.requestRespawnPane(
pane.tmuxPaneID,
command: command,
workingDirectory: workingDirectory
)
}

func requestKill() -> Bool {
owner.requestKillPane(pane.tmuxPaneID)
}
}
27 changes: 27 additions & 0 deletions Sources/RemoteTmuxControlPaneMutationOwner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation

/// Mutation boundary shared by session-owned pane projections and deliberately
/// standalone window-mirror fixtures.
@MainActor
protocol RemoteTmuxControlPaneMutationOwner: AnyObject {
func controlFocus(pane tmuxPaneID: Int) -> Bool
func sendInput(toPane tmuxPaneID: Int, text: String) -> Bool
func sendKey(
toPane tmuxPaneID: Int,
name: String
) -> RemoteTmuxControlKeySendResult
func requestSplit(fromPane tmuxPaneID: Int, vertical: Bool) -> Bool
func requestResizePane(_ tmuxPaneID: Int, direction: String, amountCells: Int) -> Bool
func requestResizePane(_ tmuxPaneID: Int, absoluteAxis: String, targetCells: Int) -> Bool
func requestResizePane(
_ tmuxPaneID: Int,
absoluteAxis: String,
targetPercentage: Int
) -> Bool
func requestRespawnPane(
_ tmuxPaneID: Int,
command: String,
workingDirectory: String?
) -> Bool
func requestKillPane(_ tmuxPaneID: Int) -> Bool
}
4 changes: 3 additions & 1 deletion Sources/RemoteTmuxController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,9 @@ final class RemoteTmuxController {
seededSessionId: sessionId,
connection: connection,
tabManager: tabManager,
workspace: workspace
workspace: workspace,
onControlPaneRemoved: TerminalController.remoteTmuxControlPaneRemovalHandler(),
onControlSurfaceRemoved: TerminalController.remoteTmuxControlSurfaceRemovalHandler()
)
return true
}
Expand Down
Loading
Loading