diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLocalization.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLocalization.swift index 0a176e9e1f18..93ce55dc6f73 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLocalization.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLocalization.swift @@ -2,6 +2,8 @@ import Foundation /// Resolves diagnostic copy from the shared package's locale catalog. struct DiagnosticLocalization: Sendable { + private final class BundleFinder {} + let locale: Locale private let bundle: Bundle @@ -23,16 +25,17 @@ struct DiagnosticLocalization: Sendable { } private static func bundle(for locale: Locale) -> Bundle { - languageBundle(for: locale) ?? .module + languageBundle(for: locale) ?? packageResourceBundle ?? .main } private static func languageBundle(for locale: Locale) -> Bundle? { + guard let packageResourceBundle else { return nil } let identifiers = Bundle.preferredLocalizations( - from: Bundle.module.localizations, + from: packageResourceBundle.localizations, forPreferences: [locale.identifier] ) for identifier in identifiers { - guard let path = Bundle.module.path( + guard let path = packageResourceBundle.path( forResource: identifier, ofType: "lproj" ), let bundle = Bundle(path: path) else { continue } @@ -40,4 +43,27 @@ struct DiagnosticLocalization: Sendable { } return nil } + + /// SwiftPM normally synthesizes `Bundle.module` for this lookup. That + /// accessor traps when a tagged app is replaced while its previous process + /// is still starting, because the old process can briefly observe a bundle + /// whose package resources have moved. Keep the lookup optional so + /// diagnostics fall back to their supplied English defaults instead of + /// turning startup telemetry into a process-wide fatal error. + private static let packageResourceBundle: Bundle? = { + let bundleName = "CMUXMobileCore_CMUXMobileCore" + let resourceRoots = [ + Bundle.main.resourceURL, + Bundle(for: BundleFinder.self).resourceURL, + Bundle.main.bundleURL, + ] + for root in resourceRoots { + guard let root else { continue } + let url = root.appendingPathComponent(bundleName + ".bundle") + if let bundle = Bundle(url: url) { + return bundle + } + } + return nil + }() } diff --git a/Packages/Shared/CmuxIrohTransport/Package.resolved b/Packages/Shared/CmuxIrohTransport/Package.resolved index c14e6dfd49fa..0f99e4e776fa 100644 --- a/Packages/Shared/CmuxIrohTransport/Package.resolved +++ b/Packages/Shared/CmuxIrohTransport/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "8da966cad36377e4e55569f80dcde1be91d30a5b", - "version" : "1.0.2-cmux.7.ios17.2" + "revision" : "af08f0e1b9bb3ddb839210b175738d5761fea686", + "version" : "1.0.2-cmux.7.ios17.3" } } ], diff --git a/Packages/Shared/CmuxIrohTransport/Package.swift b/Packages/Shared/CmuxIrohTransport/Package.swift index 7fbd8a20b18a..4b8cb0154d0a 100644 --- a/Packages/Shared/CmuxIrohTransport/Package.swift +++ b/Packages/Shared/CmuxIrohTransport/Package.swift @@ -18,7 +18,7 @@ let package = Package( .package(path: "../CMUXMobileCore"), .package( url: "https://github.com/manaflow-ai/iroh-ffi.git", - exact: "1.0.2-cmux.7.ios17.2" + exact: "1.0.2-cmux.7.ios17.3" ), ], targets: [ diff --git a/Packages/Shared/CmuxIrxTransport/Package.resolved b/Packages/Shared/CmuxIrxTransport/Package.resolved index 9d3d360482e2..2cb07f5ac28f 100644 --- a/Packages/Shared/CmuxIrxTransport/Package.resolved +++ b/Packages/Shared/CmuxIrxTransport/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "8da966cad36377e4e55569f80dcde1be91d30a5b", - "version" : "1.0.2-cmux.7.ios17.2" + "revision" : "af08f0e1b9bb3ddb839210b175738d5761fea686", + "version" : "1.0.2-cmux.7.ios17.3" } } ], diff --git a/Packages/Shared/CmuxIrxTransport/Package.swift b/Packages/Shared/CmuxIrxTransport/Package.swift index bd2e992da95e..debd5e278e28 100644 --- a/Packages/Shared/CmuxIrxTransport/Package.swift +++ b/Packages/Shared/CmuxIrxTransport/Package.swift @@ -21,7 +21,7 @@ let package = Package( .package(path: "../CmuxIrohTransport"), .package( url: "https://github.com/manaflow-ai/iroh-ffi.git", - exact: "1.0.2-cmux.7.ios17.2" + exact: "1.0.2-cmux.7.ios17.3" ), ], targets: [ diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RemoteReplay.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RemoteReplay.swift new file mode 100644 index 000000000000..46e00659b039 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RemoteReplay.swift @@ -0,0 +1,23 @@ +public import Foundation +import GhosttyKit + +extension TerminalSurface { + /// Enqueues replacement output and refreshes after the parser has applied it. + /// + /// A Cloud snapshot is a replacement state, so refreshing when its bytes + /// are merely admitted can present the previous IOSurface contents. The + /// completion runs after the generation FIFO has parsed the bytes. + @MainActor + public func processRemoteReplay( + _ data: Data, + onApplied: @escaping @MainActor @Sendable () -> Void + ) { + guard !data.isEmpty, + let surface = liveSurfaceForGhosttyAccess(reason: "remoteReplay") else { + processRemoteOutput(data) + return + } + flushPendingRemoteOutput(to: surface) + remoteOutputLane.enqueue(data, to: surface, onApplied: onApplied) + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceRemoteOutputLane.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceRemoteOutputLane.swift index c1143f5a9e10..22b613cef3c6 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceRemoteOutputLane.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceRemoteOutputLane.swift @@ -30,7 +30,11 @@ final class TerminalSurfaceRemoteOutputLane: @unchecked Sendable { } /// Enqueues one ordered output batch and its refresh signal. - func enqueue(_ data: Data, to surface: ghostty_surface_t) { + func enqueue( + _ data: Data, + to surface: ghostty_surface_t, + onApplied: (@MainActor @Sendable () -> Void)? = nil + ) { guard !data.isEmpty else { return } // Raw pointers are represented as bits across the Sendable queue // boundary; the lane fence owns the native lifetime until this work @@ -51,6 +55,9 @@ final class TerminalSurfaceRemoteOutputLane: @unchecked Sendable { ghostty_surface_process_output(surface, baseAddress, UInt(rawBuffer.count)) } ghostty_surface_refresh(surface) + if let onApplied { + Task { @MainActor in onApplied() } + } } } } diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 49f53a160b3a..2250b0887990 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -23,7 +23,6 @@ final class CloudTuiManualMirrorSession { private(set) var remoteSurfaceID: UInt64 let inputRouter: CloudTuiManualIOInputRouter let imagePaste = CloudImagePasteCoordinator() - private let operations: CloudOperationRecorder? private var diagnosticContext: CloudOperationContext? private var creationAttachment: CloudCreationAttachment? @@ -50,6 +49,9 @@ final class CloudTuiManualMirrorSession { private var attachResponseReceived = false private var claimInFlight = false private var geometryClaimed = false + private var geometryClaimBlockedByPeer = false + private var explicitGeometryClaimPending = false + private var geometryClaimLossPending = false private var geometryClaimEligible: Bool /// Older daemons do not know `set-client-sizing`. In that case the /// recorded `resize-surface` report is still useful, so the scheduler can @@ -58,7 +60,6 @@ final class CloudTuiManualMirrorSession { /// Retained for diagnostics and for a future targeted detach. Closing the /// socket is still the cleanup fence for peers without lease support. private var remoteLease: String? - private var replayNeedsReset = false /// The last sidecar fed to the local surface; the next one is applied as a delta from it. private var appliedRemoteColors = CloudTuiRemoteColors() private var hasReceivedRemoteReplay = false @@ -240,8 +241,8 @@ final class CloudTuiManualMirrorSession { ) } } - geometryClaimed = false - geometryClaimEligible = false + (geometryClaimed, geometryClaimBlockedByPeer, geometryClaimLossPending) = (false, false, false) + explicitGeometryClaimPending = false claimUnsupported = false claimInFlight = false discardPendingSizingRequests() @@ -286,9 +287,6 @@ final class CloudTuiManualMirrorSession { /// a reset screen. private func tearDownConnection() { watchdog.cancel() - if hasReceivedRemoteReplay { - replayNeedsReset = true - } connectTask?.cancel() connectTask = nil eventTask?.cancel() @@ -300,7 +298,8 @@ final class CloudTuiManualMirrorSession { pendingRequests.removeAll(keepingCapacity: true) attachResponseReceived = false claimInFlight = false - geometryClaimed = false + (geometryClaimed, geometryClaimBlockedByPeer, geometryClaimLossPending) = (false, false, false) + explicitGeometryClaimPending = false claimUnsupported = false remoteLease = nil serverCapabilities.removeAll(keepingCapacity: true) @@ -426,10 +425,7 @@ final class CloudTuiManualMirrorSession { /// is also used by the composed explicit-input callback. func claimGeometry() { guard surface?.isRendererPortalVisible == true else { return } - geometryClaimEligible = true - // Another local projection may have claimed the shared terminal since - // our last report. Treat an explicit focus/input edge as a fresh claim - // opportunity instead of trusting the stale local flag. + (geometryClaimEligible, geometryClaimBlockedByPeer, explicitGeometryClaimPending, geometryClaimLossPending) = (true, false, true, false) geometryClaimed = false claimUnsupported = false sendClaimIfNeeded() @@ -569,15 +565,17 @@ final class CloudTuiManualMirrorSession { inputRouter.updateSurfaceID(surfaceID) } guard surfaceID == remoteSurfaceID else { return } - applyReplay(bytes, reset: replayNeedsReset) - applyColors(colors) - replayNeedsReset = false + // A snapshot replaces the local VT state. Reset first so cells, + // cursor state, alternate-screen mode, and SGR from a prior + // restore cannot survive where the replacement is shorter. + applyReplay(bytes, colors: colors) hasReceivedRemoteReplay = true diagnosticReplayReceived = true if phase == .attached { finishDiagnostics() } updatePresentationEpisode() synchronizePresentation() lastRemoteGrid = CloudTuiManualIOGrid(columns: columns, rows: rows) + if geometryClaimLossPending { geometryClaimLossPending = false; geometryClaimBlockedByPeer = !explicitGeometryClaimPending && lastRemoteGrid != resizeScheduler.desired; geometryClaimed = geometryClaimed && !geometryClaimBlockedByPeer } reconcileRemoteGrid() case let .output(surfaceID, bytes, colors): guard surfaceID == remoteSurfaceID else { return } @@ -588,14 +586,14 @@ final class CloudTuiManualMirrorSession { // `resized` carries a replacement replay, not an incremental // output chunk. Resetting first prevents old rows/cursor state from // surviving a shrink or a reconnect. - applyReplay(bytes, reset: true) - applyColors(colors) + applyReplay(bytes, colors: colors) hasReceivedRemoteReplay = true diagnosticReplayReceived = true if phase == .attached { finishDiagnostics() } updatePresentationEpisode() synchronizePresentation() lastRemoteGrid = CloudTuiManualIOGrid(columns: columns, rows: rows) + if geometryClaimLossPending { geometryClaimLossPending = false; geometryClaimBlockedByPeer = !explicitGeometryClaimPending && lastRemoteGrid != resizeScheduler.desired; geometryClaimed = geometryClaimed && !geometryClaimBlockedByPeer } reconcileRemoteGrid() case let .colorsChanged(surfaceID, colors): guard surfaceID == remoteSurfaceID else { return } @@ -623,16 +621,19 @@ final class CloudTuiManualMirrorSession { break } } - - private func applyReplay(_ bytes: Data, reset: Bool) { - if reset { - // Drop every remote color before the reset rather than trusting - // RIS to do it: the replay's own sidecar re-applies the authored - // set in full, so the pane ends in the same state either way. - applyColors(CloudTuiRemoteColors()) - surface?.processRemoteOutput(Self.replayReset) + private func applyReplay(_ bytes: Data, colors: CloudTuiRemoteColors?) { + // A sidecar replaces authored colors; an absent sidecar preserves them. + // Restore the authoritative set after resetting the replacement VT state. + let replayColors = colors ?? appliedRemoteColors + var replay = CloudTuiRemoteColors().oscDelta(from: appliedRemoteColors) + replay.append(Self.replayReset) + replay.append(bytes) + replay.append(replayColors.oscBytes) + appliedRemoteColors = replayColors + guard let surface else { return } + surface.processRemoteReplay(replay) { [weak surface] in + surface?.forceRefresh(reason: "cloud.replay.applied") } - surface?.processRemoteOutput(bytes) } /// The replay is theme-portable: it carries no palette or default-color @@ -829,15 +830,12 @@ final class CloudTuiManualMirrorSession { transitionToDisconnected(reason: .rejected("attachment superseded")) return } - if outcome == "passive" { - // Another view owns this terminal's geometry. Keep the local - // sample, but make the explicit claim the next operation so a - // focused pane can take authority back deterministically. - geometryClaimed = false + if outcome == "passive" || (accepted == false && geometryClaimed && lastRemoteGrid != nil && lastRemoteGrid != requestedGrid) { + (geometryClaimed, geometryClaimBlockedByPeer, geometryClaimLossPending) = (false, !explicitGeometryClaimPending, false) claimUnsupported = false + } else if accepted == false && geometryClaimed && lastRemoteGrid == nil { + geometryClaimLossPending = true } - // A report is useful even when it was passive. Hold the newest - // sample while the explicit geometry claim is in flight. let next = resizeScheduler.acknowledge( requestedGrid, canSend: geometryClaimed || claimUnsupported @@ -853,6 +851,7 @@ final class CloudTuiManualMirrorSession { claimInFlight = false if ok, surface?.isRendererPortalVisible == true { geometryClaimed = true + explicitGeometryClaimPending = false claimUnsupported = false } else if Self.isUnsupportedClaimError(error) { // Keep compatibility with protocol-v5/v6 peers. Their @@ -971,10 +970,11 @@ final class CloudTuiManualMirrorSession { surface?.isRendererPortalVisible == true, surface?.isNativeViewInRealWindow == true, geometryClaimEligible, + !geometryClaimBlockedByPeer, !geometryClaimed, !claimUnsupported, !claimInFlight, - resizeScheduler.inFlight != nil || resizeScheduler.lastAcknowledged != nil, + resizeScheduler.lastAcknowledged != nil, let connection else { return } manualMirrorLogger.info("geometry terminal=\(self.terminalID, privacy: .private(mask: .hash)) decision=claim") claimInFlight = true diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 06d7764be30f..145356536c25 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -639,11 +639,6 @@ extension Workspace { ) } guard let effectiveRestorableAgent else { return nil } - let confirmedRuntimeProcessIdentities = confirmedRuntimeAgentProcessIdentities( - for: effectiveRestorableAgent, - panelId: panelId, - currentProcessIdentity: currentAgentProcessIdentity - ) let matchingObservation = restorableAgentObservation?.matchingAgentSession( kind: effectiveRestorableAgent.kind.rawValue, sessionId: effectiveRestorableAgent.sessionId @@ -655,6 +650,11 @@ extension Workspace { ) { return true } + let confirmedRuntimeProcessIdentities = confirmedRuntimeAgentProcessIdentities( + for: effectiveRestorableAgent, + panelId: panelId, + currentProcessIdentity: currentAgentProcessIdentity + ) guard let matchingObservation else { return false } if let resumeBinding { return matchingObservation.wasRunningForSnapshot( diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index 131547861a20..669f6d1d0349 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -3582,18 +3582,21 @@ impl Terminal { segment_ends.insert(range.start); } segment_ends.insert(range.end); - // A replay without image placement anchors can let the target terminal - // recreate soft wraps naturally. Placement commands depend on physical - // row cursor positions, so retain the legacy row-delimited form for any - // range that intersects an occupied placement span. - let preserve_soft_wrap = !insert_at_start && !has_placement_anchor; - let mut bytes = Vec::new(); let mut insertion_offsets = BTreeMap::new(); let mut segment_start = range.start; let replay_rows = range.end - range.start + 1; let screen_rows = u64::from(self.rows().max(1)); - let history_bearing = replay_rows > screen_rows; + // A replay with retained scrollback can contain exactly one viewport + // of rows while still carrying a sparse history prefix in Ghostty's + // screen coordinate space. Keep every row boundary in that case so + // the target cannot retain stale history above the active TUI. + let history_bearing = self.history_rows() > 0 || replay_rows > screen_rows; + // A replay without image placement anchors can let the target terminal + // recreate soft wraps naturally. Placement commands and history-bearing + // ranges depend on physical row cursor positions, so retain the + // row-delimited form for those cases. + let preserve_soft_wrap = !history_bearing && !insert_at_start && !has_placement_anchor; let mut emitted_breaks = 0usize; for segment_end in segment_ends { if segment_end < segment_start { @@ -5236,6 +5239,29 @@ mod tests { assert_eq!(target.viewport_text().unwrap(), expected); } + #[test] + fn vt_replay_preserves_blank_tail_after_history() { + let mut source = Terminal::new(20, 8, 100, Callbacks::default()).unwrap(); + for _ in 0..12 { + source.vt_write(b"history\r\n"); + } + source.vt_write(b"\x1b[2J\x1b[HHEADER\x1b[5;1H> Ask Codex\x1b[6;1HSTATUS\x1b[5;3H"); + let expected = source.viewport_text().unwrap(); + let replay = source.vt_replay_bounded_theme_portable(128 * 1024).unwrap(); + let mut restored = Terminal::new(20, 8, 100, Callbacks::default()).unwrap(); + restored.vt_write(&replay); + + assert_eq!(restored.viewport_text().unwrap(), expected); + assert_eq!(restored.cursor_position(), source.cursor_position()); + + // A TUI continues with absolute-cell diffs after attaching. Its header, + // composer and cursor must still agree on the same physical rows. + let update = b"\x1b[5;3HInput\x1b[6;1HDONE\x1b[5;8H"; + source.vt_write(update); + restored.vt_write(update); + assert_eq!(restored.viewport_text().unwrap(), source.viewport_text().unwrap()); + } + #[test] fn theme_portable_replay_retains_aliases_for_admitted_kitty_images() { let mut source = Terminal::new(20, 4, 100, Callbacks::default()).unwrap(); diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 30b2c922c8ee..f13140f8904b 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -795,6 +795,9 @@ C12625000000000000000004 /* CloudRefreshURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12625000000000000000003 /* CloudRefreshURLProtocol.swift */; }; C12449010000000000000001 /* CloudRemoteColorOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12449020000000000000001 /* CloudRemoteColorOwnershipTests.swift */; }; C12575000000000000000050 /* CloudRenameOptimismTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12575000000000000000051 /* CloudRenameOptimismTests.swift */; }; + C12625A00000000000000002 /* CloudRequestClock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12625A00000000000000001 /* CloudRequestClock.swift */; }; + EFD5BEC316FCF70B7E4FDFBE /* CloudRestoreReplayFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */; }; + 9A1E85136C5F9BDC2E66DC08 /* CloudRestoreReplayGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD277217AB02219F074EDADA /* CloudRestoreReplayGridTests.swift */; }; 46383D21CF46427F36EBEB7B /* CloudSidebarAcceptanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8150EA26C949866059F3B6EA /* CloudSidebarAcceptanceTests.swift */; }; 5E3484F75CE048CC95018CF1 /* CloudSidebarAttentionLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DB4F197C29A4C03B61418A1 /* CloudSidebarAttentionLayoutTests.swift */; }; D075FB64A68C4CA0A4042528 /* CloudSidebarConsistencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B27A4938294E4A109BC6D2B3 /* CloudSidebarConsistencyTests.swift */; }; @@ -4886,6 +4889,9 @@ C12625000000000000000003 /* CloudRefreshURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshURLProtocol.swift; sourceTree = ""; }; C12449020000000000000001 /* CloudRemoteColorOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRemoteColorOwnershipTests.swift; sourceTree = ""; }; C12575000000000000000051 /* CloudRenameOptimismTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRenameOptimismTests.swift; sourceTree = ""; }; + C12625A00000000000000001 /* CloudRequestClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRequestClock.swift; sourceTree = ""; }; + 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudRestoreReplayFixture.swift"; sourceTree = ""; }; + DD277217AB02219F074EDADA /* CloudRestoreReplayGridTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudRestoreReplayGridTests.swift"; sourceTree = ""; }; 8150EA26C949866059F3B6EA /* CloudSidebarAcceptanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudSidebarAcceptanceTests.swift"; sourceTree = ""; }; 5DB4F197C29A4C03B61418A1 /* CloudSidebarAttentionLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudSidebarAttentionLayoutTests.swift"; sourceTree = ""; }; B27A4938294E4A109BC6D2B3 /* CloudSidebarConsistencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSidebarConsistencyTests.swift; sourceTree = ""; }; @@ -12076,6 +12082,8 @@ 511EAB11A81C099E4F5A8ECF /* DeferredAgentResumeAdmissionOwnerTests.swift */, C7E3FFB9B84F6DE45250D5D5 /* CodexSessionStartDeadTurnTests.swift */, 0B0073F9140EA86D5ADEE233 /* CodexStaleTurnRestoreIntentTests.swift */, + 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */, + DD277217AB02219F074EDADA /* CloudRestoreReplayGridTests.swift */, 9BE0A210ACEDFB6FB21BBE0E /* AgentRestoreIssue12775Tests.swift */, 25DB476025F922037411EA98 /* CommandPaletteAuthCommandTests.swift */, F0677DB157F30F725FECA0EC /* CMUXCLICodexUnavailableAdmissionTests.swift */, @@ -15880,6 +15888,8 @@ C12625000000000000000004 /* CloudRefreshURLProtocol.swift in Sources */, C12449010000000000000001 /* CloudRemoteColorOwnershipTests.swift in Sources */, C12575000000000000000050 /* CloudRenameOptimismTests.swift in Sources */, + EFD5BEC316FCF70B7E4FDFBE /* CloudRestoreReplayFixture.swift in Sources */, + 9A1E85136C5F9BDC2E66DC08 /* CloudRestoreReplayGridTests.swift in Sources */, 46383D21CF46427F36EBEB7B /* CloudSidebarAcceptanceTests.swift in Sources */, 5E3484F75CE048CC95018CF1 /* CloudSidebarAttentionLayoutTests.swift in Sources */, D075FB64A68C4CA0A4042528 /* CloudSidebarConsistencyTests.swift in Sources */, @@ -17509,7 +17519,7 @@ repositoryURL = "https://github.com/manaflow-ai/iroh-ffi.git"; requirement = { kind = exactVersion; - version = "1.0.2-cmux.7.ios17.2"; + version = "1.0.2-cmux.7.ios17.3"; }; }; A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */ = { diff --git a/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 606f3cbf7a1e..3f05350f097c 100644 --- a/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "8da966cad36377e4e55569f80dcde1be91d30a5b", - "version" : "1.0.2-cmux.7.ios17.2" + "revision" : "af08f0e1b9bb3ddb839210b175738d5761fea686", + "version" : "1.0.2-cmux.7.ios17.3" } }, { diff --git a/cmuxTests/CloudManualMirrorSocketFixture.swift b/cmuxTests/CloudManualMirrorSocketFixture.swift index c5a691f64501..28144a156372 100644 --- a/cmuxTests/CloudManualMirrorSocketFixture.swift +++ b/cmuxTests/CloudManualMirrorSocketFixture.swift @@ -10,6 +10,8 @@ struct CloudManualMirrorFixtureCommand: Sendable { let expectedTerminalID: String? let id: UInt64 let surface: UInt64? + let columns: Int? + let rows: Int? let capabilities: [String] let hasInitialSize: Bool let imageOperation: String? @@ -28,6 +30,8 @@ struct CloudManualMirrorFixtureCommand: Sendable { inputBytes = (object["bytes"] as? String).flatMap { Data(base64Encoded: $0) } id = (object["id"] as? NSNumber)?.uint64Value ?? 0 surface = (object["surface"] as? NSNumber)?.uint64Value + columns = object["cols"] as? Int + rows = object["rows"] as? Int capabilities = object["capabilities"] as? [String] ?? [] hasInitialSize = object["cols"] != nil || object["rows"] != nil imageOperation = object["op"] as? String diff --git a/cmuxTests/CloudRestoreReplayFixture.swift b/cmuxTests/CloudRestoreReplayFixture.swift new file mode 100644 index 000000000000..c66eed9c682a --- /dev/null +++ b/cmuxTests/CloudRestoreReplayFixture.swift @@ -0,0 +1,128 @@ +import AppKit +import CmuxTerminal +import Foundation +import GhosttyKit +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Owns a real manual-I/O Ghostty terminal and its scripted daemon socket. +@MainActor +final class CloudRestoreReplayFixture { + private let workspace = TerminalPortalTestWorkspace() + let surface: TerminalSurface + private let window: NSWindow + let socket: CloudManualMirrorSocketFixture + private let session: CloudTuiManualMirrorSession + + init(initiallyClaimsGeometry: Bool = true) throws { + _ = NSApplication.shared + socket = try CloudManualMirrorSocketFixture() + session = CloudTuiManualMirrorSession( + machineID: "restore-grid-test", terminalID: "term_restore_grid", + remoteSurfaceID: 17, initiallyClaimsGeometry: initiallyClaimsGeometry, + onNeedsReconnect: {} + ) + surface = TerminalSurface( + tabId: workspace.id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, ioMode: .manualMirror, manualInputHandler: { _ in } + ) + surface.setManualIONoReflow(false) + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .closable], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + let content = try #require(window.contentView) + let hosted = surface.hostedView + hosted.frame = content.bounds + content.addSubview(hosted) + content.layoutSubtreeIfNeeded() + hosted.setVisibleInUI(false) + hosted.setActive(false) + session.bind(surface: surface) + } + + func setGrid(columns: Int, rows: Int) async throws { + try await waitUntil { self.surface.hasLiveSurface } + let runtime = try #require(surface.surface) + #expect(ghostty_surface_set_grid_size(runtime, UInt16(columns), UInt16(rows), nil)) + // The app-facing size cache leads Ghostty's IO-thread resize. Read + // actual terminal rows through the existing render-grid export. + try await waitUntil { + let frame = self.surface.mobileRenderGridFrame( + stateSeq: 0, scrollbackLines: 0, includeTheme: false + )?.frame + return frame?.columns == columns && frame?.rows == rows + } + } + + func attach(replay: Data) async throws { + session.reconnect(socketPath: socket.socketPath) + let identify = try #require(await socket.nextCommand(timeout: .seconds(5))) + #expect(identify.cmd == "identify") + socket.send(["id": identify.id, "ok": true, "data": ["capabilities": ["attach-initial-size"]]]) + let registration = try #require(await socket.nextCommand(timeout: .seconds(5))) + #expect(registration.cmd == "set-client-info") + socket.send(["id": registration.id, "ok": true, "data": [:]]) + let attach = try #require(await socket.nextCommand(timeout: .seconds(5))) + #expect(attach.cmd == "attach-surface") + #expect(!attach.hasInitialSize, "Hidden restores must not claim their temporary grid") + socket.send(["id": attach.id, "ok": true, "data": [:]]) + try await deliver(replay, event: "vt-state", marker: "STATUS_READY") + try await waitUntil { self.session.phase == .attached } + } + + func setVisible(_ visible: Bool) { + surface.hostedView.setVisibleInUI(visible) + } + + func focus() { session.claimGeometry() } + + func seedLocalOutput(_ bytes: Data, marker: String) async throws { + surface.processRemoteOutput(bytes) + try await waitUntil { self.surface.readText(region: .screen)?.contains(marker) == true } + } + + func expectInputAfterPendingResponses(marker: String) async throws { + let bytes = Data(marker.utf8) + // The marker follows earlier replies on the incoming stream. Once + // parsed, input is queued behind every command those replies emitted. + // Seeing it next proves absence without a timed observation window. + try await deliver(bytes, event: "output", marker: marker) + session.inputRouter.send(.bytes(bytes)) + let input = try #require(await socket.nextCommand(timeout: .seconds(5))) + #expect(input.cmd == "send") + #expect(input.inputBytes == bytes) + } + + func deliver(_ bytes: Data, event: String, marker: String, colors: [String: Any]? = nil) async throws { + var payload: [String: Any] = [ + "event": event, "surface": 17, "cols": 80, "rows": 24, + "data": bytes.base64EncodedString() + ] + if let colors { payload["colors"] = colors } + socket.send(payload) + try await waitUntil { self.surface.readText(region: .screen)?.contains(marker) == true } + } + + func close() { + session.stop() + socket.close() + surface.teardownSurface() + window.orderOut(nil) + workspace.tearDown() + } + + private func waitUntil(_ condition: @MainActor () -> Bool) async throws { + let deadline = ContinuousClock.now + .seconds(5) + while !condition(), ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + try #require(condition(), "Timed out waiting for the native terminal state") + } +} diff --git a/cmuxTests/CloudRestoreReplayGridTests.swift b/cmuxTests/CloudRestoreReplayGridTests.swift new file mode 100644 index 000000000000..319f051cf28a --- /dev/null +++ b/cmuxTests/CloudRestoreReplayGridTests.swift @@ -0,0 +1,110 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// A hidden restore releases its old geometry contribution. Reveal must +/// reclaim the final pane size without waiting for focus or a keystroke. +@MainActor +@Suite(.serialized, .timeLimit(.minutes(1))) +struct CloudRestoreReplayGridTests { + @Test(arguments: ["vt-state", "resized"]) + func replayWithoutSidecarPreservesAuthoredColors(event: String) async throws { + let fixture = try CloudRestoreReplayFixture() + defer { fixture.close() } + try await fixture.setGrid(columns: 80, rows: 24) + try await fixture.attach(replay: Data("STATUS_READY".utf8)) + try await fixture.deliver( + Data("AUTHORED".utf8), event: "vt-state", marker: "AUTHORED", + colors: ["overrides": ["fg": "#123456", "bg": "#654321"]] + ) + try await fixture.expectInputAfterPendingResponses(marker: "COLOR_APPLIED") + let before = try #require(fixture.surface.mobileRenderGridFrame( + stateSeq: 0, scrollbackLines: 0, includeTheme: true + )?.frame) + #expect(before.terminalForeground == "#123456") + #expect(before.terminalBackground == "#654321") + try await fixture.deliver(Data("REPLACEMENT".utf8), event: event, marker: "REPLACEMENT") + try await fixture.expectInputAfterPendingResponses(marker: "REPLAY_APPLIED") + let after = try #require(fixture.surface.mobileRenderGridFrame( + stateSeq: 0, scrollbackLines: 0, includeTheme: true + )?.frame) + #expect(after.terminalForeground == before.terminalForeground) + #expect(after.terminalBackground == before.terminalBackground) + } + + @Test + func restoredSnapshotReplacesStaleLocalCells() async throws { + let fixture = try CloudRestoreReplayFixture() + defer { fixture.close() } + try await fixture.setGrid(columns: 80, rows: 24) + try await fixture.seedLocalOutput(Data("STALE_COMPOSER".utf8), marker: "STALE_COMPOSER") + try await fixture.attach(replay: Data("FRESH_COMPOSER STATUS_READY".utf8)) + + let screen = try #require(fixture.surface.readText(region: .screen)) + #expect(screen.contains("FRESH_COMPOSER")) + #expect(!screen.contains("STALE_COMPOSER")) + } + + @Test + func hiddenRestoreReclaimsGeometryWithoutInput() async throws { + let fixture = try CloudRestoreReplayFixture() + defer { fixture.close() } + try await fixture.setGrid(columns: 99, rows: 35) + + // The pane takes its normal visible -> hidden restoration edge before + // the machine connects. No terminal focus or input follows the reveal. + fixture.setVisible(true) + fixture.setVisible(false) + try await fixture.attach(replay: Data("STATUS_READY".utf8)) + fixture.setVisible(true) + + let report = try #require(await fixture.socket.nextCommand(timeout: .seconds(5))) + #expect(report.cmd == "resize-surface") + #expect(report.surface == 17) + #expect(report.columns == 99) + #expect(report.rows == 35) + // Legacy resize-surface replies use accepted=false for an applied + // report; the first visible mirror must still promote itself. + fixture.socket.send(["id": report.id, "ok": true, "data": ["accepted": false, "outcome": "applied"]]) + let claim = try #require( + await fixture.socket.nextCommand(timeout: .seconds(5)), + "A visible restored pane must claim its reported grid without requiring focus" + ) + #expect(claim.cmd == "set-client-sizing") + #expect(claim.surface == 17) + } + + @Test + func intentionallyPassiveMirrorStillWaitsForExplicitFocus() async throws { + let fixture = try CloudRestoreReplayFixture(initiallyClaimsGeometry: false) + defer { fixture.close() } + try await fixture.setGrid(columns: 99, rows: 35) + fixture.setVisible(true) + fixture.setVisible(false) + try await fixture.attach(replay: Data("STATUS_READY".utf8)) + fixture.setVisible(true) + + let report = try #require(await fixture.socket.nextCommand(timeout: .seconds(5))) + #expect(report.cmd == "resize-surface") + #expect(report.surface == 17) + #expect(report.columns == 99) + #expect(report.rows == 35) + fixture.socket.send(["id": report.id, "ok": true, "data": ["outcome": "passive", "accepted": false]]) + try await fixture.expectInputAfterPendingResponses(marker: "PASSIVE_REPORT_APPLIED") + fixture.focus() + let claim = try #require(await fixture.socket.nextCommand(timeout: .seconds(5))) + #expect(claim.cmd == "set-client-sizing") + #expect(claim.surface == 17) + fixture.socket.send([ + "event": "resized", "surface": 17, "cols": 99, "rows": 35, + "replay": Data("CLAIMED_GRID".utf8).base64EncodedString() + ]) + fixture.socket.send(["id": claim.id, "ok": true, "data": [:]]) + try await fixture.expectInputAfterPendingResponses(marker: "CLAIM_APPLIED") + } +} diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index cc9f2823dbef..4c3649af64cb 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,12 +12,26 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -The submodule pinned by this branch is `c5c31ce819`, the upstream Ghostty -merge commit for PR #218 after the embedded-environment lifetime fix from PR -#227 was merged. It preserves cmux's Cloud loopback link-detection changes -while adding the localhost-port punctuation fix and owned POSIX environment -snapshots for embedded hosts. This SHA is reachable from `manaflow-ai/ghostty` -main and is the release target for cmux's GhosttyKit build workflow. +### Cloud restore replay trailing rows + +- Commit: `a3e9304c5d19c8667f58a342830f774579c74472` +- Summary: preserve trailing physical blank rows until the VT cursor/state + restoration footer when replay requests cursor restoration. Normal formatter + output and soft-wrap behavior are unchanged. +- Verification: hosted Ghostty test workflow passed before the GhosttyKit build; + the cmux replay regression is `vt_replay_preserves_blank_tail_after_history`. +- Artifact: + https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-a3e9304c5d19c8667f58a342830f774579c74472-crashsubdir-cmux-crash-sentry-off-noi18n-v2 +- SHA-256 `98697b9a49b36e835e900f716ac054cf2476d97bf40ea2742454e735ac5aa3a9` + is pinned in `scripts/ghosttykit-checksums.txt`. + +The submodule pinned by this branch is `a3e9304c5d`, a cmux-only replay fix on +top of `c5c31ce819`, the upstream Ghostty merge commit for PR #218 after the +embedded-environment lifetime fix from PR #227 was merged. The replay fix +preserves physical blank rows until cursor/state restoration completes, so a +restored Cloud grid cannot regain stale history rows. The base SHA preserves +cmux's Cloud loopback link-detection changes while adding the localhost-port +punctuation fix and owned POSIX environment snapshots for embedded hosts. The previous pin `35ae29b7c2` is the merge of fork `main` at `3869e81a0` into the Cloud loopback link-detection branch (`46428d790`, bare localhost port links, @@ -34,7 +48,7 @@ fork changes below, including tokened iOS render dispositions, VT formatter cursor restoration, VT stream-boundary visibility, and Hangul canonical font resolution. -### Current feature pin +### Base feature pin - Branch: - https://github.com/manaflow-ai/ghostty/tree/main (contains the Hangul fix @@ -42,7 +56,8 @@ resolution. fork `main`, on the Cloud loopback link-detection branch) - Commit: - `c5c31ce819` (upstream merge of Ghostty #218 after #227; preserves Cloud - loopback behavior and is reachable from `manaflow-ai/ghostty:main`) + loopback behavior and is reachable from `manaflow-ai/ghostty:main`; the + current branch adds `a3e9304c5d` above it) - Summary: - Fixes localhost-port sentence punctuation and owns POSIX environment snapshots retained by embedded Ghostty, on top of the diff --git a/docs/iroh-v2/SECURITY-AUDIT.md b/docs/iroh-v2/SECURITY-AUDIT.md index bb8a89df820b..1be92efbd39d 100644 --- a/docs/iroh-v2/SECURITY-AUDIT.md +++ b/docs/iroh-v2/SECURITY-AUDIT.md @@ -132,8 +132,8 @@ Drizzle's TypeScript loader. Pinning the transitive copy to `0.28.1` matches the version already used by Wrangler. `bun audit` then reports no advisories; the Drizzle loader transform, Worker check and workerd tests pass. -The pinned Iroh FFI release is `1.0.2-cmux.7.ios17.2`, source -`8da966cad36377e4e55569f80dcde1be91d30a5b`. Querying its 483 registry lockfile +The pinned Iroh FFI release is `1.0.2-cmux.7.ios17.3`, source +`af08f0e1b9bb3ddb839210b175738d5761fea686`. Querying its 483 registry lockfile entries against OSV identified three patchable advisories: | Package | Fix in [iroh-ffi #16](https://github.com/manaflow-ai/iroh-ffi/pull/16) | Reachability qualification | diff --git a/ghostty b/ghostty index c5c31ce81913..a3e9304c5d19 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit c5c31ce819131ebb2deb4c4d4a75beffe4340c8d +Subproject commit a3e9304c5d19c8667f58a342830f774579c74472 diff --git a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved index 390394897df9..3afd759224ba 100644 --- a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "8da966cad36377e4e55569f80dcde1be91d30a5b", - "version" : "1.0.2-cmux.7.ios17.2" + "revision" : "af08f0e1b9bb3ddb839210b175738d5761fea686", + "version" : "1.0.2-cmux.7.ios17.3" } }, { diff --git a/ios/cmuxPackage/Package.resolved b/ios/cmuxPackage/Package.resolved index 641ae9f38c55..fd355a301b1e 100644 --- a/ios/cmuxPackage/Package.resolved +++ b/ios/cmuxPackage/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/manaflow-ai/iroh-ffi.git", "state" : { - "revision" : "8da966cad36377e4e55569f80dcde1be91d30a5b", - "version" : "1.0.2-cmux.7.ios17.2" + "revision" : "af08f0e1b9bb3ddb839210b175738d5761fea686", + "version" : "1.0.2-cmux.7.ios17.3" } }, { diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 549492f1d1b8..fecd0a14dacb 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -130,3 +130,4 @@ abd40f6e472d57f2d4bb182004bb5f3fac8df961 fdb0f7e844fa086a410f0b1df23badf2b0503c0 35ae29b7c2bcee7c721d515d0096a9bc3f3242bb 6f83f20842140a782c8029156aabe92c246a181682794f01ad0a30ca43c76620 370f08cf15a6ab646b9a291f72af034bb0960fb3 ec53b8992b466ecd9cc87b42754188fe504898ff0b139f54b3eef1dc6a441233 c5c31ce819131ebb2deb4c4d4a75beffe4340c8d 4f75749a168712a2b456840309d9603a94039e97a453bd3f98c3ed945826fe7a +a3e9304c5d19c8667f58a342830f774579c74472 98697b9a49b36e835e900f716ac054cf2476d97bf40ea2742454e735ac5aa3a9 diff --git a/scripts/reload.sh b/scripts/reload.sh index 9dbb88cd8539..497d8f90273f 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -2070,16 +2070,6 @@ if ! /usr/bin/codesign --force --sign - --timestamp=none --generate-entitlement- exit 1 fi fi -if [[ "$BUILD_ONLY" -eq 1 && -n "${TAG_APP_STAGING_PATH:-}" ]]; then - # Keep the staged artifact separate from the running tagged app. This mode is - # explicitly for compilation/validation and must not mutate the active bundle. - APP_PATH="$TAG_APP_STAGING_PATH" -elif [[ -n "${TAG_APP_FINAL_PATH:-}" && -n "${TAG_APP_STAGING_PATH:-}" ]]; then - rm -rf "$TAG_APP_FINAL_PATH" - mv "$TAG_APP_STAGING_PATH" "$TAG_APP_FINAL_PATH" - APP_PATH="$TAG_APP_FINAL_PATH" -fi -CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" TAG_LAUNCHD_LABEL="" TAG_LAUNCHD_DOMAIN="" @@ -2088,22 +2078,43 @@ if [[ -n "${TAG_SLUG:-}" ]]; then TAG_LAUNCHD_DOMAIN="gui/$(id -u)" fi -# Tag mode: always terminate the existing same-tag instance after a successful build, -# even without --launch. A stale tagged app pinned to this bundle id would otherwise -# keep running against freshly-overwritten resources, and macOS would foreground it -# instead of launching the newly built binary when the user cmd-clicks the .app. +# Terminate the existing same-tag instance before replacing its bundle. The +# running process resolves SwiftPM resources through its app path; removing +# that path first can make Bundle.module trap during startup while the old +# process is still initializing. if [[ -n "$TAG" && "$BUILD_ONLY" -ne 1 ]]; then /usr/bin/osascript -e "tell application id \"${BUNDLE_ID}\" to quit" >/dev/null 2>&1 || true sleep 0.3 - pkill -f "${APP_NAME}.app/Contents/MacOS/${BASE_APP_NAME}" || true - sleep 0.3 - # Tagged --launch runs are handed off to launchd so they survive the terminal or - # automation process that invoked reload.sh. Remove a still-registered prior job - # after giving the app a chance to quit gracefully. + TAG_PROCESS_PATTERN="${APP_NAME}.app/Contents/MacOS/${BASE_APP_NAME}" + pkill -f "$TAG_PROCESS_PATTERN" || true + for _ in {1..20}; do + if ! pgrep -f "$TAG_PROCESS_PATTERN" >/dev/null 2>&1; then + break + fi + sleep 0.1 + done + # A startup process may not service its quit event yet. Do not replace the + # resource-bearing bundle while it is still mapped; force only this tagged + # executable after the bounded graceful window. + pkill -KILL -f "$TAG_PROCESS_PATTERN" >/dev/null 2>&1 || true + # Tagged --launch runs are handed off to launchd so they survive the terminal + # or automation process that invoked reload.sh. Remove a still-registered + # prior job before publishing the replacement bundle. /bin/launchctl bootout "$TAG_LAUNCHD_DOMAIN/$TAG_LAUNCHD_LABEL" >/dev/null 2>&1 || true /bin/launchctl remove "$TAG_LAUNCHD_LABEL" >/dev/null 2>&1 || true fi +if [[ "$BUILD_ONLY" -eq 1 && -n "${TAG_APP_STAGING_PATH:-}" ]]; then + # Keep the staged artifact separate from the running tagged app. This mode is + # explicitly for compilation/validation and must not mutate the active bundle. + APP_PATH="$TAG_APP_STAGING_PATH" +elif [[ -n "${TAG_APP_FINAL_PATH:-}" && -n "${TAG_APP_STAGING_PATH:-}" ]]; then + rm -rf "$TAG_APP_FINAL_PATH" + mv "$TAG_APP_STAGING_PATH" "$TAG_APP_FINAL_PATH" + APP_PATH="$TAG_APP_FINAL_PATH" +fi +CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" + if [[ "$BUILD_ONLY" -eq 1 ]]; then CAN_PUBLISH_RELOAD_STATE=0 RELOAD_PUBLICATION_SKIP_REASON="build-only mode left the running tagged app and tag state unchanged" diff --git a/tests/test_reload_build_only_keeps_tagged_app.sh b/tests/test_reload_build_only_keeps_tagged_app.sh index e776ff996877..2035750bedca 100755 --- a/tests/test_reload_build_only_keeps_tagged_app.sh +++ b/tests/test_reload_build_only_keeps_tagged_app.sh @@ -61,3 +61,11 @@ set -e || fail "collision refusal did not explain the protected bundle name" echo "PASS: --build-only rejects a name override that aliases the running tagged bundle" +# A normal tagged reload must stop the previous process before replacing the +# final app path. Otherwise a process that is still starting can lose its +# SwiftPM resource bundle and trap in Bundle.module during diagnostics setup. +terminate_line="$(grep -n 'TAG_PROCESS_PATTERN=' "$RELOAD" | head -n1 | cut -d: -f1)" +replace_line="$(grep -n 'rm -rf "\$TAG_APP_FINAL_PATH"' "$RELOAD" | head -n1 | cut -d: -f1)" +[[ -n "$terminate_line" && -n "$replace_line" && "$terminate_line" -lt "$replace_line" ]] \ + || fail "normal tagged reload can replace the app bundle before terminating the prior process" +echo "PASS: normal tagged reload terminates before replacing the final app bundle"